DDD 도메인 주도 설계 3장

DDD - 도메인 주도 설계

DDD

앞으로 적어갈 글은 최범균님의 저서 DDD START!를 읽고 요약 - 정리한 글입니다.

제 3장 : 애그리거트

제 3장 애그리거트

애그리거트

3-1

  • 위 그림은 개별 객체 수준에서 주문 시스템을 표현한 그림이다.

  • 확실히 객체 수준에서 모델을 바라보면 이해하기가 어렵다.

  • 이번 장에서 배울 애그리거트는 관련 객체를 하나의 으로 만들어준다.

3-2

  • 위의 그림을 애그리거트로 묶었을 때의 모습

  • 확실히 이해하는데 훨씬 쉽고 어려움이 없다.

  • 함께 변경되는 빈도가 높은 객체는 한 애그리거트에 속할 가능성이 높다.

애그리거트의 흔한 오해들

3-3

  • 그렇다고 해서 A가 B를 갖는다는 의미가 A와 B가 하나의 애그리거트에 속함을 의미하지는 않는다.

    • e.q. 상품 판매 글과 리뷰는 함께 존재하지만 둘은 하나의 애그리거틀로 보지 않는다.
    • 왜냐하면 상품 판매 글이 바뀐다고 리뷰가 바뀌지는 않으니까!
  • 반드시 도메인 2개 이상일 때 하나의 애그리거트가 되는 것은 아니다.

  • 일반적으로 다수의 애그리거트가 한 개의 엔터티 객체를 갖는 경우가 많으며, 두개 이상의 애그리거트는 드물게 존재한다.

애그리거트 루트

3-4

  • 애그리거트는 여러 객체로 구성되기 때문에 반드시 모든 객체들의 상태가 정상이어야 한다.

  • 애그리거트에 속한 모든 객체가 일관된 상태를 유지하기 위해서는 애그리거트 전체를 관리하는 루트가 필요하다.

도메인 규칙과 일관성

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

  • 이를 위해 모든 변경에 관한 인터페이스는 애그리거트가 제공한다.

  • 외부의 객체들은 애그리거트 루트와 협력하여 애그리거트의 상태를 변경할 수 있게 된다.

ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress);
  • 만약 위와 같이 애그리거트 루트 외부에서 애그리거트의 모델을 변경하려고 하는 행위는 DB 테이블에서 데이터를 직접 수정하는 것 만큼이나 위험한 작업이 된다.

  • 그래서 가급적이면 setter를 사용하지 않는 것이 좋다.

  • 그리고 밸류 타입은 가급적 불변 상태를 유지하게 한다.

트랜잭션 범위

  • 트랜잭션의 범위는 작으면 작을 수록 좋다.

  • 트랜잭션 충돌을 막기 위해 잠그는 대상이 많으면 많을 수록 그만큼 성능은 떨어지게 된다.

    • 많이 잠그면 잠글 수록 동시에 처리할 수 있는 트랜잭션 개수가 줄어들기 때문.
  • 마찬가지로 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 함

  • 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 더 높아진다.

  • 만약 한 트랜잭션에서 두 개 이상의 애그리거트를 수정해야한다면 아래와 같이 응용 서비스에서 두 애그리거트를 수정하도록 하자.

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) {
   order.getOrderer()
    .getCustomer().changeAddress(newShippingInfo.getAddress());
  }
 }
 ...
}
  • 도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경할 수 있는데 이는 10장에서 등장한다.

  • 기본적으로 한 트랜잭션에서는 하나의 애그리거트를 수정하는 것을 권장하지만, 다음의 경우에는 두 개 이상의 애그리거트를 변경하는 것을 고려해볼 수 있다.

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

리포티저리와 애그리거트

  • 애그리거트는 개념상 완전한 한 개의 도메인을 표현한다.

  • 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다.

  • Oder와 OderLine을 물리적으로 각각 별도의 DB 테이블에 저장한다고 해서 각각 별도의 리포지터리를 만들지 않는다.

  • Order가 루트이고 OrderLine인 애그리거트에 속하는 구성요소이므로 Order를 위한 애그리거트만 존재한다.

  • RDBMS든 NoSQL이든, save를 하는 것은 애그리거트를 영속화한다는 뜻이다.

  • 여러 객체가 하나의 애그리거트로 묶인다면 이 객체들은 애그리거트가 영속화 될 때 전부 영속화 되어야 한다.

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

  • 한 객체가 다른 객체를 참조하듯이, 애그리거트도 다른 애그리거트를 참조할 수 있다.

  • 애그리거트의 관리 주체가 루트이므로 애그리거트에서 다른 애그리거트를 참조한다는 것은 루트를 참조한다는 의미이기도 하다.

  • 애그리거트간의 참조는 필드를 통해 간단히 구현할 수 있다.

  • JPA를 사용한다면 @ManyToOne @OneToOne과 같은 애노테이션을 이용해 다른 애그리거트를 참조할 수 있다.

  • 하지만 필드를 이용한 애그리거트 참조는 다음의 문제를 야기할 수 있다.

    • 편한 탐색 오용
    • 성능에 대한 고민
    • 확장 어려움
public class Order {
 private Orderer orderer;

