DDD - 도메인 모델, 도메인 모델 패턴, 도메인 모델 도출 과정, 엔티티와 밸류


이 포스트에서는 아래 내용에 대해 알아본다.

  • 도메인 모델
  • 도메인 모델 패턴
  • 도메인 모델 도출 과정
  • 엔티티와 밸류

목차


개발 환경

  • 언어: java
  • Spring Boot ver: 3.2.5
  • Spring ver: 6.1.6
  • IDE: intelliJ
  • SDK: JDK 17
  • 의존성 관리툴: Maven

1. 도메인 모델

도메인은 소프트웨어로 해결하고자 하는 문제 영역이다.

요구 사항을 올바르게 이해하려면 개발자와 전문가가 직접 소통하는 것이 중요함
개발자와 전문가 사이에 내용을 전파하는 전달자가 많을수록 정보가 왜곡되고 손실이 발생하여, 개발자는 최초 전문가가 요구한 것과 다른 것을 만들 가능성이 높음

아래는 주문 모델을 객체 모델로 구성한 예시이다.

객체 기반(클래스 다이어그램) 주문 도메인 모델

도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고, 도메인 지식을 공유할 수 있다.

위 그림은 객체를 이용한 도메인 모델인데, 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악해야 한다.
이런 면에서 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 매우 적합하다.

도메인 모델을 객체로만 모델링할 수 있는 건 아니다.
상태 다이어그램을 이용해서 주문의 상태 전이를 모델링할 수도 있다.

상태 다이어그램을 이용한 주문 상태 모델링

도메인 모델을 표현할 때 클래스 다이어그램이나 상태 다이어그램과 같은 UML 표기법 외 다른 방식으로 표현해도 된다.

도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이므로 구현 기술에 맞는 구현 모델이 따로 필요하다.

도메인은 다수의 하위 도메인으로 구성된다.

각 하위 도메인이 다루는 영역은 서로 다르기 때문에 같은 용어라도 하위 도메인마다 의미가 달라질 수 있다.
예) 카탈로그 도메인에서의 상품은 상품의 정보를 담고 있는 정보를 의미하고, 배송 도메인에서의 상품은 물리적인 상품을 의미함

이렇게 도메인에 따라 용어 의미가 결정되므로 여러 하위 도메인을 하나의 다이어그램에 모델링하면 안된다.

모델의 각 구성 요소는 특정 도메인으로 한정할 때 비로소 의미가 완전해지기 때문에 각 하위 도메인마다 별도로 모델을 만들어야 한다.
예) 카탈로그 하위 도메인 모델과 배송 하위 도메인 모델은 따로 만들어야 함


2. 도메인 모델 패턴

일반적인 애플리케이션의 아키텍처는 아래와 같다. 아키텍처 구성

각 영역에 대한 상세한 내용은 1. 4개의 영역 을 참고하세요.

도메인 모델은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 의미한다.

도메인 계층은 도메인의 핵심 규칙을 구현한다.
예) 주문 도메인의 경우 ‘출고 전에 배송지 변경 가능’, ‘주문 취소는 배송 전에만 가능’ 등의 규칙을 구현한 코드가 도메인 계층에 위치함

이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 바로 도메인 모델 패턴이다.

아래는 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것이다.

public class Order {
    private OrderState state;
    private ShippingInfo shippingInfo;

    // 실제 배송지 정보를 변경하는 메서드
    // OrderState 의 isShippingChangeable() 를 이용하여 변경 가능한 경우에만 배송지 변경
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        if (!state.isShippingChangeable()) {
            throw new IllegalStateException("can't change shipping in " + state);
        }
        this.shippingInfo = newShippingInfo;
    }
    ...
}
// 주문 상태 표현
public enum OrderState {
  PAYMENT_WAITING {
    public boolean isShippingChangeable() {
      return true;
    }
  },
  PREPARING {
    public boolean isShippingChangeable() {
      return true;
    }
  },
  SHIPPED, DELIVERING, DELIVERY_COMPLETED;

  // 배송지를 변경할 수 있는지 여부 검사
  public boolean isShippingChangeable() {
    return false;
  }
}

