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.