 public void changeShippingInfo( ... ) {
  ...
  // Member의 Address를 변경한다.
  orderer.getCusotmer().changeAddress(newShippingInfo.getAddress());
 }
}
  • 위와 같이 한 애그리거트에 다른 애그리거트를 참조한다는 것은 애그리거트간의 의존도를 높여서 결과적으로 애그리거트의 변경을 여럽게 만든다.

  • 두 번째 문제는 애그리거트를 직접 참조하는 것은 성능 문제를 야기한다. JPA의 지연로딩과 즉시로딩을 사용하여 이를 해결할 수 있기는 하다. 상황에 따라 적절한 쿼리의 로딩 전략을 결정해야한다.

  • 세 번째 문제는 확장이다. 단일 DBMS를 사용하다가 확장을 하려고 한 도메인은 MongoDB를 한 DB는 마리아 DB를 사용한다면 JPA와 같은 단일 기술을 사용할 수 없게 된다.

  • 이 문제를 해결하기 위해 ID를 이용해 다른 애그리거트를 참조하는 방법이 있다.

public class Order {
 private Orderer orderer;

 public void changeShippingInfo( ... ) {
  ...
  // Member의 Address를 변경한다.
  Customer customer = customerRepository.findById(order.getOrderer().getCustomerid());
  customer.changeAddress(newShippingInfo.getAddress());
 }
}
  • ID를 이용한 참조는 DB 테이블에서 외래키를 사용해 참조하는 것과 유사하게 다른 애그리거트를 참조할 때 ID 참조를 사용한다는 점이다.

  • 물론 애그리거트 내에서의 엔티티를 참조할 때는 객체 레퍼런스로 참조한다.

3-45

  • ID로 참조하게 되면 리포지터리 마다 다른 저장소를 사용하여 구현하더라도 문제가 없고 확장에 용이해진다.

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

  • 주문이 N개 있고, 각 주문 마다 상품이 하나씩 있다고 하면 전체 주문을 조회하는 쿼리 1개와 전체 주문에서 각 상품 정보를 조회하는 쿼리가 N번 날아가는 문제가 발생한다.

  • 이를 우리는 흔히 N+1 문제라고 부른다.

  • N+1 조회는 전체 조회 속도가 느려지는 원인이 된다.

  • 이 책에서는 JPQL과 세타 조인을 사용해 한 번의 쿼리로 로딩하고 있다.

애그리거트 간 집합 연관

  • 애그리거트 간 1:N과 M: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를 조회한다.

  • 이는 상품 개수가 많아지면 많아질 수록 성능 저하가 심해질 것이다.

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);
 }
}
  • 이는 N:1로 연관지어 해결할 수 있다.

3-5

  • 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;
  • 또한 하나의 category Id를 키로 갖고, 컬렉션 리스트를 밸류로 갖는 컬렉션 매핑을 사용할 수도 있다.

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

  • 온라인 쇼핑몰에서 고객이 여러 차례 신고를 해서 특정 상점이 더 이상 물건을 등록하지 못하게 차단한 상태라고 해보자.
public class RegisterProductService {
 public ProductId registerNewProduct(NewProductRequest req) {
  Store account = accountRepository.findStoreById(req.getStoreId());
  checkNull(account);
  if (!account.isBlocked()) {
   throw new StoreBlockedException();
  }
  ProductId id = productRepository.nextId();
  Product product = new Product(id, account.getId(), ...);
  productRepository.save(product);
  return id;
 }
}
  • 나쁜 코드는 아니지만, 중간에 account를 검증하고 Product를 생성하는 로직이 노출되었다.

  • Product를 생성하는 것은 하나의 도메인 기능인데 이를 응용 서비스에서 구현하고 있다.

public class Store extends Member {
 public Product createProduct(ProductId id, ... ) {
  if (!account.isBlocked()) {
   throw new StoreBlockedException();
  }
  return new Product(id, account.getId(), ...);
 }
}
  • Product를 생성하는 기능을 Store 애그리거트에 옮겨보자

  • Store 애그리거트의 createProduct()는 Product 애그리거트를 생성하는 팩토리 역할을 한다.

public class RegisterProductService {
 public ProductId registerNewProduct(NewProductRequest req) {
  Store account = accountRepository.findStoreById(req.getStoreId());
  checkNull(account);
  ProductId id = productRepository.nextId();
  Product product = account.createProduct(id, account.getId(), ...); // Store에서 직접 생성
  productRepository.save(product);
  return id;
 }
}
  • 이제 product 생성 가능 여부를 확인하는 로직을 변경하더라도 도메인 영역의 Store만 변경하면 된다.

  • 이것이 바로 애그리거트를 팩토리로 사용할 때 얻을 수 있는 이점이다.

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

  • Store에 Product를 생성하는 팩토리 메서드를 추가하면 Product를 생성할 때 필요한 데이터의 일부를 직접 제공하면서 동시에 중요한 도메인 로직을 함께 구현할 수 있다.

Reference

DDD START! - 최범균님 -

그림 및 코드 참조 : https://incheol-jung.gitbook.io/docs/study/ddd-start/1



© 2022. by minkuk

Powered by minkuk