도메인 주도 설계 (3) - 애그리거트

gentledot·2021년 8월 28일
5

애그리거트 (Aggregate)

  • 상위 수준 개념을 이용하여 전체 모델을 정리하면 전반적인 관계를 이해하는 데 도움이 된다.

    • 예를 들어 온라인 쇼핑몰을 위한 시스템을 개발한다면...
  • 상위 수준 모델은 개별 객체 단위로 다시 그려 세부적으로 이해할 수 있다.

    • 상위 모델에 대한 이해 없이 개별 객체 단위에서 상위 수준에서 개념 을 파악하려면 더 오랜 시간이 걸린다.
      • 더 많은 코드를 보고 도메인 전문가와 더 많은 대화를 나눠야 비로소 상위 수준에서 모델 간의 관계가 이해되기 시작할 것이다.
      • 백개 이상의 테이블을 한 장의 ERD에 모두 표시하면 개별 테이블 간의 관계를 파악하느라 큰 틀에서 데이터 구조를 이해하는 데 어려움을 겪게 되는 것과 같다.
      • 도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워진다.
  • 주요 도메인 개념 간의 관계를 파악하기 어렵다는 것은 곧 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다.

    • 상위 수준에서 모델이 어떻게 엮여있는지 알아야 전체 모델을 망가뜨리지 않으면서 추가 요구사항을 모델에 반영할 수 있는데 세부적인 모델만 이해한 상태로는 코드를 수정하기가 두렵기 때문에 코드 변경을 최대한 회피하는 쪽으로 요구사항을 협의하게 된다.
    • 꼼수를 부려 당장 돌아가는 코드를 추가할 수는 있지만 이는 장기적인 관점에서 코드를 더 수정하기 어렵게 만들게 된다.
  • 연관 도메인을 aggregate 로 묶어 하나의 군으로 이해하면 좀 더 상위 수준에서 도메인 모델 간의 관계를 파악할 수 있다.

    • aggregate는 모델을 이해하는 데 도움을 줄 뿐만 아니라 일관성을 관리하는 기준이 된다.
      • 모델을 보다 잘 이해할 수 있고 (관련된 도메인 모델에 대한 이해)
      • 애그리거트 단위로 일관성을 관리하기 때문에 애그리거트는 복잡한 도메인을 단순한 구조로 만들어준다.
      • 복잡도가 낮아지는 만큼 도메인 기능을 확장하고 변경하는 데 필요한 노력(개발 시간)도 줄어든다.
  • aggregate는 관련된 모델을 하나로 모은 것이기 때문에 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프사이클을 갖는다.

    • 주문 aggregate를 만들려면 Order, Orderer, OrderLine 과 같은 관련 객체를 함께 생성해야 한다.
    • Order는 생성했는데 ShippingInfo는 만들지 않았거나 ShippingInfo를 생성하면서 Orderer를 생성하지 않는 경우는 없다.
    • 도메인 규칙에 따라 최초 주문 시점에 일부 객체를 만들 필요가 없는 경우도 있지만 aggregate에 속한 구성요소는 대부분 함께 생성하고 함께 제거한다.
  • aggregate는 경계를 갖는다. 한 aggregate에 속한 객체는 다른 aggregate에 속하지 않는다.

    • aggregate는 독립된 객체 군이며, 각 aggregate는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다.
    • 예를 들어, 주문 aggregate는 배송지를 변경하거나 주문 상품 개수를 변경하는 등 자기 자신을 관리하지만, 회원의 비밀번호를 변경하거나 상품의 가격을 변경하지는 않는다.
  • 경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다. 도메인 규칙에 따라 함께 생성되는 구성요소는 한 aggregate에 속할 가능성이 높다.

    • 예를 들어, 주문할 상품 개수, 배송지 정보, 주문자 정보는 주문 시점에 함께 생성되므로 이들은 한 애그리거트에 속한다.
    • 또한, OrderLine 주문 상품 개수를 변경하면 도메인 규칙에 따라 Order의 총 주문 금액을 새로 계산해야 한다.
    • 사용자 요구사항에 따라 주문 상품 개수와 배송지를 함께 변경하기도 한다.
  • 'A가 B를 갖는다' 로 설계할 수 있는 요구사항이 있더라도 이것이 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미 하는 것은 아니다.

    • Order가 ShippingInfo와 Orderer를 가진다. ≠ ShippingInfo와 Orderer 는 한 aggregate에 속한다.
    • 상품과 리뷰를 예로 들면 상품 상세 정보와 리뷰 내용을 함께 보여줘야 하는 요구사항이 있는 경우에 Product entity와 Review 엔티티가 한 aggregate에 속한다?
      • Product와 Review는 함께 생성되지 않고 함께 변경되지도 않는다. (Review의 변경이 Product에 영향을 주지 않고 Product의 변경이 Review에 영향을 주지 않는다.)
      • Product의 변경 주체는 상품 담당자이고 Review를 생성하고 변경하는 주체는 고객이다.
  • 처음 도메인 모델을 만들기 시작하면 큰 aggregate로 보이는 것들이 많지만 도메인에 대한 경험이 생기고 도메인 규칙을 제대로 이해할수록 실제 aggregate의 크기는 줄어들게 된다.

    • 책의 저자는 다수의 애그리거트가 한 개의 엔티티 객체만 갖는 경우가 많으며 두 개 이상의 엔티티로 구성되는 애그리거트는 드물게 존재한다고 이야기한다.

