Post

Entity, Value Object, Domain Service 구분이 실제로 뭘 바꿨나

Entity, Value Object, Domain Service 구분이 실제로 뭘 바꿨나

DDD 개념 정리

처음에는 Entity, VO, Domain Service를 구분하는 게 이론적인 개념 정리 정도로만 느꼈습니다. 실제 코드에 적용해보니 설계가 달라졌습니다.

Entity

  • 고유한 식별자가 있습니다 (id)
  • 시간이 지남에 따라 상태가 변합니다
  • 같은 id를 가지면 동일한 객체입니다
1
2
3
4
5
6
7
8
@Entity
public class Order {
    @Id
    private Long id;
    private Long userId;
    private OrderStatus status;
    // ...
}

Value Object (VO)

  • 값 자체가 본질입니다
  • 불변(Immutable)입니다
  • 값이 같으면 동일한 객체입니다 (id 필요 없음)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Embeddable
public class Money {
    private final int amount;

    public Money(int amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("금액은 0 이상이어야 합니다.");
        }
        this.amount = amount;
    }

    public Money add(Money other) {
        return new Money(this.amount + other.amount);
    }

    public Money subtract(Money other) {
        return new Money(this.amount - other.amount);
    }
}

Domain Service

  • 상태가 없습니다 (Stateless)
  • 여러 Entity나 VO 간의 협력이 필요한 로직을 담당합니다
  • 특정 Entity에 속하지 않는 도메인 로직입니다

실제 적용: OrderItemPrice와 OrderTotalAmount를 VO로

기존 코드에서는 금액을 int로 다루고 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 변경 전
@Entity
public class OrderItem {
    private int price;
    private int quantity;

    public int calculateSubtotal() {
        return price * quantity;
    }
}

// Order에서
int totalAmount = 0;
for (OrderItem item : items) {
    totalAmount += item.calculateSubtotal();
}
if (totalAmount < 0) { // 여기저기 검증 코드가 흩어진다
    throw new IllegalStateException("...");
}

금액 검증 로직이 여러 곳에 흩어져 있고, int로 다루다 보니 음수 체크를 매번 해야 했습니다. 솔직히 이게 첫 번째 걸림돌이었습니다.

VO로 추출하면:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 변경 후
@Embeddable
public class OrderItemPrice {
    private Money price;
    private int quantity;

    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    protected OrderItemPrice() {}

    public OrderItemPrice(Money price, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("수량은 1 이상이어야 합니다.");
        }
        this.price = price;
        this.quantity = quantity;
    }

    public Money calculateSubtotal() {
        return new Money(price.getAmount() * quantity);
    }
}

@Embeddable
public class OrderTotalAmount {
    private Money amount;

    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    protected OrderTotalAmount() {}

    public OrderTotalAmount(List<OrderItemPrice> items) {
        this.amount = items.stream()
            .map(OrderItemPrice::calculateSubtotal)
            .reduce(new Money(0), Money::add);
    }

    // JPA @Embeddable 클래스는 기본 생성자가 필요하므로 final 필드를 사용할 수 없습니다.
    // Money 타입을 받는 private 생성자를 통해 내부 변환을 처리합니다.
    private OrderTotalAmount(Money amount) {
        this.amount = amount;
    }

    public OrderTotalAmount applyDiscount(Money discountAmount) {
        return new OrderTotalAmount(this.amount.subtract(discountAmount));
    }
}

금액 연산과 검증이 VO 내부에 캡슐화됐습니다. Order 코드가 단순해졌습니다.

1
2
3
4
5
6
7
8
9
@Entity
public class Order {
    @Embedded
    private OrderTotalAmount totalAmount;

    public void applyDiscount(Money discountAmount) {
        this.totalAmount = totalAmount.applyDiscount(discountAmount);
    }
}

무엇이 달라졌나

버그 원천 차단: 음수 금액은 Money 객체 생성 시점에 차단됩니다. 이전에는 연산 결과를 확인해야 했습니다.

테스트 용이성: Money, OrderItemPrice 같은 VO는 독립적으로 테스트할 수 있습니다. 의존성이 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void 금액은_음수가_될_수_없다() {
    assertThatThrownBy(() -> new Money(-1))
        .isInstanceOf(IllegalArgumentException.class);
}

@Test
void 금액_덧셈() {
    Money a = new Money(1000);
    Money b = new Money(500);
    assertThat(a.add(b)).isEqualTo(new Money(1500));
}

코드 가독성: int totalAmount 대신 OrderTotalAmount totalAmount를 보면 의도가 명확합니다.

@Embeddable 활용

JPA에서 VO를 사용할 때 @Embeddable을 활용하면 별도 테이블 없이 값 객체를 매핑할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
@Entity
public class Order {
    @Id
    private Long id;

    @Embedded
    private OrderTotalAmount totalAmount;

    @ElementCollection
    private List<OrderItemPrice> items;
}

DB 스키마는 단순하게 유지하면서 도메인 모델은 풍부하게 가져갈 수 있습니다.

정리

Entity, VO, Domain Service 구분은 코드 구조의 문제입니다. VO로 값을 표현하면 그 값에 관련된 로직과 검증이 한 곳에 모입니다. Entity는 상태 변화와 식별에 집중할 수 있습니다. 저는 이 분리가 실제로 버그를 줄이고 테스트를 단순하게 만들었다고 느꼈습니다.

This post is licensed under CC BY 4.0 by the author.