애그리게잇 하나에 리파지토리 하나

필자는 도메인 주도 설계Domain-Driven Design(이하 DDD) 빌딩 블록Building blocks[1]으로 애플리케이션을 구현하면서 엔티티ENTITY[2] 마다 리파지토리REPOSITORY를 만드는 것을 자주 보았는데 자세히 살펴보면 여러 엔티티를 묶어서 하나처럼 사용하는 경우가 대부분이었다. DDD에서는 이러한 연관 객체의 묶음을 애그리게잇AGGREGATE이라고 정의하고 애그리게잇에 포함된 특정 엔티티를 루트Root 엔티티라고 부른다. 그리고 리파지토리를 만들 때 애그리게잇 루트 엔티티에 대해서만 리파지토리를 제공하라고 한다.

이 글은 주문 도메인 예시를 통해 애그리게잇이 무엇인지 알아보고 왜 애그리게잇 루트에 대해서만 리파지토리를 제공해야 하는지에 대해 설명한다.

주문 예시

흔한(?) 주문 도메인 예시로 시작해 보자. Order(주문)는 하나의 배송지(ShippingAddress)와 하나 이상의 LineItem(품목)을 가지며 하나 이상의 OrderPayment(주문 결제) 가진다. OrderPayment는 추상 클래스로 MobilePhonePayment(휴대전화 결제)와 CreditCardPayment(신용카드 결제)가 상속한다.

image2019-1-2_11-11-5

JPAJava Persistence API 엔티티로 아래와 같이 구현할 수 있다.

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@Entity
@Table(name = "orders")
public class Order {
  @Id
  @GeneratedValue
  private Long id;
  private LocalDateTime createdAt;
  @OneToMany(mappedBy = "order")
  private List<LineItem> lineItems = new ArrayList<>();
  @OneToMany(mappedBy = "order")
  private List<OrderPayment> payments = new ArrayList<>();
  @OneToOne(mappedBy = "order")
  private ShippingAddress shippingAddress;
  // ...
}
@Entity
@Table(name = "line_items")
public class LineItem {
  @Id
  @GeneratedValue
  private Long id;
  private String productId;
  private String name;
  private Long price;
  private Integer qty;
  @ManyToOne
  @JoinColumn(name = "order_id", referencedColumnName = "id")
  private Order order;
  //...
}
@Entity
@Table(name = "shipping_address")
public class ShippingAddress {
  @Id
  @GeneratedValue
  private Long id;
  private String zipCode;
  private String recipient;
  @OneToOne
  @JoinColumn(name = "order_id", referencedColumnName = "id")
  private Order order;
  // ...
}
@Entity
@Table(name = "order_payments")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "method", discriminatorType = DiscriminatorType.STRING)
public abstract class OrderPayment {
  @Id
  @GeneratedValue
  private Long id;
  @Enumerated(EnumType.STRING)
  @Column(name = "method", nullable = false, insertable = false, updatable = false)
  private PaymentMethod method;
  public enum PaymentMethod {
    CREDIT_CARD (Values.CREDIT_CARD),
    MOBILE_PHONE (Values.MOBILE_PHONE);
    //...
  }
  private Long amount;
  @ManyToOne
  @JoinColumn(name = "order_id", referencedColumnName = "id")
  private Order order;
  //...
}
@Entity
@DiscriminatorValue(value = OrderPayment.PaymentMethod.Values.CREDIT_CARD)
public class CreditCardPayment extends OrderPayment {
  private String cardNumber;
  // ...
}
@Entity
@DiscriminatorValue(value = OrderPayment.PaymentMethod.Values.MOBILE_PHONE)
public class MobilePhonePayment extends OrderPayment {
  private String phoneNumber;
  // ...
}

그리고 Order를 아래와 같이 생성할 수 있다.

1
2
3
4
5
6
7
8
9
Order order = new Order();
// 품목
order.addLineItem(new LineItem("P-0001", "상품 A", 1000l, 2));
order.addLineItem(new LineItem("P-0002", "상품 B", 2000l, 1));
// 결제 수단
order.addOrderPayment(new CreditCardPayment(2000l, "1234-123"));
order.addOrderPayment(new MobilePhonePayment(2000l, "010-0000-0000"));
// 배송지
order.setShippingAddress(new ShippingAddress("12345", "Yoo Young-mo"));