Aggregate Root

  • 주문 aggregate로 예를 들면 Order가 루트가 된다.

    • aggregate는 여러 객체로 구성되기 때문에 한 객체만 상태가 정상이어서는 안된다. (도메인 규칙 위반)
    • OrderLine을 변경하면 Order의 totalAmounts도 다시 계산하여 총 금액이 맞아야 한다.
    • aggregate 에 속한 모든 객체가 일관된 상태를 유지하려면 aggregate 전체를 관리할 주체가 필요한데 이 책임을 지는 것이 바로 aggregate의 root entity 이다.
    • aggregate에 속한 객체는 aggregate 루트 엔티티에 직접 또는 간접적으로 속한다.

도메인 규칙과 일관성

  • aggregate 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.

    • 이를 위해 aggregate 루트는 aggregate가 제공해야 할 도메인 기능을 구현한다.

    • 주문 aggregate ⇒ 배송지 변경, 상품 변경 등 제공

      public class Order {
      
      // 애그리거트 루트는 도메인 규칙을 구현한 기능을 제공한다.
      
          public void changeShippingInfo(ShippingInfo newShippingInfo) {
              verifyNotYetShipped();
              setShippingInfo(newShippingInfo);
          }
      
          private void verifyNotYetShipped() {
      
              if (state != OrderState.PAYMENT_WAITING && state != OrderState.WAITING)
                  throw new IllegalStateException("aleady shipped");
      
          }
          
          ...
      
      }
    • 예를 들어, 배송이 시작되기 전까지만 배송지 징보를 변경할 수 있다는 규칙이 있다면, aggregate 루트인 Order의 changeShippingInfo() 메서드는 이 규칙에 따라 배송 시작 여부를 확인하고 변경이 가능한 경우에만 배송지 정보를 변경해야 한다.

  • aggregate 루트가 아닌 다른 객체가 aggregate에 속한 객체를 직접 변경하면 안 된다. 이는 aggregate 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다.

    • 즉, 논리적인 데이터 일관성이 깨지게 되는 것.

      // order의 ShippingInfo를 setter로 직접 변경할 수 있다면? 
      // 업무 규칙을 무시하고 DB 테이블에서 직접 데이터를 수정하는 것과 같은 효과
      ShippingInfo si = order.getShippingInfo();
      si.setAddress(newAddress);
      
      // 주요 도메인 로직이 중복되는 문제 (orderState를 각 모델마다 점검해야하는가?)
      ShippingInfo si = order.getShippingInfo();
      
      if (state != OrderState.PAYMENT_WAITING && state != OrderState.WAITING) {
          throw new IllegalStateException("aleady shipped");
      }
      si.setAddress(newAddress);
  • 불필요한 중복을 피하고 aggregate 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음의 두 가지를 습관적으로 적용해야 한다.

    • 단순히 필드를 변경하는 set 메서드를 공개(public) 범위로 만들지 않는다.

      • 도메인 모델에서 습관적으로 작성하는 public set 메서드는 가급적 피해야 한다.
      • 공개 set 메서드는 중요 도메인의 의미나 의도를 표현하지 못하고 도메인 로직이 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산되게 만드는 원인이 된다.
      • 도메인 로직이 한곳에 응집 되어 있지 않게 되므로 코드를 유지보수할 때에도 분석하고 수정하는 데 더 많은 시간을 들이게 된다.
      • setter보다는 의미가 드러나는 메서드를 사용해서 구현한다.
    • 밸류 타입은 불변으로 구현한다.

      • 밸류 객체의 값을 변경할 수 없으면 aggregate 루트에서 밸류 객체를 구해도 값을 변경할 수 없기 때문에 aggregate 외부에서 밸류 객체의 상태를 변경할 수 없게 된다.

      • aggregate 외부에서 내부 상태를 함부로 바꾸지 못하므로 aggregate의 일관성이 깨질 가능성이 줄어든다.

      • 밸류 객체가 불변이면 밸류 객체의 값을 변경하는 방법은 새로운 밸류 개체를 할당하는 것뿐이다.

      • 밸류 타입의 내부 상태를 변경하려면 aggregate 루트를 통해서만 가능하다.

        public class Order {
        
            private ShippingInfo shippingInfo;
        
            public void changeShippingInfo(ShippingInfo newShippingInfo) {
                verifyNotYetShipped();
                setShippingInfo(newShippingInfo);
            }
        
            // set 메서드의 접근 허용 범위는 private.
            private void setShippingInfo(ShippingInfo newShippingInfo) {
                // value가 불변이면, 새로운 객체를 할당해서 값을 변경해야 한다.
                // this.shippingInfo.setAddress(newShippingInfo.getAddress()) 같은 코드를 사용할 수 없다.
                this.shippingInfo = newShippingInfo;
            }
        }
  • aggregate 루트가 도메인 규칙을 올바르게만 구현하면 aggregate 전체의 일관성을 올바르게 유지할 수 있다.

