도메인 주도 개발 시작하기 - 3. 애그리거트

 Date: 2022-09-26

3.1 애그리거트
3.2 애그리거트 루트
3.3 리포지터리와 애그리거트
3.4 ID를 이용한 애그리거트 참조
3.5 애그리거트 간 집합 연관
3.6 애그리거트를 팩토리로 사용하기

3.1 애그리거트

  • 복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들려면 상위 수준에서 모델을 조망할 수 있어야 한다.
  • 상위 모델에 대한 이해 없이 개별 객체 단위로 개념을 이해하려면 더 복잡해진다. ➡️  주요 도메인 요소 간의 관계를 파악하기 어렵다는 것은 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다.
  • 애그리커트를 사용하면, 모델 간의 관계를 개별 모델 수준과 상위 수준에서 모두 이해할 수 있다.
  • 애그리거트는 모델을 이해하는데 도움을 줄 뿐만 아니라 일관성을 관리하는 기준도 된다.
    • 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖는다.
    • 애그리거트에 속한 구성요소는 대부분 함께 생성하고 함께 제거한다.
  • 애그리거트는 경계를 갖는다.
    • 경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다.
    • ‘A가 B를 갖는다’ 라는 요구사항이 A와 B가 반드시 같은 애그리거트에 속한다는 것은 아니다. (상품-리뷰)
    • 처음 도메인 모델을 만들기 시작하면 큰 애그리거트로 보이는 것들이 많지만, 도메인을 제대로 이해할수록 애그리거트의 크기는 줄어든다. (두개 이상의 엔티티로 구성되는 애그리거트는 드물었다.)

3.2 애그리거트 루트

  • 애그리거트 루트는 애그리거트에 속한 모든 객체가 일관된 상태를 유지하도록 관리하는 주체이다.
  • 애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직접 또는 간접적으로 속하게 된다.

3.2.1) 도메인 규칙과 일관성

  • 애그리거트 루트의 핵심 역할은 일관성이 깨지지 않도록 하는 것이다.
  • 애그리거트 루트가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다.
    e.g. 배송이 시작되기 전까지만 배송 정보를 변경할 수 있다.

      public class Order {
          // 애그리거트 루트는 도메인 규칙을 구현한 기능을 제공한다.
          public void changeShippingInfo(ShippingInfo newShippingInfo) {
              verifyNotYetShipped();
              setShippingInfo(newShippingInfo);
          }
        
          private void verifyNotYetShipped() {
              if (state != OrderState.PAYMENT_WATING & state != OrderState.PREPARING)
                  throw new IllegalArgumentException("alread shipped");
          }
          ...
      }
    
    • 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안된다.

      ShippingInfo si = order.getShippingInfo();
      si.setAddress(newAddress);
      
    • 상태 확인 로직을 응용 서비스에 구현할 수도 있지만, 중복으로 구현하게 될 가능성이 높아진다.

      ShippingInfo si = order.getShippingInfo();
      // 주요 도메인 로직이 중복되는 문제
      if (state != OrderState.PAYMENT_WATING & state != OrderState.PREPARING) {
          throw new IllegalArgumentException("alread shipped");
      }
      si.setAddress(newAddress);
      

    ➡️  애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들기

    • 단순히 필드를 변경하는 set 메서드를 공개(public) 범위로 만들지 않는다.
    • 밸류 타입은 불변으로 구현한다.

3.2.2) 애그리거트 루트의 기능 구현

  • 애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다.
  • 애그리거트 루트는 기능 실행을 위임하기도 한다.

      public class Order {
          private OrderLines orderLines;
        
          public void changeOrderLines(List<OrderLines> newLines) {
              orderLines.changeOrderLines(newLines);
              this.totalAmounts = orderLines.getTotalAmounts();
          }
          ...
      }
    
    • 이때 OrderLines를 조회할 수 있는 get 메서드를 제공하면 외부에서 OrderLines의 기능을 실행할 수 있게 된다. → 애초에 OrderLines를 불변으로 구현하자.