주문 대기중, 상품 준비중 일때는 배송지를 변경할 수 있도록 한다.
즉, OrderState 는 주문 대기중이거나 상품 준비 중에는 배송지를 변경할 수 있다는 도메인 규칙을 구현하고 있다.

실제 배송지 정보를 변경하는 Order 클래스의 changeShippingInfo()OrderStateisShippingChangeable() 를 이용하여 변경 가능한 경우에만 배송지 변경한다.

혹은 아래처럼 배송지 정보를 변경하는 메서드를 Order 클래스에서 판단하도록 할 수 있다.

public class Order {
    private OrderState state;
    private ShippingInfo shippingInfo;

    // 실제 배송지 정보를 변경하는 메서드
    // OrderState 의 isShippingChangeable() 를 이용하여 변경 가능한 경우에만 배송지 변경
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        if (!isShippingChangeable()) {
            throw new IllegalStateException("can't change shipping in " + state);
        }
        this.shippingInfo = newShippingInfo;
    }

    private boolean isShippingChangeable() {
        return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING
    }
    ...
}
// 주문 상태 표현
public enum OrderState {
    PAYMENT_WAITING,
    PREPARING,
    SHIPPED,
    DELIVERING,
    DELIVERY_COMPLETED;
}

배송지 변경 여부 가능을 판단하는 기능이 Order 에 있던 OrderState 에 있던 중요한 점은 주문에 관련된 업무 규칙을 주문 도메인 모델인 OrderOrderState 에서 구현한다는 점이다.

핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장할 때 다른 코드에 영향을 덜 주면서 변경 내역을 모델에 반영할 수 있다.

개념 모델과 구현 모델

개념 모델은 문제를 분석한 결과물이므로 DB, 트랜잭션 처리, 성능 등과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없음
그래서 개념 모델을 구현 가능한 형태의 모델로 전환하는 과정을 거치게 됨

프로젝트 초기에 도메인 모델을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 변경하는 일이 발생함

따라서 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알 수 있는 수준으로 개념 모델을 작성하여 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 함


3. 도메인 모델 도출

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

아래는 이번에 사용될 클래스들이다.

  • Order: 주문
  • OrderLine: 주문 항목
  • ShippingInfo: 배송지 정보
  • OrderState: 주문 상태

아래는 주문 도메인과 관련된 요구사항들이다.

  • 최소 한 종류 이상의 상품 주문 가능
  • 한 상품을 한 개 이상 주문 가능
  • 총 주문 금액은 각 상품의 구매 가격의 합을 모두 더한 금액
  • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값
  • 주문 시 배송지 정보가 반드시 있어야 함
  • 배송지 정보는 이름, 전화번호, 주소로 구성됨
  • 출고를 하면 배송지 변경 불가
  • 출고 전에 주문 취소 가능
  • 결제 완료 전에는 상품을 준비하지 않음

여기서 주문은 아래 4가지 기능을 제공한다는 것을 알 수 있다.

  • 출고 상태로 변경
  • 배송지 정보 변경
  • 주문 취소
  • 결제 완료

상세 구현은 아니더라도 Order 에 관련 기능을 메서드로 추가 가능하다.

// 주문
public class Order {
    public void changeShipped() { ... }
    public void changeShippingInfo(ShippingInfo newShipping) { ... }
    public void cancel() { ... }
    public void completePayment() { ... }
}

그리고 아래 요구사항으로 주문 항목이 어떤 데이터로 이루어져 있는지 알 수 있다.

  • 한 상품을 한 개 이상 주문 가능
  • 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값

위 요구사항으로 주문 항목 (OrderLine) 은 주문할 상품, 상품 가격, 구매 갯수, 각 구매 항목의 구매 가격도 제공을 포함해야 하는 것을 알 수 있다.

// 주문 항목
@Getter
public class OrderLine {
    private Product product;
    private int price;
    private int quantity;
    private int amounts;
    
    public OrderLine(Product product, int price, int quantity) {
        this.product = product;
        this.price = price;
        this.quantity = quantity;
        this.amounts = this.calculateAmounts();
    }
    
    private int calculateAmounts() {
        return price * quantity;
    }
}