aggregate 루트의 기능 구현

  • aggregate 루트는 aggregate 내부의 다른 객체를 조합해서 기능을 완성한다.
    • Order ⇒ 총 주문 금액 산출을 위한 OrderLine 목록 사용
    • Member ⇒ 암호 변경을 위해 Password 객체에 암호 일치 여부를 확인
  • aggregate 루트가 구성요소의 상태만 참조하는 것은 아니다. 기능 실행을 위임하기도 한다.
    • 단, 외부에서 기능을 실행할 수 있게되므로 도메인 객체에 대해서 불변으로 구현하여야 한다.
    • 팀 표준이나 구현 기술의 제약으로 도메인 모델을 불변으로 구현할 수 없다면 모델의 변경 기능을 패키지나 protected 범위로 한정해서 외부에서 실행할 수 없도록 제한하는 방법이 있다.
      • 보통 한 aggregate에 속하는 모델은 한 패키지에 속하기 때문에 패키지나 protected 범위를 사용하면 aggregate 외부에서 상태 변경 기능을 실행하는 것을 방지할 수 있다.

Repository 와 Aggregate

  • aggregate는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지터리는 aggregate 단위로 존재한다.
    • 각 도메인 모델이 물리적으로 각각 별도의 DB 테이블에 저장한다고 해서 리포지터리를 각각 만들지 않는다. aggregate 루트를 위한 리포지터리만 존재한다.
  • 새로운 aggregate를 만들면 저장소에 aggregate를 영속화하고 aggregate를 사 용하려면 저장소에서 aggregate를 읽어야 하므로 리포지터리는 적어도 다음의 두 메서드를 제공해야 한다.
    • save: aggregate 저장
    • findById: id로 aggregate를 구함
    • 그 외에 필요에 따라 다양한 조건으로 aggregate를 검색하는 메서드나 aggregate를 삭제하는 메서드를 추가할 수 있다.
  • 어떤 기술을 이용해서 리포지터리를 구현하느냐에 따라 aggregate의 구현도 영향을 받는다.
    • ORM 기술 중의 하나인 JPA / Hibernate 를 사용하면 데이터베이스 관계형 모델에 객체 도메인 모델을 맞춰야 하는 경우도 있다.
    • 특히 legacy DB를 사용 해야 하거나 팀 내 DB 설계 표준을 따라야 한다면 DB 테이블 구조에 맞게 모델을 변경해야 한다. 이 경우 value type인 도메인 모델을 (JPA서 밸류 타입을 매핑할 때 사용하는) @Component 아닌 (엔터티를 매핑할 때 사용하는) @Entity 를 이용해야 할 수도 있다.
  • aggregate는 개념적으로 하나이므로 리포지터리는 aggregate 전체를 저장소에 영속화해야 한다.
    • aggregate 루트와 매핑되는 테이블뿐만 아니라 aggregate에 속한 모든 구성요소를 위한 테이블에 데이터를 저장해야한다.
  • 동일하게 aggregate를 구하는 리포지터리 메서드는 완전한 aggregate를 제공해야 한다.
    • Order order = orderRepository.findById(orderId);
    • 리포지터리가 완전한 aggregate를 제공하지 않으면, 필드나 값이 올바르지 않아 aggregate의 기능을 실행하는 도중에 NullPointerException 같은 문제가 발생하게 된다.
  • 저장소로 RDBMS (Oracle, Maria DB 등)뿐만 아니라 Mongo DB, HBase와 같은 NoSQL도 함께 사용하는 곳이 증가하고 있다. aggregate를 영속화할 저장소로 무엇을 사용하든지 간에 aggregate의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 한다.
    • aggregate에서 두 개의 객체를 변경했는데 저장소에는 한 객체에 대한 변경만 반영되면 데이터 일관성이 깨지므로 문제가 된다.
    • RDBMS를 이용해서 리포지터리를 구현하면 트랜잭션을 이용해서 aggregate의 변경이 저장소에 반영되는 것을 보장할 수 있다
    • Mongo DB를 사용하면 한 개 aggregate를 한 개 문서(Documents)에 저장함으로써 한 aggregate의 변경을 손실 없이 저장소에 반영할 수 있다.