애그리게잇AGGREGATE

Order를 주문(placeOrder)한다고 해보자. Order가 비즈니스 규칙에 위반되는 것은 없는지 확인할 필요가 있을 것이다.

  • 특정 상품은 특정 결제 수단으로만 결제할 수 있다.

흔히 이러한 비즈니스 규칙은 아래와 같이 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class OrderService {
  // ...
  public void placeOrder(Order order) {   
    validateOrder(order);
    // ...
  }
  private void validateOrder(Order order) {
    validatePaymentMethodPolicyOfLineItems(order);
    validateShippingAddress(order);
    //...
  }
  private void validatePaymentMethodPolicyOfLineItems(Order order) {
    boolean contains = order.getLineItems().stream().anyMatch(lineItem -> lineItem.getProductId().equalsIgnoreCase("P-0004"));
    if(contains) {
      if(order.getPayments().size() != 1
          || order.getPayments().stream().anyMatch(orderPayment -> orderPayment.getMethod() != OrderPayment.PaymentMethod.CREDIT_CARD)) {
        throw new IllegalArgumentException("P-0004 상품은 신용카드로만 결제가 가능 합니다.");
      }
    }
  }
  // ...
}

image2019-1-2_13-38-0

OrderService에 비즈니스 규칙을 구현함에 따라 OrderService는 Order 뿐만 아니라 연관된 LineItem, OrderPayment, ShippingAddress을 함께 참조하고 있다. 이런 경우 Order를 사용할 때 늘 비즈니스 규칙을 머릿속에 넣어두고 코딩해야 한다. 이 글에서는 이해를 위해 Order를 단순화했지만 실무에서는 Order는 훨씬 더 복잡한 연관 관계와 속성을 가진다. 복잡한 연관 관계를 가지는 Order를 모두 파악하고 사용하는 것은 쉬운 일이 아니다.

DDD의 저자 에릭 에반스Eric Evans는 "모델 내에서 복잡한 연관 관계를 맺는 객체를 대상으로 변경의 일관성을 보장하기란 쉽지 않다. 그 까닭은 단지 개별 객체만이 아닌 서로 밀접한 관계에 있는 객체 집합에도 불변식이 적용돼야 하기 때문이다."[3] 라고 말했다. 여기서 불변식Invariants은 데이터가 변경될 때마다 유지돼야 하는 일관성 규칙(비즈니스 규칙)을 뜻한다.[4]

Order, LineItem, ShippingAddress, OrderPayment는 각각이 아닌 하나의 집합으로 다루어야 한다. 에릭 에반스는 이를 애그리게잇AGGREGATE으로 정의한다.

모델 내의 참조에 대한 캡슐화를 추상화할 필요가 있다. AGGREGATE는 우리가 데이터 변경의 단위로 다루는 연관 객체의 묶음을 말한다. 각 AGGREGATE에는 루트(root)와 경계(boundary)가 있다. 경계는 AGGREGATE에 무엇이 포함되고 포함되지 않는지를 정의한다. 루트는 단 하나만 존재하며, AGGREGATE에 포함된 특정 ENTITIY를 가르킨다. 경계 안의 객체는 서로 참조할 수 있지만, 경계 바깥의 객체는 해당 AGGREGATE의 구성요소 가운데 루트만 참조할 수 있다. - 도메인 주도 설계, 131쪽

애그리게잇에 포함된 특정 엔티티를 루트 엔티티라고 한다고 했다. Order, LineItem, ShippingAddress, OrderPayment 중 어떤 것이 루트 엔티티일까?

DDD에서는 루트 엔티티는 전역 식별성Global identity을 지닌 엔티티라고 말한다. 필자는 전자 상거래 사이트에서 주문 파트 개발자로 일한 적이 있다. 콜 센터나 상품 파트, 회원 파트와 협업할 일이 매우 많았는데 대부분 사람들이 주문 번호를 말하며 의사소통했다. 필자가 보기에는 이것이 바로 전역 식별성이다.

