[ddd] 도메인 모델

도메인이란?

  • 온라인 서점 시스템을 구현한다고 할 때, 소프트웨어로 해결하고자 하는 문제의 영역인 온라인 서점이 도메인이 된다
  • 한 도메인은 다시 여러개의 하위 도메인으로 나뉠 수 있다
    • 온라인 서점의 하위 도메인 : 상품, 회원, 주문, 정산, 배송 등등
  • 모든 도메인마다 고정된 하위 도메인이 존재하는 것은 아니다. 상황에 따라 달라진다
    • 대상이 기업인지, 사용자인지 등등
  • 특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 구현하는 것은 아니다
    • 외부 업체의 배송 시스템이나, 결제 시스템 같은것들을 이용한다

도메인 모델

  • 특정 도메인을 개념적으로 표현한 것이다
  • 이를 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고, 도메인 지식을 공유하는데 도움이 된다
    • 도메인을 이해하려면 도메인이 제공하는 기능과 주요 데이터 구성을 파악해야 한다
    • 보통은 기능과 데이터를 함꼐 보여주는 클래스 다이어그램이 적합하다
    • 꼭 UML만 사용해야 하는 것은 아니다. 도메인을 이해하는데 도움이 된다면 표현방식이 무엇인지는 중요하지 않다
  • 여러 하위 도메인을 하나의 다이어그램에 모델링하면 안된다
    • 각 하위 도메인마다 별도로 모델을 만들어야 한다
    • 모델의 각 구성요소는 특정 도메인을 한정할 때 비로소 의미가 완전해지기 때문이다

      카탈로그의 상품과 배송의 상품은 다르다

도메인 모델 패턴(★★★)

일반적인 어플리케이션의 아키텍쳐는 아래의 4계층으로 구성된다

  • Presentation(표현)

    사용자의 요청을 처리하고 사용자에게 정보를 보여준다
    외부 시스템도 사용자가 될 수 있다

  • Application(응용)

    사용자가 요청한 기능을 실행한다
    업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다

  • Domain(도메인)

    시스템이 제공할 도메인의 규칙을 구현한다

  • Intrastructure(인프라스트럭쳐)

    외부 시스템과의 연동을 처리한다(DB, Messaging 등)

여기서 도메인 영역에 해당하는,
도메인의 규칙을 객체지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다

e.g. 주문 상태 변경에 대한 규칙을 처리하는 Order class

1
2
3
4
5
6
7
8
9
10
11
class Order {
// ...

public void changeShippingInfo(ShippingInfo newShippingInfo) {
if(!isShippingChangeable()) {
throw new IllegalStateException();
}

// ...
}
}

주문 상태 변경 로직을 도메인에서 직접 처리하고 있다
이처럼 핵심 규칙을 구현한 코드가 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 된다

모델 작성시 주의사항

개념 모델을 만들때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로 이는 불가능에 가깝다

소프트웨어를 개발하는 동안 도메인을 점점 더 이해하게 되기 때문에, 시간이 지나감에 따라 모델을 수정하는 경우가 많기 때문이다
(지식이 쌓이면서 완전히 다른 의미로 해석되어지는 상황도 존재한다)

그러므로 처음에는 개요 수준의 개념 모델을 작성하여 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야한다

도메인 모델 도출

아무리 천재 개발자라도 도메인에 대한 이해없이 코딩을 시작할수는 없다
기획서, 유스 케이스, 유저 스토리 같은 요구사항과 관련자와의 대화를 통해 도메인을 이해하고, 이를 바탕으로 도메인 모델 초안을 만들어야 코드를 작성할 수 있다

도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.