3.2.3) 트랜잭션 범위

  • 트랜잭션은 충돌을 방지하기 위해 잠금을 수행하므로, 트랜잭션 범위는 작을수록 좋다.
  • 한 트랜잭션에서는 한 애그리거트만 수정해야하며, 애그리거트 내부에서 다른 애그리거트의 상태를 변경하는 기능을 실행하면 안된다.

      public class Order {
          private Orderer orderer;
        
          public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
              verifyNotYetShipped();
              setShippingInfo(newShippingInfo);
                  if (useNewShippingAddrAsMemberAddr) {
                      // 다른 애그리거트의 상태를 변경하면 안됨!
                      orderer.getMember().changeAddress(newShippingInfo.getAddress());
                  }
          }
          ...
      }
    
  • 한 트랜잭션에서 두 개 이상의 애그리거트를 수정해야 한다면, 애그리거트 내부에서 수정하지 말고 응용 서비스에서 애그리거트를 수정하도록 구현한다.

      public class ChangeOrderService {
          @Transactional
          public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
              Order order = orderRepository.findById(id);
              if (order == null) throw new OrderNotFoundException();
              order.shipTo(newShippingInfo);
                
              if (useNewShippingAddrAsMemberAddr) {
                  Member member = findMember(order.getOrderer());
                  member.changeAddress(newShippingInfo.getAddress());
              }
          }
      		...
      }
    
  • 도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있다. (→ 10장 참고)

3.3 리포지터리와 애그리거트

  • 애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로, 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다.
    • Order와 OrderLine을 물리적으로 각각 별도의 DB 테이블에 저장한다고 해서 Order와 OrderLine을 위한 리포지터리를 각각 만들지 않는다.
  • 애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 한다.

3.4 ID를 이용한 애그리거트 참조

  • 한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다.
  • 애그리거트에서 다른 애그리커트를 참조한다는 것은, 다른 애그리거트의 루트를 참조한다는 것과 같다.
  • 필드를 통한 애그리거트 참조의 문제점
    • 편한 탐색 오용 한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있게 된다.
    • 성능에 대한 고민 애그리거트를 직접 참조하면 어떤 기능을 사용하느냐에 따라 쿼리의 로딩 전략을 결정해야 한다.
    • 확장 어려움 사용자가 늘고 트래픽이 증가하면 도메인별로 시스템을 분리하게 되는데, 이때 단일 기술을 사용할 수 없게 된다.

    ➡️  ID를 이용해서 다른 애그리거트를 참조하면, 위의 문제를 완화할 수 있다.

  • ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결된다.
    • 이는 애그리거트의 경계를 명확하게 하고, 애그리거트 간 물리적인 연결을 제거하기 때문에 모델의 복잡도를 낮춰준다.
    • 또한 애그리거트 간의 의존을 제거하므로 응집도를 높여주는 효과도 있다.
    • 구현 복잡도도 낮아진다.
      • 응용 서비스에서 필요한 애그리거트를 로딩하므로 애그리거트 수준에서 지연 로딩을 하는 것과 동일한 결과를 만든다.
      • ID를 이용한 참조 방식을 사용하면 애그리거트에서 다른 애그리거트를 수정하는 문제를 근원적으로 방지할 수 있다.
      • 애그리거트별로 다른 구현 기술을 사용할 수 있고, 각 도메인을 별도 프로세스로 서비스하도록 구현할 수도 있다.

3.4.1) ID를 이용한 참조와 조회 성능

  • 다른 애그리거트를 ID로 참조하면 참조하는 여러 애그리거트를 읽을 때 조회 속도가 문제가 될 수 있다.
    • 데이터가 한 DBMS에 있다면 조인을 이용해서 한 번에 모든 데이터를 가져올 수 있음에도 불구하고 주문마다 상품 정보를 읽어와야 한다.

        Member member = memberRepository.findById(ordererId);
        List<Order> orders = orderRepository.findByOrderer(ordererId);
        List<OrderView> dtos = orders.stream()
                .map(order -> {
                        ProductId prodId = order.getOrderLines().get(0).getProductId();
                        // 각 주문마다 첫 번째 주문 상품 정보 로딩을 위한 쿼리 실행
                        Product product = productRepository.findById(prodId);
                        return new OrderView(order, member, product);
                }).collect(toList());
      
  • 조회 대상이 N개 일 때, N개를 읽어오는 한 번의 쿼리와 연관된 데이터를 읽어오는 N번의 쿼리가 실행되는 N+1 조회 문제가 발생한다.
    • ID를 이용한 애그리거트 참조는 지연 로딩과 같은 효과를 만드는데, 지연 로딩과 관련된 대표적인 문제가 N+1 조회 문제이다.
  • ID 참조 방식을 사용하면서 N+1 조회와 같은 문제가 발생하지 않도록 하려면 조회 전용 쿼리를 사용하면 된다. (→ 5장, 11장 참고)
    • JPQL을 사용하여 애그리거트를 조인으로 한번만 로딩하는 예시

        @Repository
        public class JpaOrderViewDao implements OrderViewDao {
            @PersistenceContext
            private EntityManager em;
            
            @Override
            public List<OrderView> selectByOrderer(String ordererId) {
                String selectQuery = "select new com.myshop.order.application.dto.OrderView(o, m, p) " +
                                     "from Order o join o.orderLines ol, Member m, Product p " +
                                     "where o.orderer.memberId.id = :ordererId " +
                                     "and o.orderer.memberId = m.id " +
                                     "and index(ol) = 0 " +
                                     "and ol.productId = p.id " +
                                     "order by o.number.number desc";
                TypedQuery<OrderView> query = em.createQuery(selectQuery, OrderView.class);
                query.setParameter("ordererId", ordererId);
                return query.getResultList();
            }
        }
      
  • 애그리거트마다 서로 다른 저장소를 사용하면 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다.
    • 이때는 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다.
    • 이 방법은 코드가 복잡해지는 단점이 있지만, 시스템의 처리량을 높일 수 있다는 장점이 있다.