아래 요구사항은 주문과 주문 항목과의 관계를 알려준다.

  • 최소 한 종류 이상의 상품 주문 가능
  • 총 주문 금액은 각 상품의 구매 가격의 합을 모두 더한 금액

한 종류 이상의 상품을 주문할 수 있기 때문에 Order 는 최소 한 개 이상의 OrderLine 을 포함해야 한다.
총 주문 금액은 OrderLine 에서 구할 수 있다.

위의 두 요구 사항을 Order 에 반영하면 아래와 같다.

// 주문
public class Order {

    private List<OrderLine> orderLines;
    private Money totalAmounts;

    public Order(List<OrderLine> orderLines) {
        setOrderLines(orderLines);
    }

    // 생성자에서 호출되는 함수
    // 요구사항에서 정의한 제약 조건 검사
    private void setOrderLines(List<OrderLine> orderLines) {
        verifyAtLeastOneOrderLine(orderLines);
        this.orderLines = orderLines;
        calculateTotalAmounts();
    }

    // 최소 한 종류 이상의 상품이 포함되어 있는지 확인
    private void verifyAtLeastOneOrderLine(List<OrderLine> orderLines) {
        if (orderLines == null || orderLines.isEmpty()) {
            throw new IllegalArgumentException("no orderLine");
        }
    }

    // 총 주문 금액 계산
    private void calculateTotalAmounts() {
        int sum = orderLines.stream()
                .mapToInt(OrderLine::getAmounts)
                .sum();
        this.totalAmounts = new Money(sum);
    }
    
    // ...
}

생성자에서 호출하는 setOrderLines() 은 요구사항에서 정의한 제약 조건을 검사한다.

mapToInt() 에 대한 상세한 내용은 2.2. 숫자 스트림으로 매핑: mapToInt(), mapToDouble(), mapToLong() 을 참고하세요.


아래 요구사항으로 배송지 정보가 어떤 데이터로 이루어져 있는지 알 수 있다.

  • 배송지 정보는 이름, 전화번호, 주소로 구성됨
// 배송지 정보
@RequiredArgsConstructor
@Getter
public class ShippingInfo {
    private final String receiverName;
    private final String receiverPhoneNumber;
    private final String shippingAddress1;
    private final String shippingAddress2;
    private final String shippingZipcode;
}

아래 요구사항은 주문과 배송지 정보와의 관계를 알려준다.

  • 주문 시 배송지 정보가 반드시 있어야 함
// 주문
public class Order {

    private List<OrderLine> orderLines;
    private ShippingInfo shippingInfo;

    public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
        setOrderLines(orderLines);
        setShippingInfo(shippingInfo);
    }

    // ...

    // 배송지 정보 검사
    private void setShippingInfo(ShippingInfo shippingInfo) {
        // 배송지 정보는 필수임
        if (shippingInfo == null) {
            throw new IllegalArgumentException("no shippingInfo");
        }
        this.shippingInfo = shippingInfo;
    }
}

아래 요구 사항은 결제 완료 전의 상태와 결제 완료/상품 준비 중이라는 상태가 필요함을 알려준다.

  • 결제 완료 전에는 상품을 준비하지 않음
// 주문 상태 표현
public enum OrderState {
    PAYMENT_WAITING,
    PREPARING,
    SHIPPED,
    DELIVERING,
    DELIVERY_COMPLETED,
    CANCELED;
}

도메인을 구현하다 보면 특정 조건이나 상태에 따라 제약이나 규칙이 다르게 적용되는 경우가 있다.

  • 출고를 하면 배송지 변경 불가
  • 출고 전에 주문 취소 가능

위 조건은 출고 상태가 되기 전/후의 제약 사항을 기술한다.
이 요구 사항을 충족하려면 주문은 최소한 출고 상태를 표현할 수 있어야 한다.

배송지 변경이나 주문 취소는 출고 전에만 가능하다는 제약 규칙이 있으므로 changeShippingInfo()cancel()verifyNotYetShipped() 를 먼저 실행한다.

// 주문
public class Order {
    private OrderState state;
    
    // ...

    // 배송지 변경
    public void changeShippingInfo(ShippingInfo newShipping) {
      verifyNotYetShipped();
      setShippingInfo(newShipping);
    }
  
