[DDD] 3장. 애그리거트

매빈·2023년 3월 19일
0

1. 애그리거트


3.1 애그리거트

  • 애그리거트: 상위 수준에서 모델을 정리하는 것. 도메인 모델의 복잡한 관계를 이해하는데 도움을 줌.
  • 필요성: 도메인 객체 모델이 복잡해지면 도메인 간의 관계를 파악하기 어려워지고, 그렇게 되면 코드를 변경하고 확장하는 것이 어려워지므로 애그리거트를 사용해야 함.
  • 장점
    • 모델을 이해하는 데 도움을 줌.
    • 일관성을 관리하는 기준이 됨.
    • 복잡한 도메인을 단순한 구조로 만들어 줌.
    • 도메인 기능을 확장하고 변경하는데 필요한 노력도 줄어듬.
  • 특징
    • 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 가짐.
    • 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않음.
    • 각 애그리거트는 독립된 객체군임
      ex. 주문 애그리거트 - 배송지 변경 O, 주문 상품 개수 변경 O / 회원 비밀번호 변경 X, 상품의 가격 변경 X
  • 애그리거트 경계는 도메인 규칙과 요구사항에 따라 설정함.
    - 도메인 규칙: 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높음.

    ❗️ 주의할 점
    함께 변경되는 빈도가 높은 객체는 한 애그리거트에 속할 가능성이 높지만, 반드시 한 애그리거트에 속한다는 것을 의미하는 것은 아님.
    한 객체의 변경이 다른 객체에 영향을 주지 않으면 다른 애그리거트에 속하는 것.

2. 애그리거트 루트와 역할


3.2 애그리거트 루트

  • 애그리거트 루트 엔티티
    - 도메인 규칙을 지키기 위해서는 모든 객체가 정상 상태를 가져야 함 ➡️ 애그리거트 전체를 관리할 주체가 필요한테, 이것이 바로 애그리거트 루트 엔티티.
    - 애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직간접적으로 속함.