이는 모두 요구사항을 분석하면서 찾아낼 수 있다

  • 요구사항을 통해 제공해야 하는 기능을 도출해낼 수 있다
    • 출고 상태로 변경하기, 배송지 정보 변경하기 등
  • 요구사항을 통해 특정 항목이 어떤 데이터로 구성되어야 하는지 알 수 있다
    • e.g. 한 상품을 한개 이상 주문할 수 있다
  • 요구사항을 통해 각 항목간의 관계를 알 수 있다
    • e.g. 최소 한 종류 이상의 상품을 주문해야 한다
  • 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우도 많다
    • e.g. 출고를 하면 배송지 정보를 변경할 수 없다
  • 요구사항을 이해하다보면 설계가 바뀌는 경우가 종종 생긴다

문서화
코드는 상세한 모든 내용을 다루고 있기 때문에 코드를 이용해서 전체 소프트웨어를 분석하려면 많은 시간을 투자해야한다
전반적인 기능 목록이나 모듈 구조는 상위 수준에서 정리된 문서를 참조하는 것이 소프트웨어 전반을 빠르게 이해하는데 도움이 된다

코드 자체도 문서화의 대상이 될 수 있다
도메인 지식이 잘 묻어나고, 도메인을 잘 표현하도록 코드를 작성하면 코드의 가독성이 높아지면서 코드가 문서로서의 의미도 가지게 된다

엔티티와 벨류

모델은 크게 엔티티와 벨류로 구분할 수 있다
이 둘의 차이를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있으므로 차이를 명확하게 이해해야 한다

엔티티 타입

  • 가장 큰 특징은 식별자를 갖는다는 것이다
    • 식별자는 엔티티마다 고유해서 각 엔티티는 서로 다른 식별자를 가진다
    • 엔티티를 생성하고 엔티티의 속성을 바꾸고 엔티티를 삭제할 때 까지 식별자는 유지된다
  • 두 엔티티의 식별자가 같으면 두 엔티티는 같다고 볼 수 있다
    • equals와 hashCode를 식별자를 기준으로 구현한다
  • 항상 변화하고, 추적 가능해야 한다

식별자 생성

엔티티 식별자 생성은 도메인의 특징과 사용하는 기술에 따라 달라진다
흔히 식별자는 아래 방식으로 생성한다

  • 특정 규칙에 따라 생성
    • 흔히 사용하는 규칙은 현재 시간과 다른 값을 함께 조합하는 것이다
    • 여기서 주의할 점은 같은 시간에 동시에 식별자를 생성할 때 같은 식별자가 만들어지면 안된다는 것이다
  • UUID
    • 마땅한 규칙이 없을때 사용하면 좋다
    • 대부분의 개발 언어는 UUID 생성기를 제공하고 있다
  • 값 직접 입력
    • 회원 id, email 같은 것이다
    • 직접 입력하는 것이기 때문에 중복되지 않도록 사전에 방지하는 것이 중요하다
  • 일련번호 사용
    • 주로 데이터베이스에서 제공하는 자동 증가 기능을 사용한다

벨류 타입

  • 개념적으로 완전한 하나를 표현할 때 사용한다
    • 우편번호, 주소, 상세주소 는 묶어서 하나의 주소로 표현될 수 있다
  • 보다 명확하게 표현할 수 있게되고, 코드의 가독성도 올라간다
    • 벨류 타입 자체만 봐도 의미를 쉽게 이해할 수 있다
    • 벨류 타입으로 구성된 클래스 또한 쉽게 이해 가능해진다
  • 벨류 타입이 꼭 2개 이상의 데이터를 가져야하는 것은 아니다
    • 의미를 명확하게 하기 위해 int 대신 Money를 쓸 수 있다
  • 밸류 타입을 위한 기능을 추가할 수 있다는 장점도 있다
    • Address를 사용하면 주소를 위한 기능을 Address 클래스에 추가할 수 있다
  • 두 벨류 객체가 같은지 비교할때는 모든 속성이 같은지 비교해야한다