    // 주문 취소
    public void cancel() {
      verifyNotYetShipped();
      this.state = OrderState.CANCELED;
    }
  
    // 출고 전 상태인지 검사
    private void verifyNotYetShipped() {
      // 결제 전 이 아니고, 상품 준비중이 아니면 이미 출고된 상태임
      if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
        throw new IllegalArgumentException("already shipped");
      }
    }
}

3.1. 전체 코드

Order (주문)

package com.assu.study.chap01;

import java.util.List;

// 주문
public class Order {
  private OrderState state;
  private List<OrderLine> orderLines;
  private ShippingInfo shippingInfo;
  //private Money totalAmounts;

  public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
    setOrderLines(orderLines);
    setShippingInfo(shippingInfo);
  }

  // 생성자에서 호출되는 함수
  // 요구사항에서 정의한 제약 조건 검사
  private void setOrderLines(List<OrderLine> orderLines) {
    verifyAtLeastOneOrderLine(orderLines);
    this.orderLines = orderLines;
    calculateTotalAmounts();
  }

  // 최소 한 종류 이상의 상품이 포함되어 있는지 확인
  private void verifyAtLeastOneOrderLine(List<OrderLine> orderLines) {
    if (orderLines == null || orderLines.isEmpty()) {
      throw new IllegalArgumentException("no orderLine");
    }
  }

  // 총 주문 금액 계산
  private void calculateTotalAmounts() {
    int sum = orderLines.stream()
            .mapToInt(OrderLine::getAmounts)
            .sum();
    //this.totalAmounts = new Money(sum);
  }

  // 배송지 정보 검사
  private void setShippingInfo(ShippingInfo shippingInfo) {
    // 배송지 정보는 필수임
    if (shippingInfo == null) {
      throw new IllegalArgumentException("no shippingInfo");
    }
    this.shippingInfo = shippingInfo;
  }

  // 배송지 변경
  public void changeShippingInfo(ShippingInfo newShipping) {
    verifyNotYetShipped();
    setShippingInfo(newShipping);
  }

  // 주문 취소
  public void cancel() {
    verifyNotYetShipped();
    this.state = OrderState.CANCELED;
  }

  // 출고 전 상태인지 검사
  private void verifyNotYetShipped() {
    // 결제 전 이 아니고, 상품 준비중이 아니면 이미 출고된 상태임
    if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
      throw new IllegalArgumentException("already shipped");
    }
  }

  public void changeShipped() {
    // TODO
  }

  public void completePayment() {
    // TODO
  }
}

OrderLine (주문 항목)

package com.assu.study.chap01;

import lombok.Getter;

// 주문 항목
@Getter
public class OrderLine {
    private Product product;
    private int price;
    private int quantity;
    private int amounts;

    public OrderLine(Product product, int price, int quantity) {
        this.product = product;
        this.price = price;
        this.quantity = quantity;
        this.amounts = this.calculateAmounts();
    }

    private int calculateAmounts() {
        return price * quantity;
    }
}

ShippingInfo (배송지 정보)

import lombok.Getter;
import lombok.RequiredArgsConstructor;

// 배송지 정보
@RequiredArgsConstructor
@Getter
public class ShippingInfo {
    private final String receiverName;
    private final String receiverPhoneNumber;
    private final String shippingAddress1;
    private final String shippingAddress2;
    private final String shippingZipcode;
}

OrderState (주문 상태)

// 주문 상태 표현
public enum OrderState {
    PAYMENT_WAITING,
    PREPARING,
    SHIPPED,
    DELIVERING,
    DELIVERY_COMPLETED,
    CANCELED;
}

문서화를 하는 이유

실제 구현은 코드에 있으므로 코드를 보면 다 알 수 있지만, 코드는 모든 상세한 내용을 다루고 있기 때문에 코드를 이용해서 전체를 파악하려면 많은 시간을 투자해야 함
전반적인 기능 목록이나 모듈 구조, 빌드 과정은 코드를 보고 직접 이해하는 것보다 상위 수준에서 정리한 문서를 참조하는 것이 전반을 빠르게 이해하는데 도움이 됨