3.2.1 도메인 규칙과 일관성

  • 애그리거트 루트의 핵심 역할: 애그리거트의 일관성이 깨지지 않는 것.
    ➡️ 그러기 위해 애그리거트가 제공해야 할 도메인 기능을 구현. 즉, 기능이 구현된 메서드를 제공함.

  • 실습 코드

    public class Order {
        // 애그리거트 루트는 도메인 규칙을 구현한 기능을 제공함
        public void changeShippingInfo(ShippingInfo newShippingInfo) {
            verifyNotYetShipped();
            setShippingInfo(newShippingInfo);
        }
    
        private void verifyNotYetShipped() {
            if (state != OrderState.PAYMENT_WAITING && STATE != OrderState.PREPARING throw new IllegalStateException("already shipped");
        }
    
        ...
    
  • 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하지 않으면 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 됨.

    ShippingInfo si = order.getShippingInfo(); //애그리거트 루트인 order에서 ShippingInfo를 가져와 직접 정보를 변경하는 코드
    si.setAddress(newAddress);
    
    // 변경 후
    ShippingInfo si = order.getShippingInfo();
    
    if (state != OrderState..PAYMENT_WAITING && state != OrderState.PREPARING) {
    	throw new IllegalArgumentException();
    }
    si.setAddress(newAddress);
  • 애그리거트 루트를 통해서만 로메인 로직을 구현하는 방법
    - 단순히 필드를 변경하는 set 메서드를 public으로 만들지 않기
    - 밸류 타입은 불변으로 구현
    ➡️ 밸류 객체가 불변일 때 밸류 객체의 값을 변경하는 방법: 새로운 밸류 객체 할당
    ex.

    ```
    public class Order {
    	private ShippingInfo shippingInfo;
        
        public void changeShippingInfo(ShippingInfo newShippingInfo) {
        	vefifyNotYetShipped);
            setShippingInfo(newShippingInfo);
        }
        
        // set 메서드의 접근 허용범위를 private로 설정 
        private void setShippingInfo(ShippingInfo newShippingInfo_ {
        	// 밸류 불변 ➡️ 새로운 객체를 할당해서 값을 변경.
        	this.shippingInfo = newShippingInfo;
        }
        
        ...
        
    }
    ```

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

  • 애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성함.

    • ex 1. Order(애그리거트 루트)가 총 주문 금액을 구하기 위해 OrderLine 목록을 사용

      public class Order {
          private Money totalAmounts;
          private List<OrderLine> orderLines;
      
          private void calculateTotalAmounts() {
              int sum = orderLines.stream()
                      .mapToInt(ol -> ol.getPrice() * ol.getQuantity())
                      .sum();
              this.totalAmounts = new Money(sum);
          }
      }
    • ex 2. Member 애그리거트 루트는 암호를 변경하기 위해 Password 객체에 암호가 일치하는지를 확인

      public class Member {
          private Password password;
      
          public woid changePassword(String currentPassword, String newPassword) {
              if (!password.match(currentPassword)) {
                  throw new PasswordNotMatchException();
              }
              this.password = new Password(newPassword);
          }
      }
  • 애그리거트 루트가 구성요소에 기능 실행을 위임하기도 함

    public class OrderLines {
        private List<OrderLine> lines;
    
        public Money getTotalAmounts() {
            ...
        }
        public void chageOrderLines(List<OrderLine> newLines) {
        this.lines = newLines;
        }
    }
    
    // orderLines 필드에 상태 변경을 위임하는 방식으로 기능 구현
    public class Order {
        private OrderLines orderLines;
    
        public void changeOrderLines(List<OrderLine> newLines) {
            orderLines.changeOrderLines(newLines);
            this.totalAmounts = orderLines.getTotalAmounts();
        }
    }
    

3.2.3 트랜잭션 범위

  • 트랜잭션 범위는 작을수록 좋음.

  • 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 함. 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것을 의미함.

    public class Order {
    	private Orderer orderer;
      
      public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
      	verifyNotYetShipped();
          setShippingInfo(newShippingInfo);
          
          if (userNewShippingaddrAsMemberAddr) {
          	// 다른 애그리거트의 상태를 변경하면 안 됨
              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 (userNewShippingAsMemberAddr) {
          	Member member = findMemeber(order.getOrderer());
              member.changeAddress(newShippingInfo.getAddress());
          }
      }
    }
  • 도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있음.

  • 한 트랜잭션에서 한 개의 애그리거트를 변경하는 것을 권장하지만 아래의 경우에는 두 개 이상의 애그리거트를 변경하는 것을 고려할 수 있음.

    • 팀 표준: 팀이나 조직의 표준에 따라 사용자 유스케이스와 관련된 응용 서비스의 기능을 한 트랜잭션으로 실행해야 하는 경우가 있음
    • 기술 제갹: 기술적으로 이벤트 방식을 도입할 수 없는 경우 한 트랜잭션에서 다수의 애그리거트를 수정해서 일관성을 처리해야 함
    • UI 구현의 편리: 운영자의 편리함을 위해 주문 목록 화면에서 여러 주문의 상태를 한 번에 변경하고 싶을 때는 한 트랜잭션에서 여러 주문 애그리거트의 상태를 변경해야 함.

3. 애그리거트와 리포지터리


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

  • 리포지터리: 객체의 영속성을 처리, 애그리거트 단위로 존재함 ➡️ 애그리거트는 완전한 한 개의 도메인 모델을 표현하므로.
  • 리포지터리가 기본으로 제공하는 메서드
    • save: 애그리거트 저장
    • findById: ID로 애그리거트를 구함
    • 이 외에도 다양한 조건으로 애그리거트를 검색하거나 삭제하는 메서드를 추가할 수 있음.
  • 리포지터리를 구현하는 기술에 따라 애그리거트의 구현도 영향을 받음.
  • 리포지터리에 애그리거트를 저장하면 애그리거트 전체를 영속화해야 함.
    orderRepository.save(order);
  • 리포지터리 메서드는 완전한 메서드를 제공해야 함. 그렇지 않으면 기능 실행 도중 문제 발생.
Order order = orderRepository.findById(orderId);

order.cancel();
  • 애그리거트를 영속화할 저장소가 무엇이든 간에 애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 함. 그렇지 않으면 일관성이 깨지는 문제 발생. ➡️ RDBMS를 이용하여 리포지터리를 구현하면 트랜잭션을 이용하여 애그리거트의 변경이 저장소에 반영되는 것을 보장할 수 있음.
    4장에서 더 알아보기

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


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

  • 애그리거트는 다른 애그리거트를 참조할 수 있음.
    = 다른 애그리거트의 루트를 참조한다는 것과 같은 의미.

  • 참조 방법: 필드를 통해 구현.

    • 개발자에게 구현의 편리함을 제공

    • ex. 주문한 회원을 참조하기 위해 회원 애그리거트 루트인 Member 참조

  • 애그리거트 참조의 장단점

    • 장점
      • 개발자에게 구현의 편리함을 제공
        // 이런 식으로 편하게 회원 Id를 구할 수 있음.
        order.getOrderer().getMember().getId()
        
        // JPA의 경우 @ManyToOne, @OneToOne과 같은 애너테이션 사용하여 다른 애그리거트 참조
    • 단점
      • 편한 탐색 오용
      public class Order {
      	private Orderer orderer;
        
        public void changeShippingInfo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
        ...
        
          if (usenewShippingAddrAsMemberAddr) {
              // 한 애그리거트 내에서 다른 애그리거트에 접근할 수 있으면 다른 애그리거트의 상태를 변경하는 유혹에 빠지기 쉬움
              // 하지만 이는 애그리거트 간의 의존 결합도를 높여 애그리거트의 변경을 어렵게 만듦.
              orderer.getMember().changeAddress(newShippingInfo.getAddress());
          }
        }
      }
      • 성능에 대한 고민: 지연 로딩/즉시 로딩 중 무엇을 사용할지 다양한 경우의 수를 고려하여 결정해야 함.
      • 확장 어려움: 사용자가 늘고 트래픽이 증가하면 부하를 분산하기 위해 하위 도메인별로 시스템 분리하기 시작함 ➡️ 도메인마다 서로 다른 DBMS를 사용하게 되면 다른 애그리거트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없게 됨.
        ➡️ 이러한 문제를 해결하기 위해 ID를 이용하여 애그리거트를 간접 참조함.
  • ID를 이용한 간접 참조

    • 한 애그리거트에 속한 객체들만 참조로 연결되어, 애그리거트의 경계를 명확히 하고 애그리거트 간 물리적인 연결을 제거해줌
    • 모델의 복잡도 낮아짐.
    • 애그리거트 간의 의존을 제거하여 응집도를 높여줌,
    • 구현 복잡도 낮아짐(성능에 대한 고민 X).
    • 애그리거트 별로 다른 구현 기술을 사용하는 것이 가능해짐.
    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.changeShippingInfo(newShippingInfo);
    
            if (useNewShippingAsMemberAddr) {
                // ID를 이용하여 참조하는 애그리거트를 구하기
                Member member = memberRepository.findById(order.getOrderer().getMemberId());
               // 외부 애그리거트를 직접 참조하지 않으므로 애초에 다른 애그리거트의 상태를 변경할 수 없음. member.changeAddress(newShippingInfo.getAddress());
            }
        }
    }

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

  • ID로 참조할 경우, 참조하는 여러 애그리거트를 읽을 때 조회 속도가 저하될 수 있음.
  • ex. N+1 조회 문제: 조회 대상이 N개일 때 N개를 읽어오는 한 번의 쿼리와 연관된 데이터를 읽어오는 쿼리를 N번 실행 ➡️ 전체 조회 속도가 느려지는 원인