벨류 타입을 불변으로 선언하기

  • 모든 필드를 final private로 만듦
  • 값의 변경이 있을떄마다 해당 필드의 값을 변경하는 것이 아니라 변경된 값을 가진상태의 새로운 객체를 생성해서 반환
  • 추적성과 별칭 문제에 대해 부담이 없어짐
  • 일반적으로 날짜, 금액 등의 작은 개념을 의미하므로 새로운 객체를 만들어도 오버헤드가 적고, 추적성에도 관심을 가질 필요가 없기 떄문에 굳이 동일 객체를 유지할 필요가 없다
  • 기존 객체는 가비지컬렉션의 대상이 됨

엔티티 식별자에 벨류 타입 사용

엔티티의 식별자는 단순한 문자열이 아니라 도메인에서 특별한 의미를 지니는 경우가 많기 때문에, 식별자를 위한 벨류 타입을 사용해서 의미가 잘 드러나도록 할 수 있다

1
2
3
class Order {
private OrderNo no;
}

그냥 String no 라고 썼다면 이게 주문 번호인지 알아보기 힘들었을 것이다
혹은 변수명으로 나타내야 했을텐데, 벨류 타입을 사용하는 것이 좀 더 확실하다

참고로 응용 서비스 계층에서 Order를 조회하는 메서드를 만들때, 파라미터를 OrderNo 대신 String으로 받는다
응용 서비스 계층을 호출하는 프레젠테이션 계층에서 도메인 계층의 요소를 알지 못하게 하기 위함일까?
e.g. orderRepository.findById(new OrderNo(orderNo))

set 사용하지 않기

도메인 모델에 무조건 getter/setter를 추가하는 것은 좋지않은 버릇이다

  • 특히 setter는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다

    • changeShippingInfo()는 배송지 정보를 새로 변경한다는 의미를 가지지만, setShippingInfo()는 단순히 배송지 값을 설정한다는 것을 뜻한다
    • 구현할때에도 setter에 추가적 처리를 하기가 애매해진다
      • 습관적으로 setter는 필드값만 변경하고 끝나는 경우가 많았기 때문이다
    • 이러한 특징으로 인해 도메인 지식이 코드에서 사라지게 되는것이다
  • setter 는 도메인 객체를 생성할 때 완전한 상태가 아닐수도 있게끔 한다

    1
    2
    3
    4
    5
    6
    7
    8
    Order order = new Order();
    order.setOrderLine(lines);
    order.setShippingInfo(shippingInfo);
    // ...

    // 주문자를 설정하지 않은 상태에서 주문 완료 처리가 되어버렸다
    // 그렇다고 여기다가 주문자 null 체크를 넣는것 또한 이상하다
    order.setState(OrderState.PREPARING);
    • 도메인 객체가 불완전한 상태로 사용되는것을 막으려면 생성시점에 필요한 것을 모두 전달해줘야 한다
  • DTO는 괜찮다

    • 도메인 로직을 가지고 있지 않기 때문이다
    • 프레임워크에서 setter 없이도 값을 할당하게끔 해주기도 하는데, 이럴경우 그 기능을 최대한 활용해서 DTO까지 VO의 특징을 가지게 하는것이 좋다

도메인 용어

도메인 용어를 코드에 반영하지 않으면 개발자가 코드의 의미를 해석해야 하는 부담이 생긴다

1
2
3
public enum OrderState {
STEP1, STEP2, STEP3, STEP4 ..
}

각각의 STEP이 어느 상태를 나타내는지 누군가에게 물어서 알아내야하고, 그 상태로 매번 변환하는(머리속에서) 과정이 추가되게 된다
그러므로 되도록 도메인 용어를 코드에 직접 사용해서 이런 불필요한 과정을 줄이고, 코드가 바로 이해될 수 있도록 해주는 것이 좋다

참고로 우리는 한국인이라 도메인 용어를 영어로 나타내는데 많은 어려움이 있다
하지만 여기 시간을 투자하지 않는다면 코드가 점점 도메인에서 멀어지게 되니, 도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말아야 한다

참고 : 최범균, 『DDD Start!』, 지앤선(2016)