또한, 안영회 님은 상품 정보 다룰 때 BoundedContext 와 엔터티 글에서 애그리게잇을 언급하며 아래처럼 말했다.

이런 경우는 조회 작업의 주체로 쓰이는 엔터티와 그렇지 않은 엔터티가 존재할 수 있습니다. DDD의 또 다른 빌딩블록인 Aggregate 가 떠오르는 지점입니다.

멋진 표현이다. "주체로 쓰이는 엔티티와 그렇지 않는 엔티티" 루트 엔티티는 주체로 쓰이는 엔티티이다.

결론적으로 이 글에서는 Order가 루트 엔티티가 될 수 있다.

image2019-1-3_10-7-23

이전 코드를 아래와 같이 리펙토링할 수 있다.  Order를 루트 엔티티로 하는 애그리게잇은 루트를 거쳐 접근하게 함으로써 참조(LineItem, OrderPayment, ShippingAddress)에 대한 캡슐화를 추상화했기 때문에 OrderService와의 결합도를 줄이면서 불변식을 효과적으로 이행할 수 있다.

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
public class OrderService {
  // ...
  public void placeOrder(Order order) {   
    order.placeOrder();
    // ...
  }
}
@Entity
@Table(name = "orders")
public class Order {
  // ...
  public void placeOrder() {
    this.validate();
    // ...
  }
  private void validate() {
    this.validatePaymentMethodPolicyOfLineItems();
    this.validateShippingAddress();
  }
  private void validatePaymentMethodPolicyOfLineItems() {
    boolean contains = this.getLineItems().stream().anyMatch(lineItem -> lineItem.getProductId().equalsIgnoreCase("P-0004"));
    if(contains) {
      if(this.getPayments().size() != 1
          || this.getPayments().stream().anyMatch(orderPayment -> orderPayment.getMethod() != OrderPayment.PaymentMethod.CREDIT_CARD)) {
        throw new IllegalArgumentException(String.format("P-0004 상품은 신용카드로만 결제가 가능 합니다."));
      }
    }
  }
}

리파지토리REPOSITORY

데이터를 보존하기 위해서 비휘발성 저장소에 저장하는 것을 영속화라고 한다. 리파지토리는 객체를 영속화하는데 사용한다. 이 글에서는 JPA를 사용하고 있기 때문에 관계형 데이터베이스에 객체를 영속화한다. DDD에서는 리파지토리를 만들 때 에그리게잇 루트 엔티티에 대해서만 리파지토리를 제공하라고 한다.

실질적으로 직접 접근해야 하는 AGGREGATE의 루트에 대해서만 REPOSITORY를 제공하고, 모든 객체 저장과 접근은 REPOSITORY에 위임해서 클라이언트가 모델에 집중하게 하라. - 도메인 주도 설계 REPOSITORY 157쪽

image2019-1-11_11-41-34

1
2
3
4
5
6
7
8
9
10
11
12
public interface OrderRepository {
  void save(Order order);
  void delete(Order order);
  Order findOne(Long orderId);
  // ...
}
public class OrderService {
  public void saveOrder(Order order) {   
    orderRepository.save(order);
    // ...
  }
}

왜 애그리게잇 단위로 리파지토리를 만들어야 할까?

먼저 데이터 무결성 측면에서 살펴보면 LineItem 혹은 OrderPayment만 데이터베이스에 저장한다면 주문 데이터 무결성이 깨지고 만다. 이것은 삭제도 동일하다. 따라서 애그리게잇으로 데이터베이스에 저장하고 삭제해야 한다.

데이터베이스에서 저장된 Order를 조회하여 배송지를 변경한다고 해보자.  배송지 변경에는 아래와 같은 불변식이 있을 수 있다.

  • 배송지에 따라 배송비 운임이 달라질 수 있으며, 상품에 따라 배송이 불가능할 수도 있다.

앞서 언급했던 것처럼 불변식을 효과적으로 이행하기 위해서는 데이터베이스에서 Order 에그리게잇으로 획득해야 캡슐화와 같은 특징을 활용할 수 있다.