Member member = memberRepository.findById(orderId)
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());
        
// 문제 해결을 위해 ID 참조 방식을 사용하면서 join을 사용해야 함.
// 데이터 조회를 위한 별도 DAO를 만들고, DAO의 조회 메서드에서 join을 이용해 한 번에 필요한 데이터 로딩
@Repository
public class JapOrderViewDao implements OrderViewDao {
	@PersistenceContext
    private EntityManager em;
    
    @Override
    public List<OrderView> selectByOrderer(String ordererId) {
    // Order, Member, Product 애그리거트를 join으로 조회하여 한 번의 쿼리로 로딩
    	String selectQuery = 
        	"select new com.myshop.order.application.dto.OrderView(o, m, p)" + 
            "from Order o join o.orderLines ol, Memeber 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();
    }
}
  • 애그리거트마다 서로 다른 저장소를 사용할 경우, 한 번의 쿼리로 관련 애그리거트를 모두 조회할 수 없음 ➡️ 조회 성능을 높이기 위해 캐시 적용 OR 조회 전용 저장소를 따로 구성하기.
    5장에서 JPA에서 조회 전용 쿼리를 실행하는 방법 알아보기
    11장에서 명령 모델과 조회 전용 모델을 분리해서 구현하는 패턴 살펴보기