전체 구조를 이해하고 나서 더 깊게 이해할 필요가 있는 부분을 코드로 분석해나가는 것이 좋음


4. 엔티티와 밸류

도출한 모델은 크게 엔티티와 밸류로 구분할 수 있는데, 3. 도메인 모델 도출 에서 도출한 모델은 아래와 같이 엔티티와 밸류가 존재한다.

주문 도메인 모델의 엔티티와 밸류

엔티티와 밸류를 정확히 이해해야 도메인을 올바르게 설계할 수 있으므로 이 둘의 차이를 명확하게 알아야 한다.

엔티티와 밸류에 대한 추가 설명은 4.1. 엔티티와 밸류 를 참고하세요.


4.1. 엔티티

엔티티의 가장 큰 특징은 식별자를 가진다는 것이다.

식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
예) 주문 도메인에서 각 주문은 주문 번호를 가져야 하고, 이 주문 번호는 유일해야 함

따라서 Order 는 엔티티가 되고, 주문번호가 Order 의 식별자가 된다.

Order 는 엔티티로서 orderNumber 식별자를 가짐

엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자 값이 같으면 두 엔티티는 같다고 판단할 수 있다.

엔티티를 구현한 클래스는 아래와 같이 equals(), hashCode() 메서드를 구현할 수 있다.

// 주문
@EqualsAndHashCode
public class Order {
    private String orderNumber;
    // ...
}

4.2. 엔티티의 식별자 생성

엔티티의 식별자를 생성하는 시점은 보통 아래 4가지 중 하나로 생성한다.

  • 특정 규칙에 따라 생성
  • UUID 나 Nano ID 와 같은 고유 식별자 생성기 사용
  • 값을 직접 입력: 이메일이나 회원 아이디 등
  • 일련번호 (시퀀스나 DB 의 autoIncrement) 사용

일련 번호는 아래와 같은 방식으로 조회 가능하다.

Article article = new Article(title, ...)
articleRepository.save(article) // db 에 저장한 후 구한 식별자를 엔티티에 반영
Long articleId = article.getId();   // db 에 저장한 후 식별자 참조 가능

4.3. 밸류 타입

위에서 배송지 정보인 ShippingInfo 클래스를 보자.

import lombok.Getter;
import lombok.RequiredArgsConstructor;

// 배송지 정보
@RequiredArgsConstructor
@Getter
public class ShippingInfo {
    // 받는 사람
    private final String receiverName;
    private final String receiverPhoneNumber;
    
    // 주소
    private final String shippingAddress1;
    private final String shippingAddress2;
    private final String shippingZipcode;
}

위에서 receiverNamereceiverPhoneNumber 필드는 다른 데이터를 담고 있지만 두 개의 필드는 개념적으로 받는 사람을 의미한다.
비슷하게 shippingAddress1, shippingAddress2, shippingZipcode 필드도 주소라는 하나의 개념을 표현한다.

밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.

예를 들어 위에서 받는 사람을 위한 밸류 타입인 Receiver 를 아래와 같이 작성할 수 있다.

@Getter
public class Receiver {
    private String name;
    private String phoneNumber;
}

Receiver 는 받는 사람 이라는 도메인 개념을 표현한다.

ShippingInforeceiverName, receiverPhoneNumber 가 받는 사람과 관련된 데이터라면, Receiver 는 그 자체로 받는 사람을 의미한다.

밸류 타입을 사용함으로써 개념적으로 완전한 하나를 표현한 것이다.

@Getter
public class Address {
    private String address1;
    private String address2;
    private String zipcode;
}

이제 ShippingInfo 는 아래와 같이 나타낼 수 있다.
배송지 정보가 받는 사람과 주소로 구성된다는 것을 쉽게 알 수 있다.

// 배송지 정보
@RequiredArgsConstructor
@Getter
public class ShippingInfo {
    private final Receiver receiver;
    private final Address address;
}

4.3.1. 의미를 명확히 표현하기 위한 밸류 타입

밸류 타입이 꼭 2개 이상의 데이터를 가져야하는 것은 아니다.
의미를 명확하게 표현하기 위해 밸류 타입을 사용하기도 하는데, 좋은 예가 주문 항목인 OrderLine 이다.