3.5 애그리거트 간 집합 연관

  • 애그리거트 간 컬렉션을 이용한 연관(1-N, M-N)에 대해 살펴보자.
  • 애그리거트간 1-N 관계는 Set과 같은 컬렉션을 이용해서 표현할 수 있다.

      public Category {
          private Set<Product> products;  // 다른 애그리거트에 대한 1-N 연관
      }
    
  • 개념적으로 존재하는 애그리거트 간의 1-N 연관을 실제 구현에 반영하는 것이 요구사항을 충족하는 것과는 상관없을 때가 있다.
    • 아래 코드는 Product 개수가 수만 개 정도로 많다면 성능에 심각한 문제를 일으킬 것이다.
      public Category {
          private Set<Product> products;  // 다른 애그리거트에 대한 1-N 연관
            
          public List<Product> getProducts(int page, int size) {
              List<Product> sortedProducts = sortById(products);
              return sortedProducts.subList((page - 1) * size, page * size);
          }
      }
    

    ➡️  따라서 개념적으로는 애그리거트 간에 1-N 연관이 있더라도, 성능 문제 때문에 애그리거트 간의 1-N 연관을 실제 구현에 반영하지 않는다.

      public class ProductListService {
        
          public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
              Category category = categoryRepository.findById(categoryId);
              checkCatetory(category);
              List<Product> products = productRepository.findByCategoryId(category.getId(), page, size);
              int totalCount = productRepository.countsByCategoryId(category.getId());
              return new Page(page, size, totalCount, products);
          }
          ...
      }
    
  • M-N 연관은 개념적으로 양쪽 애그리거트에 컬렉션으로 연관을 만든다.
    • 1-N 연관처럼 M-N 연관도 실제 요구사항을 고려하여 M-N 연관을 구현에 포함시킬지 결정해야 한다.
    • 보통 특정 카테고리에 속한 상품 목록을 보여줄 때 목록 화면에서 각 상품이 속한 모든 카테고리를 상품 정보에 표시하지 않는다. → 상품 상세 화면에서 제품이 속한 모든 카테고리를 확인한다.

➡️  개념적으로는 애그리거트 간에 양방향 M-N 연관이 있더라도, 실제 구현에서는 단방향 M-N 연관만 적용한다.

  • RDBMS를 이용해서 M-N 연관을 구현하려면 조인 테이블을 사용한다.
    • JPA에서 ID 참조를 이용한 M-N 단방향 연관 구현 예시

        @Entity
        @Table(name = "product")
        public class Product {
            @EmbeddedId
            private ProductId id;
            
            @ElementCollection(fetch = FetchType.LAZY)
            @CollectionTable(name = "product_category", joinColumns = @JoinColumn(name = "product_id"))
            private Set<CategoryId> categoryIds;
        }
      
      • JPQL의 member of 연산자를 이용해서 특정 Category에 속한 Product 목록을 구할 수 있다.

    💡 목록이나 상세 화면과 같은 조회 기능은 조회 전용 모델을 이용해서 구현하는 것이 좋다.

3.6 애그리거트를 팩토리로 사용하기

  • 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보자.

      public class Store {
          public Product createProduct(ProductId newProductId, ProductInfo pi) {
              if (isBlocked()) throw new StoreBlockedException();
              return ProductFactory.create(newProductId, getId(), pi);
          }
      }
    
  • 팩터리 메서드 내부에서 또 다른 팩토리에 생성을 위임할 수도 있다.