도메인이란?
도메인이란
해결하고자 하는 문제의 영역을 말합니다.
한 도메인은 다시 하위 도메인으로 나눌 수 있습니다.
예를 들어, "온라인 서점"이라는 도메인은 다음과 같은 하위 도메인으로 나눌 수 있습니다.
- 회원
- 주문
- 결제
- 배송
- 카탈로그
- 리뷰
- 정산
- 혜택
도메인 모델
도메인 모델이란
특정 도메인을 개념적으로 표현한 것입니다.
주문 도메인을 예시로 객체 모델로 구성하면 다음과 같이 나타낼 수 있습니다.
도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악해야 합니다. 이런 면에서 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 매우 적합합니다.
물론, 객체가 아닌 상태 다이어그램, 수학 공식 등을 통해 모델링하는 방법도 존재합니다. 즉, 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않습니다.
한마디로 도메인 모델은
기본적으로 도메인 자체를 이해하기 위한 개념 모델입니다.
도메인 모델 패턴
일반적 애플리케이션의 아키텍처는 다음과 같이 네 개의 영역으로 구성됩니다.
영역 | 설명 |
사용자 인터페이스 or 표현 : Presentation | 사용자의 요청을 처리하고 사용자에게 정보를 보여줍니다. 사용자는 SW를 사용하는 사람뿐만 아니라 외부 시스템일 수도 있습니다. |
응용 : Application | 사용자가 요청한 기능을 실행합니다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행합니다. |
도메인 : Domain | 시스템이 제공할 도메인 규칙을 구현합니다. |
인프라스트럭처 : Infrastructure | DB나 Messaging 시스템과 같은 외부 시스템과의 연동을 처리합니다. |
여기서 특히, 도메인 계층은 도메인의 핵심 규칙을 구현합니다.
다시 주문 도메인을 예시로 들어보겠습니다. 이 경우, 다음과 같은 규칙을 구현한 코드가 도메인 계층에 위치하게 됩니다.
- 출고 전에 배송지를 변경할 수 있다.
- 주문 취소는 배송 전에만 할 수 있다.
코드로 보면 다음과 같습니다.
public class Order {
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
if (!state.isShippingChangeable()) {
throw new IllegalArgumentException("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;
}
}
지금은 실제 배송지 정보를 변경하는 `Order` 클래스의 `changeShippingInfo()` 메서드는 `OrderState`의 `isShippingChangeable()` 메서드를 이용해서 변경 가능한 경우에만 배송지를 변경하고 있습니다.
하지만, 큰 틀에서 보면 `OrderState`는 `Order`에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 `Order`로 이동할 수 있습니다.
즉, 다음과 같이 코드를 수정할 수 있습니다.
public class Order {
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
if (!isShippingChangeable()) {
throw new IllegalArgumentException("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;
}
배송지 변경 가능 여부를 판단할 규칙이 만약 주문 상태와 다른 정보를 함께 사용한다면, `OrderState`만으로는 판단할 수 없기에 `Order`에서 로직을 구현해야 합니다.
위 내용에서 중요한 점은
주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 `Order`나 `OrderState`에서 구현한다는 점입니다.
핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기에 규칙이 바뀌거나 규칙을 확장할 경우 다른 코드에 영향을 덜 주고 변경이 가능합니다.
도메인 모델 도출
도메인을 모델링할 때 가장 기본이 되는 작업은
- 모델을 구성하는 핵심 구성요소
- 규칙
- 기능
을 찾는 것입니다. 이 과정은 요구사항에서 출발합니다.
주문 도메인과 관련된 몇 가지 요구 사항을 살펴보겠습니다.
- 최소 한 종류 이상의 상품을 주문해야 한다.
- 한 상품을 한 개 이상 주문할 수 있다.
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
- 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
- 주문할 때 배송지 정보를 반드시 지정해야 한다.
- 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
- 출고를 하면 배송지를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
위 요구사항에서 알 수 있는 기능은 다음과 같습니다.
- 출고 상태로 변경하기
- 배송지 정보 변경하기
- 주문 취소하기
- 결제 완료하기
위 내용을 간략히 `Order`에 관련 기능 메서드로 추가하면 다음과 같습니다.
public class Order {
public void changeShipped() { ... } // 출고 상태 변경
public void changeShippingInfo(ShippingInfo newShippingInfo) { ... } // 배송지 정보 변경
public void cancel() { ... } // 주문 취소
public void completePayment() { ... } // 결제 완료
}
위 요구사항 중 2번, 3번 요구사항을 통해 주문 항목을 표현하는 `OrderLine`은 적어도 주문할 상품, 상품의 가격, 구매 개수, 각 구매 항목의 구매 가격을 포함해야 합니다.
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 = calculateAmounts();
}
private int calculateAmounts() {
return price * quantity;
}
public int getAmounts() { ... }
...
}
1번, 4번 요구사항은 `Order`와 `OrderLine`의 관계를 알려줍니다. `Order`는 최소 한 개 이상의 `OrderLine`을 포함해야 하고, 총 주문 금액은 `OrderLine`에서 구할 수 있습니다.
이 내용을 다음과 같이 반영할 수 있습니다.
public class Order {
private List<OrderLine> orderLines;
private Money totalAmounts;
public Order(List<OrderLine> orderLines) {
setOrderLines(orderLines);
}
private void setOrderLines(List<OrderLine> orderLines) {
verifyAtLeastOneOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines) {
if (orderLines == null || orderLines.isEmpty()) {
throw new IllegalArgumentException("Order lines must be at least one or more.");
}
}
private void calculateTotalAmounts() {
int sum = orderLines.stream()
.mapToInt(x -> x.getAmounts())
.sum();
this.totalAmounts = new Money(sum);
}
...
}
배송지 정보는 이름, 전화번호, 주소 데이터를 가지므로 다음과 같이 정의할 수 있습니다.
public class ShippingInfo {
private String receiverName;
private String receiverPhoneNumber;
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipcode;
... Constructor, getter
}
위 요구사항에서 5번에 의해 `Order`를 생성할 때 `OrderLine`의 목록뿐만 아니라 `ShippingInfo`도 함께 전달해야 합니다. 이는 생성자에 반영할 수 있습니다.
public class Order {
private List<OrderLine> orderLines;
private ShippingInfo shippingInfo;
private Money totalAmounts;
public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
setOrderLines(orderLines);
setShippingInfo(shippingInfo);
}
private void setShippingInfo(ShippingInfo shippingInfo) {
if (shippingInfo == null) {
throw new IllegalArgumentException("Shipping information is required.");
}
this.shippingInfo = shippingInfo;
}
...
}
`setShippingInfo` 메서드에서 null 확인을 하는데, 이를 통해 '배송지 정보 필수`라는 도메인 규칙을 구현할 수 있습니다.
도메인을 구현하다 보면 특정 조건이나 상태에 따라 제약이나 규칙이 다르게 적용되는 경우가 많습니다.
주문 요구사항에서는 다음과 같은 내용이 제약과 규칙에 해당합니다.
- 출고를 하면 배송지 정보를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
위의 요구사항을 충족하기 위해서는 주문은 최소한 출고 상태를 표현할 수 있어야 하고, 추가로 존재할 수 있는 상태는 열거 타입을 이용해 상태 정보를 표현할 수 있습니다.
public enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED,
CANCELED;
}
public class Order {
private OrderState state;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
}
private void verifyNotYetShipped() {
if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
throw new IllegalArgumentException("Already shipped.");
}
}
...
}
앞서 도메인 모델 패턴에서는 isShippingChangeable라는 이름으로 제약 조건을 검사했지만, 현재는 verifyNotYetShipped라는 이름을 사용하고 있습니다. 이름이 바뀐 이유는 그 사이에 도메인을 더 잘 알게 되었기 때문입니다. 메서드 이름은 그 의미를 잘 반영하도록 발전시키는 것이 좋습니다.
Entity와 Value
도출된 모델은 크게 엔티티(Entity)와 밸류(Value)로 구분할 수 있습니다.
위의 요구사항 분석 과정에서 만든 모델은 아래와 같은데, 엔티티도 존재하고 밸류도 존재합니다.
엔티티와 밸류를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있습니다.
엔티티 : Entity
엔티티의 가장 큰 특징은 식별자를 가진다는 것입니다. 엔티티의 식별자는 바뀌지 않습니다.
주문 도메인의 각 주문은 주문 번호라는 서로 다른 문자열을 가지고 있습니다.
엔티티를 구현한 클래스는 다음과 같이 식별자를 이용해서 `equals()`메서드와 `hashCode()` 메서드를 구현할 수 있습니다.
public class Order {
private String orderNumber;
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (getClass() != obj.getClass()) return false;
Order other = (Order) obj;
if (this.orderNumber == null) return false;
return this.orderNumber.equals(other.orderNumber);
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((orderNumber == null) ? 0 : orderNumber.hashCode());
return result;
}
}
엔티티의 식별자 생성
엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라지지만, 흔히 다음 중 한가지 방식으로 생성합니다.
- 특정 규칙에 따라 생성
- UUID나 Nano ID와 같은 고유 식별자 생성기 사용
- 값을 직접 입력
- 일련번호 사용(시퀀스나 DB의 Auto Increment 칼럼)
밸류 타입 : Value
밸류 타입은
개념적으로 완전한 하나를 표현할 때 사용합니다.
`ShippingInfo` 클래스는 아래와 같이 받는 사람과 주소에 대한 데이터를 가지고 있습니다.
public class ShippingInfo {
// 받는 사람
private String receiverName;
private String receiverPhoneNumber;
// 주소 정보
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipcode;
... Constructor, getter
}
`receiverName`과 `receiverPhoneNumber`은 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미합니다. 즉,
두 필드는 실제로 하나의 개념을 표현하고 있습니다.
비슷하게 아래의 주소를 표현하는 세 개의 필드도 주소라는 하나의 개념을 표현하고 있습니다.
이를 적용해 받는 사람을 위한 밸류 타입인 `Receiver`를 다음과 같이 작성할 수 있습니다.
public class Receiver {
private String name;
private String phoneNumber;
public Receiver(String name, String phoneNumber) {
this.name = name;
this.phoneNumber = phoneNumber;
}
... Getter
}
`Receiver`는 '받는 사람'이라는 도메인 개념을 표현합니다. 이렇게 밸류 타입을 사용함으로써 개념적으로 완전한 하나를 잘 표현할 수 있게 됩니다.
주소 관련 데이터도 `Address` 밸류 타입을 통해 보다 명확하게 표현할 수 있습니다.
public class Address {
private String address1;
private String address2;
private String zipcode;
public Address(String address1, String address2, String zipcode) {
this.address1 = address1;
this.address2 = address2;
this.zipcode = zipcode;
}
... Getter
}
위 두 가지의 밸류 타입을 이용해 `ShippingInfo`를 다음과 같이 새롭게 표현할 수 있습니다.
public class ShippingInfo {
private Receiver receiver;
private Address address;
... Constructor, getter
}
하나의 데이터를 가지는 밸류 타입
밸류 타입이 꼭 두 개 이상의 데이터를 가져야 하는 것은 아닙니다.
의미를 명확하게 표현하기 위해 밸류 타입을 사용하는 경우도 있습니다.
다음 코드를 보겠습니다.
public class OrderLine {
private Product product;
private int price;
private int quantity;
private int amounts;
...
}
위의 `price`와 `amounts`는 int 타입의 숫자를 사용하지만, 이들은 "돈"을 의미하는 값입니다. 따라서, "돈"을 의미하는 `Money` 타입을 만들어 사용한다면 코드를 이해하는 데 도움이 됩니다.
public class Money {
private int value;
public Money(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
public class OrderLine {
private Product product;
private Money price;
private int quantity;
private Money amounts;
...
}
밸류 타입의 장점
밸류 타입의 또 다른 장점은
밸류 타입을 위한 기능을 추가할 수 있다
는 것입니다.
`Money`에서는 돈 계산을 위한 기능을 추가할 수 있습니다.
public class Money {
private int value;
public Money(int value) {
this.value = value;
}
public Money add(Money money) {
return new Money(this.value + money.value);
}
public Money multiply(int multiplier) {
return new Money(this.value * multiplier);
}
}
이를 통해 `Money`를 사용하는 코드는 이제 '정수 타입 연산'이 아니라 '돈 계산'이라는 의미로 코드를 작성할 수 있습니다.
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 = calculateAmounts();
}
private Money calculateAmounts() {
return price.multiply(quantity);
}
public int getAmounts() { ... }
...
}
밸류 객체 변경
참고로, 밸류 객체의 데이터를 변경할 때는
기존의 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호합니다.
`Money`처럼 데이터 변경 기능을 제공하지 않은 타입을 불변(Immutable)이라고 표현하는데, 이를 통해 안전한 코드를 작성할 수 있습니다.
밸류 객체 비교
두 밸류 객체를 비교할 때는
모든 속성이 같은지 비교합니다.
public class Receiver {
private String name;
private String phoneNumber;
@Override
public boolean equals(Object other) {
if (this == obj) return true;
if (other == null) return false;
if (! (other instanceof Receiver)) return false;
Receiver that = (Receiver) other;
return this.name.equals(that.name) &&
this.phoneNumber.equals(that.phoneNumber);
}
}
엔티티 식별자와 밸류 타입
엔티티 식별자의 실제 데이터는 String과 같은 문자열로 구성된 경우가 많습니다. 하지만, 식별자 또한 도메인에서 특별한 의미를 지니는 경우가 많기 때문에
식별자를 위한 밸류 타입을 사용해서 의미가 잘 드러나도록 할 수 있습니다.
예를 들어, `Order`의 식별자 타입으로 String 대신 `OrderNo` 밸류 타입으로 사용한다면 타입을 통해 해당 필드가 주문번호라는 것을 알 수 있습니다.
'DDD' 카테고리의 다른 글
[DDD] : 도메인 서비스 (0) | 2025.03.12 |
---|---|
[DDD] : 표현 영역 (0) | 2025.03.11 |
[DDD] : 응용 영역 (0) | 2025.03.10 |
[DDD] : 리포지터리와 모델 구현 (0) | 2025.03.06 |
[DDD] : 아키텍처 (0) | 2025.03.04 |