// 주문 항목
@Getter
public class OrderLine {
    private Product product;
    private int price;
    private int quantity;
    private int amounts;
    
  ...
}

위에서 price, amounts 는 int 타입이지만, 돈을 의미하므로 돈을 의미하는 Money 타입을 만들어 사용하면 이해하는데 도움이 된다.

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Getter
public class Money {
    private final int value;
}

이제 OrderLine 을 아래와 같이 표현할 수 있다.
Money 타입 덕에 price, amounts 가 금액을 의미한다는 것을 알 수 있다.

// 주문 항목
@Getter
public class OrderLine {
    private Product product;
    private Money price;
    private int quantity;
    private Money amounts;

    public OrderLine(Product product, Money price, int quantity) {
        this.product = product;
        this.price = price;
        this.quantity = quantity;
        this.amounts = this.calculateAmounts();
    }
    // ...
}

4.3.2. 밸류 타입을 위한 기능 추가

밸류 타입의 또 다른 장점은 밸류 타입을 위한 기능을 추가할 수 있다는 부분이다.
예) Money 타입에 돈 계산을 위한 기능 추가

@RequiredArgsConstructor
@Getter
public class Money {
    private final int value;

    public Money add(Money money) {
        return new Money(this.value + money.value);
    }

    public Money multiply(int multiplier) {
        return new Money(this.value * multiplier);
    }
}

이제 OrderLine 의 전체 금액을 아래와 같이 계산할 수 있다.

// 주문 항목
@Getter
public class OrderLine {
    private Product product;
    private Money price;
    private int quantity;
    private Money amounts;

    public OrderLine(Product product, Money price, int quantity) {
        this.product = product;
        this.price = price;
        this.quantity = quantity;
        this.amounts = this.calculateAmounts();
    }

    // 돈의 개념으로 계산
    private Money calculateAmounts() {
        return price.multiply(quantity);
    }
}

이렇게 밸류 타입은 코드의 의미를 더 잘 이해할 수 있도록 해준다.


4.3.3. 밸류 객체의 데이터 변경

밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.

@RequiredArgsConstructor
@Getter
public class Money {
    private final int value;

    public Money add(Money money) {
        // 새로운 Money 를 생성하여 리턴
        return new Money(this.value + money.value);
    }
    //...
}

이렇게 데이터 변경 기능을 제공하지 않는 타입을 불변 (immutable) 이라고 표현한다.

밸류 타입을 불변으로 구현하는 가장 큰 이유는 코드의 안전성이다.

immutable 클래스를 설계하는 법에 대한 좀 더 상세한 내용은 9. Spring bean, Java bean, DTO, VO 를 참고하세요.

예를 들어 아래와 같은 코드를 보자.

Money price = new Money(1000);
int quantity = 2;
OrderLine line = new OrderLine(product, price, quantity);   // [price=1000, quantity=2, amounts=2000]

// 만일 price.setValue(2000) 로 값을 변경할 수 있다면..
price.setValue(2000);   // [price=2000, quantity=2, amounts=2000]

위와 같이 잘못된 데이터가 반영되는 상황을 방지하기 위해 밸류 타입은 불변으로 구현하는 것이 좋다.

밸류 타입은 모든 속성이 같은지 비교하기 위해 equals(), hashCode() 를 오버라이드 한다.

@RequiredArgsConstructor
@Getter
@EqualsAndHashCode
public class Money {
    private final int value;

    // ...
}
@Getter
@EqualsAndHashCode
public class Receiver {
    private String name;
    private String phoneNumber;
}

4.3.4. 전체 코드

소스는 github, 변경 내역 에 있습니다.

@EqualsAndHashCode  // 밸류 타입
@Getter
public class Address {
  private String address1;
  private String address2;
  private String zipcode;
}
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode  // 밸류 타입
public class Money {
    private final int value;

    public Money add(Money money) {
        return new Money(this.value + money.value);
    }

    public Money multiply(int multiplier) {
        return new Money(this.value * multiplier);
    }
}
@Getter
@EqualsAndHashCode  // 밸류 타입
public class Receiver {
    private String name;
    private String phoneNumber;
}
// 배송지 정보
@RequiredArgsConstructor
@Getter
public class ShippingInfo {
  private final Receiver receiver;
  private final Address address;
}
import java.util.List;