1
2
3
4
5
6
7
8
public class OrderService {
  // ...
  public void changeShippingAddress(final Long orderId, final ShippingAddress shippingAddress) {   
    Order order = orderRepository.findOne(orderId);
    order.changeShippingAddress(shippingAddress);
    // ...
  }
}

JPA는 루트 엔티티(Order)의 참조 엔티티(LineItem, OrderPayment, ShippingAddress) 매핑에 cascade을 사용해서 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
@Table(name = "orders")
public class Order {
  // ...
  @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
  private List<LineItem> lineItems = new ArrayList<>();
  @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
  private List<OrderPayment> payments = new ArrayList<>();
  @OneToOne(mappedBy = "order", cascade = CascadeType.ALL)
  private ShippingAddress shippingAddress;
}
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@Repository
public class OrderRepositoryJpaImpl implements OrderRepository {
  @PersistenceContext
  private EntityManager entityManager;
  @Override
  public void save(Order order) {
    entityManager.persist(order);
  }
  //...
}

조슈아 블로크Joshua Bloch의 제언

앞서 Order를 생성할 때 자바빈즈JavaBeans를 사용했다.

1
2
3
4
5
6
7
8
9
Order order = new Order();
// 품목
order.addLineItem(new LineItem("P-0001", "상품 A", 1000l, 2));
order.addLineItem(new LineItem("P-0002", "상품 B", 2000l, 1));
// 결제 수단
order.addOrderPayment(new CreditCardPayment(2000l, "1234-123"));
order.addOrderPayment(new MobilePhonePayment(2000l, "010-0000-0000"));
// 배송지
order.setShippingAddress(new ShippingAddress("12345", "Yoo Young-mo"));

죠슈아 블로크의 그의 책 이펙티브 자바Effective Java에서 복잡한 객체를 생성할 때에 자바빈즈의 단점을 언급하며 빌더 패턴Builder pattern을 고려하라고 한다.

자바빈즈 패턴에서는 객체 하나를 만들려면 메서드를 여러개 호출해야 하고, 객체가 완전히 생성되기 전까지 일관성(Consistency)이 무너진 상태에 놓이게 된다. - 이펙티브 자바 3판, 16 쪽

빌더 패턴을 적용하면 Order 생성을 아래와 같이 변경할 수 있다.

1
2
3
4
5
6
7
Order order = new Order.Builder()
        .addLineItem(new LineItem("P-0001", "상품 A", 1000l, 2))
        .addLineItem(new LineItem("P-0002", "상품 B", 2000l, 1))
        .addOrderPayment(new CreditCardPayment(2000l, "1234-123"))
        .addOrderPayment(new MobilePhonePayment(2000l, "010-0000-0000"))
        .shippingAddress(new ShippingAddress("12345", "Yoo Young-mo"))
        .build();
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
@Entity
@Table(name = "orders")
public class Order {
  // ...
  public static class Builder {
    private List<LineItem> lineItems = new ArrayList<>();
    private List<OrderPayment> payments = new ArrayList<>();
    private ShippingAddress shippingAddress;
    public Builder() {
    }
    public Builder addOrderPayment(OrderPayment orderPayment) {
      this.payments.add(orderPayment);
      return this;
    }
    public Builder addLineItem(LineItem lineItem) {
      this.lineItems.add(lineItem);
      return this;
    }
    public Builder shippingAddress(ShippingAddress shippingAddress) {
      this.shippingAddress = shippingAddress;
      return this;
    }
    public Order build() {
      return new Order(this);
    }
  }
  public Order(Builder builder) {
    this.createdAt = LocalDateTime.now();
    builder.lineItems.forEach(lineItem -> this.addLineItem(lineItem));
    builder.payments.forEach(payment -> this.addOrderPayment(payment));
    this.setShippingAddress(builder.shippingAddress);
  }
}

주석

[1] Entity, Value Object, Aggregate, Domain Event, Service, Repository, Factory - https://en.wikipedia.org/wiki/Domain-driven_design#Building_blocks

[2] 상품 정보 다룰 때 BoundedContext 와 엔터티 DDD의 엔터티Entity 절에 잘 나와 있다.

[3] 도메인 주도 설계, 130 쪽

[4] 도메인 주도 설계, 132 쪽

참고 자료


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.