ID를 이용한 aggregate 참조

  • 한 객체가 다른 객체를 참조하는 것처럼 aggregate도 다른 aggregate를 참조한다.

    • 다른 aggregate를 참조한다는 것은 aggregate의 루트를 참조한다는 것과 같다.

    • aggregate 간의 참조는 필드를 통해 쉽게 구현할 수 있다.

      • 필드를 이용해서 다른 aggregate를 직접 참조하는 것은 개발자에게 구현의 편리함을 제공한다.
        • order.getOrderer().getMember().getId()
    • JPA를 사용하면 @ManyToOne, @OneToOne 과 같은 annotation을 이용해서 연관된 객체를 로딩하는 기능을 제공하고 있으므로 필드를 이용해 다른 aggregate를 쉽게 참조할 수 있다.

    • ORM 기술 덕에 aggregate 루트에 대한 참조를 쉽게 구현할 수 있고, 필드(또는 getter)를 이용한 aggregate 참조를 사용하면 다른 aggregate의 데이터를 객체 탐색을 통해 조회할 수 있다.

필드를 이용한 aggregate 참조의 문제

  • 하지만, 필드를 이용한 aggregate 참조는 다음의 문제를 야기할 수 있다.
    • 편한 탐색 오용
    • 성능에 대한 고민
    • 확장의 어려움
  • aggregate를 직접 참조할 때 발생할 수 있는 가장 큰 문제는 편리함을 오용할 수 있다는 것!
    • 한 aggregate 내부에서 다른 aggregate 객체에 접근할 수 있으면 다른 aggregate 상태를 쉽게 변경할 수 있게 된다.
    • 트랜잭션 범위에서 언급한 것 처럼 한 aggregate가 관리하는 범위는 자기 자신으로 한정해야 한다.
    • 그런데, aggregate 내부에서 다른 aggregate 객체에 접근할 수 있으면 구현의 편리함 때문에 다른 aggregate를 수정하고자 하는 유혹에 빠지기 쉽다.
    • 한 aggregate에서 다른 aggregate의 상태를 변경하는 것은 aggregate 간의 의존 결합도를 높여서 결과적으로 aggregate의 변경을 어렵게 만든다.
  • 두 번째 문제는 aggregate를 직접 참조하면 성능과 관련된 여러 가지 고민을 해야 한다는 것이다.
    • JPA를 사용할 경우 참조한 객체를 지연 로딩 (lazy loading)즉시 로딩 (eager loading)의 두 가지 방식으로 로딩할 수 있다. 두 로딩 방식 중 무엇을 사용할지 여부는 aggregate의 어떤 기능을 사용하느냐에 따라 달라진다.
    • 단순히 연관된 객체의 데이터를 함께 화면에 보여주어야 하면 즉시 로딩이 조회 성능에 유리하지만, aggregate의 상태를 변경하는 기능을 실행하는 경우에는 불필요한 객체를 함께 로딩할 필요가 없으므로 지연 로딩이 유리하다.
    • 이런 다양한 경우의 수를 고려해서 연관 매핑과 JPQL/Criteria 쿼리의 로딩 전략을 결정해야 한다.
  • 세 번째 문제는 확장이다.
    • 초기에는 단일 서버에 단일 DBMS 서비스를 제공하는 것이 가능하다.
    • 문제는 사용자가 늘고 트래픽이 증가하면 자연스럽게 부하를 분산하기 위해 하위 도메인별로 시스템을 분리하기 시작한다.
      • 이 과정에서 하위 도메인마다 서로 다른 DBMS를 사용할 가능성이 높아진다.
      • 심지어 하위 도메인마다 다른 종류의 데이터 저장소를 사용하기도 한다. (한 하위 도메인은 Maria DB를 사용하고 다른 하위 도메인은 Mongo DB를 사용하는 식)
      • 이는 더 이상 다른 aggregate 루트를 참조하기 위해 JPA와 같은 단일 기술을 시용할 수 없음을 의미한다.