3.5 애그리거트 간 집합 연관

  • 애그리거트 간 집합 연관은 컬렉션을 이용한 연관(ex. 카테고리와 상품 간의 연관, 1-n 관계)
  1. 1-N 관계
  • Set과 같은 컬렉션을 이용하여 표현
  • ex.
    public class Category {
    	private Set <Product> products; // 다른 애그리거트에 대한 1-N 연관
    }
  • 1-N 연관을 반영하는 것이 요구사항과는 상관없는 경우
    // 요구사항: 특정 카테고리에 속한 상품 목록 보여주기
    public class Category {
    	private Set<Product> products;
      
      public List<Product> getProducts(int page, int size) {
      	List<Product> sortedProducts = sortById(products);
          return sortedProducts.subList((page - 1) * size, page * size);
      }
      
      ...
      
    }
    ➡️ 이 코드를 실제 DBMS와 연동해서 구현하게 되면 Category에 속한 모든 Product를 조회하게 되는데, 이는 데이터가 많을 경우 실행 속도를 느려지게 함. 따라서, 개념적으로 1-N 관계와 관련이 있어도 애그리거트 간의 1-N 연관을 실제 구현에 반영하지는 않음.
    ➡️ 이 경우에는 상품 입장에서 자신이 속한 카테고리를 N-1로 연결하기
    public class Product {
      ...
      private CategoryId categoryId;
      ...
    }
  • ex. 카테고리에 속한 상품 목록을 제공하는 응용 서비스 구현
    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);
      }
      ...
    }
  1. M-N 연관
  • 양쪽 애그리거트에 컬렉션으로 연관을 만듬.

  • 실제 요구사항을 고려하여 M-N 연관을 구현에 포함시킬지 결정하기.

  • ex. 개념적으로는 상품-카테고리가 양방향으로 M-N 연관을 가지고 있지만, 실제 구현에서는 상품에서 카테고리로의 단방향 M-N 연관만 적용

    public class Product {
    	private Set<CategoryId> categoryIds;
        ...
    }
  • 조인 테이블을 이용한 M-N 연관 매핑

  • JPA를 통한 ID 참조를 이용한 M-N 단방향 연관

    @Entity
    @Table(name = "product")
    public class Product {
        @EmbeddedId
        private ProductId id;
    
        @ElementCollection
        @CollectionTable(name = "product_category",
            joinColumns = @JoinColumn(name = "product_id"))
        private Set<CategoryId> categoryIds;
    
        ...
    }
  • ex. 카테고리 ID 목록을 보관하기 위해 밸류 타입에 대한 컬렉션 매핑을 이용

@Repository
public class JpaProductRepository implements ProductRepository {
	@PersistenceContext
    private EntityManager entityManagetr;
    
    @Override
    public List<Product> findByCategoryId(CategoryId catId, int page, int size) {
    	TypedQuery<Product> query = entityManager.createQuery(
        "select p from Product p " +
        // categoryIds 컬렉션에 catId로 지정한 값이 존재하는지를 검사하기 위한 검색 조건
        // 해당 조건을 이용해서 응용 서비스는 지정한 카테고리에 속한 Product 목록을 구할 수 있음
        "where :catId member of p.categoryIds order by p.id.id desc",
        Product.class
        );
    	query,setParameter("catId", catId);
        query.setFirstResult((page - 1) * size);
        query.setMaxResults(size);
        return query.getResultList();
    }
    ...	
}

4장에서 'JPA를 이용한 모델 매핑'과 '컬렉션을 사용할 때의 성능 관련 문제' 더 알아보기

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

  • 상점 계정이 차단 상태가 아닌 경우에만 상품을 생성할 수 있음

    public class RegisterProductService {
        public ProductId registerNewProduct(NewProductRequest req) {
            Store store = storeRepository.findById(req.getStoreId());
            checkNull(store);
    
            // product를 생성 가능한지 판단하는 코드
            if (store.isBlocked()) {
                throw new StoreBlockedException();
            }
    
            ProductId id = productRepository.nextId();
            // product를 생성하는 코드
            Product product = new Product(id, store,getId(), ... );
            productRepository.save(product);
            return id;
    
            // product 생성 가능 여부를 판별, product 생성하는 코드가 분리되어 있음
            // 도메인 로직 처리가 응용 서비스에 노출됨. 즉, 도메인에서 구현할 기능을 응용 서비스에서 구현하고 있는 것.
    
        }
        ...
    }
    
    
    // Product 생성 기능을 Store 애그리거트에 구현해보면,
    public class Store {
      // Proudct 애그리거트를 생성하는 팩토리 역할 && 도메인 로직 구현
      public Product createProduct(ProductId newProductId, ... ) {
        if (isBlocked()) throw new StoreBlockedException();
          return new Product(newProductId, getId(), ... )
        }
    }
    
    // 팩토리 기능을 구현했으므로, 응용서비스는 팩토리 기능을 이용하여 Product를 생성할 수 있음
    public class REegisterProudctService {
    	public ProductId registerNewProduct(NewProductRequest req) {
      	// 앞선 코드와 다르게, Store의 상태를 확인하지 않음. 해당 로직은 Store에서 구현했으므로!)
      	Store store = storeRepository.findById(req.getStoreId());
          checkNull(store);
          ProductId id = productRepository.nextId()
          Product product = store.createProduct(id, ... );
          productRepository.save(product);
          return id;
      }
      ...
    }
  • 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 할 때, 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보기.

0개의 댓글