// 주문
public class Order {
    private String orderNumber;
    private OrderState state;
    private List<OrderLine> orderLines;
    private ShippingInfo shippingInfo;
    //private Money totalAmounts;

    public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
        setOrderLines(orderLines);
        setShippingInfo(shippingInfo);
    }

    // 생성자에서 호출되는 함수
    // 요구사항에서 정의한 제약 조건 검사
    private void setOrderLines(List<OrderLine> orderLines) {
        verifyAtLeastOneOrderLine(orderLines);
        this.orderLines = orderLines;
        calculateTotalAmounts();
    }

    // 최소 한 종류 이상의 상품이 포함되어 있는지 확인
    private void verifyAtLeastOneOrderLine(List<OrderLine> orderLines) {
        if (orderLines == null || orderLines.isEmpty()) {
            throw new IllegalArgumentException("no orderLine");
        }
    }

    // 총 주문 금액 계산
    private void calculateTotalAmounts() {
        int sum = orderLines.stream()
                .mapToInt(OrderLine::getAmounts)
                .sum();
        //this.totalAmounts = new Money(sum);
    }

    // 배송지 정보 검사
    private void setShippingInfo(ShippingInfo shippingInfo) {
        // 배송지 정보는 필수임
        if (shippingInfo == null) {
            throw new IllegalArgumentException("no shippingInfo");
        }
        this.shippingInfo = shippingInfo;
    }

    // 배송지 변경
    public void changeShippingInfo(ShippingInfo newShipping) {
        verifyNotYetShipped();
        setShippingInfo(newShipping);
    }

    // 주문 취소
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
    }

    // 출고 전 상태인지 검사
    private void verifyNotYetShipped() {
        // 결제 전 이 아니고, 상품 준비중이 아니면 이미 출고된 상태임
        if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
            throw new IllegalArgumentException("already shipped");
        }
    }

    public void changeShipped() {
        // TODO
    }

    public void completePayment() {
        // TODO
    }
}
import com.assu.study.common.Money;
import lombok.Getter;

// 주문 항목
@Getter
public class OrderLine {
    private Product product;
    private Money price;
    private int quantity;
    private Money amounts;

    public OrderLine(Product product, Money price, int quantity) {
        this.product = product;
        this.price = price;
        this.quantity = quantity;
        this.amounts = this.calculateAmounts();
    }

    private Money calculateAmounts() {
        return price.multiply(quantity);
    }
}
// 주문 상태 표현
public enum OrderState {
    PAYMENT_WAITING,
    PREPARING,
    SHIPPED,
    DELIVERING,
    DELIVERY_COMPLETED,
    CANCELED;
}

4.4. 엔티티 식별자와 밸류 타입

소스는 github, 변경 내역 에 있습니다.

엔티티 식별자의 실제 데이터는 String 과 같은 문자열로 구성된 경우가 많지만, Money 가 단순한 숫자가 아닌 도메인의 ‘돈’ 을 의미하는 것처럼 이런 식별자는 단순 문자열이 아니라 도메인에서 특별한 의미를 지니는 경우가 많기 때문에 식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러날 수 있도록 할 수 있다.

주문 번호를 표현하기 위해 Order 의 식별자 타입으로 String 대신 OrderNo 라는 밸류 타입을 사용하면 타입을 통해 해당 필드가 주문 번호라는 것을 할 수 있다.

@RequiredArgsConstructor
@Getter
@EqualsAndHashCode  // 밸류 타입
public class OrderNo {
    private final String number;
}
// 주문
public class Order {
    // OrderNo 타입 자체로 id 가 주문 번호임을 알 수 있음
    private OrderNo id;
    private OrderState state;
    
    // ...
}

필드의 의미가 드러나게 하려면 id 라는 이름 대신 ‘orderNo’ 라는 필드명을 사용해야 한다.
하지만 식별자를 위한 OrderNo 타입을 만들어 사용하면 타입 자체로 주문번호라는 것을 알 수 있으므로 필드 이름이 id 어도 실제 의미를 찾는 것이 어렵지 않다.