ID를 이용한 간접 참조

  • 필드를 이용한 필드를 이용한 aggregate 참조 시 발생하는 세 가지 문제를 완화할 때 사용할 수 있는 것이 ID를 이용해서 다른 aggregate를 참조하는 것이다.

  • ID를 이용한 참조는 DB 테이블에서의 외래키를 사용해서 참조하는 것과 비슷하게 다른 aggregate를 참조할 때 ID 참조를 사용한다는 점이다. 단, aggregate 내 엔티티를 참조할 때는 객체 레퍼런스로 참조한다.

  • lD 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 aggregate에 속한 객체들만 참조로 연결된다.

    • 이는 aggregate의 경계를 명확히 하고 애그리거트 간 물리적인 연결을 제거하기 때문에 모델의 복잡도를 낮춰준다.

    • 또한, aggregate 간의 의존을 제거하므로 응집도를 높여주는 효과도 있다.

    • 구현 복잡도도 낮아진다. 다른 aggregate를 직접 참조하지 않으므로 aggregate 간 참조를 지연 로딩으로 할지 즉시 로딩으로 할지 고민하지 않아도 된다. 참조하는 aggregate가 필요하면 응용 서비스에서 아이디를 이용해서 로딩하면 된다.

      public class ChangeOrderService {
      		// 응용 서비스에서 필요한 애그리거트를 로딩하므로 
      		// aggregate 수준에서 lazy loading을 하는 것과 동일한 결과를 만든다.
          @Transactional
          public void changeShippingInfo(OrderId id,
                                         ShippingInfo newShippingInfo,
                                         boolean useNewShippingAddrAsMemberAddr) {
      
              Order order = orderRepository.findbyId(id);
              if (order == null) {
                  throw new OrderNotFoundException();
              }
              order.changeShippingInfo(newShippingInfo);
              if (useNewshippingAddrAsMemberAddr) {
                  // ID를 이용해서 참조하는 aggregate를 구한다.
                  Customer customer = customerRepository.findById(order.getOrderer().getCustomerId());
                  customer.changeAddress(newShippingInfo.getAddress());
              }
          }
          
          ...
      }
  • ID를 이용한 참조 방식을 사용하면 복잡도를 낮추는 것과 함께 한 aggregate에서 다른 aggregate를 수정하는 문제를 원천적으로 방지할 수 있다. 외부 aggregate를 직접 참조하지 않기 때문에 애초에 한 aggregate에서 다른 aggregate의 상태 를 변경할 수 없는 것이다.

  • aggregate별로 다른 구현 기술을 사용하는 것도 가능해진다.

    • 중요한 데이터인 주문 aggregate는 RDBMS에 저장하고 조회 성능이 중요한 상품 aggregate는 NoSQL에 저장할 수 있다. 또한, 각 도메인을 별도 프로세스로 서비스하도록 구현 할 수도 있다.

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

  • 다른 aggregate를 ID로 참조하면 참조하는 여러 aggregate를 읽어야 할 때 조회 속도가 문제될 수 있다.

    • 예를 들어, 주문 목록을 보여주려면 상품 aggregate와 회원 aggregate를 함께 읽어야 한다 할 때 한 DBMS에 데이터가 있다면 조인을 이용해서 한 번에 모든 데이터를 가져올 수 있음에도 불구하고 주문마다 상품정보를 읽어오는 쿼리를 실행하게 된다.

      /* N+1 조회 문제 */
      // 주문을 읽어들이는 쿼리
      Customer customer = customerRepository.findById(ordererId);
      // 주문 내 상품목록 쿼리
      List<Order> orders = orderRepository.findByOrderer(ordererId);
      
      // 주문별 각 상품을 읽어오는 쿼리
      // 연관된 데이터를 읽어오는 쿼리를 N번 실행한다.
      List<OrderView> dtos = orders.stream()
              .map(order -> {
                  ProductId prodId = order.getOrderLines().get(0).getProductId(); // 각 주문마다 첫 번째 주문 상품 정보 로딩 위한 퀴리 실행
                  Product product = productRepository.findById(prodId);
                  return new OrderView(order, customer, product);
              }).collect(toList());
    • ID를 이용한 aggregate 참조는 지연 로딩과 같은 효과를 만드는데 지연 로딩과 관련된 대표적인 문제가 N+1 조회 문제이다.

  • N+1 조회 문제는 더 많은 쿼리를 실행해서 전체 조회 속도가 느려지는 원인이 된다. 이 문제가 발생하지 않도록 하려면 조인을 사용하도록 해야 한다.

    • 조인을 사용하는 가장 쉬운 방법은 ID 참조 방식을 객체 참조 방식으로 바꾸고 즉시 로딩을 사용하도록 매핑 설정을 바꾸는 것이다. 하지만, 이 방식은 aggregate 간 참조를 ID참조 방식에서 객체 참조 방식으로 다시 되돌리는 것이다.
  • N+1 문제가 발생하지 않도록 하려면 전용 조회 쿼리를 사용하면 된다.

    • 데이터 조회를 위한 별도 DAO를 만들고 DAO의 조회 메서드에서 세타 조인을 이용해서 한 번의 쿼리로 필요한 데이터를 로딩하면 된다.

      @Repository
      public class JpaOrderViewDao implements OrderViewDao {
      
          @PersistenceContext
          private EntityManager em;
      		
      		// 위 코드는 JPA를 이용해서 특정 시용자의 주문 내역을 보여주기 위한 코드이다.
      		// 이 코드는 JPQL을 시용하는데, 이 JPQL은 Order aggregate와 Member aggregate, 그리고 Product aggregate를 세타 조인으로 조회해서 한번의 쿼리로 로딩한다.
          @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();
          }
      
      }
      • 즉시 로딩이나 지연 로딩과 같은 로딩 전략을 고민할 필요 없이 조회 화면에서 필요한 aggregate 데이터를 한 번의 쿼리로 로딩할 수 있다. 쿼리가 복잡하거나 SQL에 특화된 기능을 사용해야 한다면 조회를 위한 부분만 MyBatis 같은 기술을 이용해서 실행할 수도 있다.
  • 각 객체 간 모든 연관을 지연 로딩과 즉시 로딩으로 어떻게든 처리하고 싶은 욕구에 사로잡힐 수 있지만 이는 실용적이지 읺다. 앞서 코드에서 보는 것처럼 아이디를 이용해서 aggregate를 참조해도 한 번의 쿼리로 필요한 데이터를 로딩하는 것이 가능하다. (조회 전용 쿼리 실행)

  • aggregate마다 서로 다른 저장소를 사용하는 경우에는 한 번의 쿼리로 관련 aggregate를 조회할 수 없다.

    • 이런 경우 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다.
      • 이 방법은 코드가 복잡해지는 단점이 있지만 시스템의 처 리량을 높일 수 있다는 장점이 있다.
      • 특히 한 대의 DB 장비로 대응할 수 없는 수준의 트래픽이 발생하는 경우 캐시나 조회 전용 저장소는 필수로 선택해야 하는 기법이다.