4.5. 도메인 모델에서 set 메서드 넣지 않기

소스는 github, 변경 내역 에 있습니다.

도메인 모델에 setter 를 넣는 것은 도메인의 핵심 개념이나 의도를 사라지게 한다.

예를 들어 Order 를 아래처럼 변경한다고 해보자.

기존

// 주문
public class Order {
    // ...

    // 배송지 정보 검사
    private void setShippingInfo(ShippingInfo shippingInfo) {
        // ...
      this.shippingInfo = shippingInfo;
    }
  
    // 배송지 변경
    public void changeShippingInfo(ShippingInfo newShipping) {
      verifyNotYetShipped();
      setShippingInfo(newShipping);
    }

    public void completePayment() {
      // ...
    }
}

변경 후

// 주문
public class Order {
    // ...

    // 배송지 정보 검사 후 배송지 값 설정
    public void setShippingInfo(ShippingInfo shippingInfo) {    // public 으로 변경
        // ...
      this.shippingInfo = shippingInfo;
    }
    
    public void setOrderState(OrderState state) {
        // ...
    }
  
    // 배송지 변경
    public void changeShippingInfo(ShippingInfo newShipping) {
      verifyNotYetShipped();
      setShippingInfo(newShipping);
    }
}

changeShippingInfo() 는 배송지 정보를 새로 변경한다는 의미를 지닌 반면, setShippingInfo() 는 단순히 배송지 값을 설정한다는 것을 의미한다.
completePayment() 는 결제를 완료했다는 의미를 지닌 반면, setOrderState() 는 단순히 주문 상태값을 설정한다는 것을 의미한다.

구현할 때 completePayment() 는 결제 완료 처리를 구현하니까 결제 완료와 관련된 도메인 지식을 코드로 구현하는 것이 자연스럽지만, setOrderState() 는 단순히 상태값만 변경할지 아니면 상태값에 따라 다른 처리를 위한 코드를 함께 구현할 지 애매하다.


도메인 모델에 setter 를 넣는 것의 또 다른 문제점은 도메인 객체를 생성할 때 온전하지 않은 상태가 될 수 있다는 점이다.

// setter 로 데이터를 전달하도록 구현하면 처음에 Order 를 생성하는 시점에 order 는 완전하지 않음
Order order = new Order();

// setter 로 필요한 모든 값을 전달해야 함
order.setOrderLine(lines);
order.setShippingInfo(shippingInfo);

// 예를 들어 주문자를 설정하지 않은 상태로 주문 완료 처리
order.setStatus(OrderState.PREPARING);

위 코드는 주문자가 없는 상태에서 상품 준비중인 상태로 바뀌는 문제가 있다.
주문자가 정상인지 확인하기 위해 orderer 가 null 인지 검사하는 코드를 setState() 에 넣는 것도 이상하다.

도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점, 즉 생성자를 통해 필요한 데이터를 모두 받아야 한다.

Order order = new Order(orderer, lines, shippingInfo, OrderState.PREPARING);

이렇게 생성자로 필요한 것을 모두 받으면 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.

@RequiredArgsConstructor
@EqualsAndHashCode  // 밸류 타입
public class Orderer {
    private final String name;
}
public class Order {
    // OrderNo 타입 자체로 id 가 주문 번호임을 알 수 있음
    private OrderNo id;
    private Orderer orderer;

    private OrderState state;
    private List<OrderLine> orderLines;
    private ShippingInfo shippingInfo;
    //private Money totalAmounts;

    // 생성자 호출 시점에 필요한 데이터에 대한 검증 확인 가능
    public Order(Orderer orderer, List<OrderLine> orderLines, ShippingInfo shippingInfo, OrderState state) {
        setOrderer(orderer);
        setOrderLines(orderLines);
        setShippingInfo(shippingInfo);
        this.state = state;
    }

    private void setOrderer(Orderer orderer) {
      if (orderer == null) {
        throw new IllegalArgumentException("no orderer");
      }
      this.orderer = orderer;
    }
    
    // ...
}

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 최범균 저자의 도메인 주도 개발 시작하기을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10