aggregate 간 집합 연관

  • aggregate 간 1:N과 M:N 연관은 컬렉션을 이용한 연관이다.
    • 예를 들어, 카테고리와 상품 간의 연관이 대표적이다 카테고리 입장에서 한 카테고리에 한 개 이상의 상품이 속할 수 있으니 카테고리와 상품은 1:N 관계 이다. 한 상품이 한 카테고리에만 속할 수 있다면 상품과 카테고리 관계는 N:1 관계이다.

1:N 관계

  • aggregate 간 1:N 관계는 Set과 같은 컬렉션을 이용해서 표현할 수 있을 것이다.

    • 예를 들어, 다음 코드처럼 Category가 연관된 Product를 값으로 갖는 컬렉션을 필드로 정의할 수 있다.

      private Set<Product> products; // 다른 애그리거트에 대한 1:N 연관
  • 그런데, 개념적으로 존재하는 aggregate 간의 1:N 연관을 실제 구현에 반영하는 것이 요구사항을 충족하는 것과 상관없는 경우가 종종 있다.

    • 보통 목록 관련 요구사항은 한 번에 전체 상품을 보여주기보다는 페이징을 이용해서 제품을 나눠서 보여준다.

      // 카테고리의 품목 가져오기
      // 카테고리 입장에서 1:N 연관을 이용해서 구현하면 다음과 같은 방식으로 코드를 작성해야 한다.
      public List<Product> getProducts(int page, int size) {
          List<Product> sortedProducts = sortById(products);
          return sortedProducts.subList((page - 1) * size, page * size);
      }
      • 이 코드를 실제 DBMS와 연동해서 구현하면 Category에 속한 모든 Product를 조회 하게 된다.
      • Product 개수가 수백 에서 수만 개 정도로 많다면 이 코드를 실행할 때마다 실행 속도가 급격히 느려져 성능에 심각한 문제를 일으킬 것이다.
    • 개념적으로는 애그리거트 간에 1:N 연관이 있더라도 이런 성능상의 문제 때문에 aggregate 간의 1:N 연관을 실제 구현에 반영하는 경우는 드물다.

  • 카테고리에 속한 상품을 구할 필요가 있다면 상품 입장에서 자신이 속한 카테고리 를 N:1로 연관지어 구하면 된다.

    • 이를 구현 모델에 반영하면 Product에 다음과 같이 Category로의 연관을 추가하고 그 연관을 이용해서 특정 Category에 속한 Product 목록을 구하면 된다.

      public class Product {
          ... 
          
          private CategoryId category;
          
          ...
      
      }
      // 카테고리에 속한 상품 목록을 제공하는 응용 서비스는 
      // 다음과 같이 productRepository를 이용해서 categoryId가 지정한 카테고리 식별자인 products 목록을 구한다.
      public class ProductListService {
      
          public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
              Category category = categoryRepository.findById(categoryId);
              checkCategory(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 관계

  • M:N 연관은 개념적으로 양쪽 aggregate에 컬렉션으로 연관을 만든다.

    • 상품이 여러 카테고리에 속할 수 있다고 가정하면 카테고리와 상품은 M:N 연관을 맺는다.
    • M:N 연관도 실제 요구사항을 고려해서 M:N 연관을 구현에 포함시킬지 여부를 결정해야 한다.
  • 보통 특정 카테고리에 속한 상품 목록을 보여줄 때 목록 화면에서 각 상품이 속한 모든 카테고리를 상품 정보에 표시하지는 않는다.

    • 제품이 속한 모든 카테고리가 필요한 화면은 상품 상세 화면이다.

    • 이 요구사항을 고려할 때 카테고리에서 상품으로의 집합 연관은 필요하지 않다. 상품에서 카테고리로의 집합 연관만 존재하면 된다.

    • 즉, 개념적으로는 상품과 카테고리의 양방향 M:N 연관이 존재하지만 실제 구현에서는 상품에서 카테고리로의 단방향 M:N 연관만 적용하면 되는 것이다.

      public class Product {
          ... 
          
          private Set<CategoryId> categoryIds;
          
          ...
      
      }
  • RDBMS를 이용해서 M:N 연관을 구현하려면 Join 테이블을 사용한다.

  • JPA를 이용하면 다음과 같은 매핑 설정을 사용해서 ID 참조를 이용한 M:N 단방향 연관을 구현할 수 있다.

    • 컬렉션 매핑을 통해 JPQL의 member of 연산자를 이용해 특정 Category에 속한 Product 목록을 구하는 기능을 구현할 수 있다.

      @Entity
      @Table(name = "product")
      public class Product {
          @EmbeddedId
          private ProductId id;
      
      		// 카테고리 ID 목록을 보관하기 위해 Value Type에 대한 CollectionMapping을 이용함.
          @ElementCollection
          @CollectionTable(name = "product_category", 
                  joinColumns = @JoinColumn(name = "product_id"))
          private Set<CategoryId> categoryIds;
          ...
      }
      @Repository
      public class jpaProductRepository implements ProductRepository {
      
          @PersistenceContext
          private EntityManager entityManager;
      
          @Override
          public List<Product> findByCategoryId(CategoryId categoryId, int page, int size) {
      
      				/* :catId member of p.categoryIds
      				=> categoryIds 컬렉션에 catId로 지정한 값이 존재하는지 여부를 검사하기 위한 검색조건 */
              TypedQuery<Product> query = entityManager.createQuery(
                      "select p from Product p " +
                              "where :catId member of p.categoryIds " +
                              "order by p.id.id desc"
                      , Product.class);
              query.setParameter("catId", categoryId);
              query.setFirstResult((page - 1) * size);
              query.setMaxResults(size);
      
              return query.getResultList();
          }
      }

Aggregate를 Factory로 사용하기

  • 예를 들어, 온라인 쇼핑몰에서 고객이 여러 차례 신고를 해서 특정 상점이 더 이상 물건을 등록하지 못하도록 차단한 상태라고 할 때
    • 상품 등록 기능을 구현한 응용 서비스는 상점 계정이 차단 상태가 아닌 경우에만 상품을 생성하도록 구현할 수 있을 것임.
    • 차단상태 확인 → 상품 생성 하는 것은 논리적으로 하나의 도메인 기능
  • Store, Product가 도메인 모델로 있다고 할 때
    • Store에서 새로운 Product를 생성할 수 있는 factory method 기능을 구현 (return new Product)
    • Store가 Product를 생성할 수 있는지 여부를 확인하는 도메인 로직은 Store 에서 구현하고
    • Product 생성 가능 여부를 확인하는 도메인 로직을 변경해도 도메인 영역의 Store만 변경하면 되고
    • 응용 서비스는 영향을 받지 않는다. 도메인의 응집도도 높아진다.
  • aggregate가 갖고 있는 데이터를 이용해서 다른 aggregate를 생성해야 한다면 aggregate에 factory method를 구현하는 것을 고려해보자.
    • 대상 aggregate의 데이터를 이용해 생성할 aggregate 객체를 생성한다.
    • 대상 aggregate의 상태 값을 이용해 생성 조건을 설정할 수 있다.
    • 즉, 대상 aggregate의 데이터 일부를 직접 제공하면서 생성할 aggregate 의 도메인 로직을 함께 구현할 수 있게 된다.
profile
그동안 마신 커피와 개발 지식, 경험을 기록하는 공간

0개의 댓글