Heeveloper

[DDD Start]3장. 애그리거트


1. 애그리거트

복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들려면 상위 수준에서 모델을 조망할 수 있는 방법이 필요한데, 바로 애그리거트다.

  • 애그리거트에 속한 객체는 동일하거나 유사한 라이프사이클을 갖는다
  • 위 그림처럼 애그리거트는 경계를 갖으며 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다.
  • ‘A가 B를 갖는다’는 개념으로 A와 B가 한 애그리거트로 묶어서 볼 순 없다. ex) Product와 Review. 라이프사이클이 다르고 서로 영향 주지 않음.

2. 애그리거트 루트

애그리거트 전체를 관리하는 루트 엔티티를 갖음으로써 이를 통해 애그리거트에 속한 모든 객체가 일관된 상태를 유지한다.

도메인 규칙과 일관성

애그리거트 루트의 핵심 역할은 애그리거트가 제공할 도메인 기능을 구현하여 애그리거트의 일관성 이 깨지지 않게 하는 것이다.
애그리거트 루트가 아닌 다른 객체가 애그리거트에 속한 객체를 직접 변경하면 안된다.

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

애그리거트 루트는 내부 다른 객체를 조합해서 기능을 완성한다. 예를들어, Order는 총 주문 금액을 구하기 위해 OrderLine 목록을 사용한다. 또한 기능 실행을 하위 도메인 엔티티에 위임하기도 한다.

트랜잭션 범위

트랜잭션 범위는 작을수록 좋다. 한 트랜잭션이 한 개의 DB 테이블을 변경하는 것과 세 개의 테이블을 수정하는 것은 성능에서 차이가 발생한다.
일반적으로 한 트랜잭션에서 한 애그리거트만 수정하도록 해야하고, 이는 애그리거트에서 다른 애그리거트를 변경하지 않는 다는 것을 뜻한다.

public class Order {
  private Orderer orderer;
  
  public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddressAsMemberAddress) {
    verifyNotYetShipped();
    setShippingInfo(newShippingInfo);
    if (useNewShippingAddressAsMemberAddress) {
      // 다른 애그리거트 상태를 변경하면 안됨!
      orderer.getCustomer().changeAddress(newShippingInfo.getAddress());
    }
  }
}
  • 애그리거트 간 결합도가 높아지고 향후 수정 비용이 증가하는 문제를 야기한다.
  • 부득이하게 한 트랜잭션으로 두 개 이상 애그리거트를 수정해야하는 경우, 애그리거트에서 다른 애그리거트를 직접 수정하지 말고 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 한다.

예외 상황

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

도메인 이벤트 를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있다. (10장 참고)

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

애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로, 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다.
리포지터리에 애그리거트를 저장하면 애그리거트의 전체를 영속화해야 한다. 그렇지 않은 경우 기능 실행중 NullPointException 과 같은 문제가 발생할 수 있다.

실무에서 이를 완벽하게 적용하기는 어려운 것 같다.
예를 들어 사업자단위로 운영되는 통합서비스시스템이 있고, 하위엔 관리되는 여러 서비스가 연동돼 있는 경우, 개념상 하나의 애그리거트에 포함되나 그 동안 각 서비스가 서로 다른 조직에서 관리되고 있었을 경우 DB가 분리되어 있기 때문에, 애그리거트 저장 또는 호출시 각 팀에서 제공한 API를 통해 여러번 요청후 이들을 취합하여 온전한 애그리거트를 구할 때가 있다.

이는 각 서비스들 자체가 처음부터 통합서비스를 위해 설계된 것이 아니었기 때문에 발생하는 한계인 것 같다.

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

애그리거트의 필드(Order -> Orderer -> Member)를 이용하여 애그리거트간 참조를 쉽게 할 수 있지만 다음의 문제를 야기할 수 있다.

  • 편한 탐색 오용 : 구현 편리함으로 한 애그리거트에서 다른 애그리거트를 수정하기 쉬워져 애그리거트간 의존 결합도를 높이고, 결과적으론 애그리거트의 변경을 어렵게 만든다.
  • 성능에 대한 고민해야 함 : JPA의 경우 일반적으로 상태를 변경만 하는 경우 지연(lazy) 로딩을, 화면에 바로 보여줘야 하는 경우엔 즉시(eager) 로딩을 사용한다. 다양한 수를 고려해 연관 매핑과 JPQL/Criteria 쿼리의 로딩 전략을 결정해야 한다.
  • 확장 어려움 : 사용자가 늘고 트래픽이 증가하면서 하위 도메인별로 시스템을 분리할 때 보통 하위 도메인마다 서로 다른 DBMS를 사용할 가능성이 높은데, 더 이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없다.

이러한 세 가지 문제는 ID를 이용한 참조를 통해 완화할 수 있다.
ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조되는 것이다. 이는 애그리거트 경계를 명확히 하고 물리적 연결을 제거해 모델의 복잡도를 낮춰주어 애그리거트의 응집도를 높여줄 수 있다.
또한 애그리거트별 다른 구현 기술을 사용하는 것도 가능해진다.

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

ID참조로 여러 애그리거트를 읽어야 할 때, 그 횟수만큼 쿼리를 실행해야 한다.
(N+1 조회 문제 : 한 사람이 주문했던 상품들을 조회하려면, 주문 조회 1번과 각 주문별 상품 조회 N번이 필요하다.)

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

세타조인
R과 S relation을 조인하되, R의 속성 A와 S의 속성 B가 세타관계가 성립되고 새로운 Table Relation을 만든다.
(세타 : =, < 등)

@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 ol.productId = p.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();
  }
}

애그리거트마다 서로 다른 저장소를 사용하는 경우 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다.
이런 경우 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다. 코드가 복잡해지는 단점이 있지만, 시스템 처리량을 높일 수 있다. 특히 한 대의 DB 장비로 대응할 수 없는 수준의 트래픽이 발생하는 경우, 캐시나 조회 전용 저장소는 필수다. (조회전용저장소는 5장 참고)

5. 애그리거트 간 집합 연관

애그리거트 간에는 1:N과 N:M 연관이 존재하는데, 이 두 연관은 컬렉션을 이용한 연관이다.

1:N

// 1:N 연관
public class Category {
  private Set<Product> products;
  
  
  public List<Product> getProduct(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 Prodcut {
  ...
  private CategoryId category;
  ...
}

public class ProductListService {
  public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
    ...
    List<Product> products = productRepository.findByCategoryId(category.getId(), page, size);
    ...
  }
}

M:N

M:N 연관은 양쪽 애그리거트에 컬렉션으로 연관을 만든다. 예를 들어 상품이 여러 카테고리에 속할 수 있는 경우인데, 실제로 목록 화면에서 각 상품이 속한 모든 카테고리를 상품 정보에 표시하지는 않는다. 제품에 속한 카테고리가 필요한 화면은 상품 상세 화면이다.

이러한 요구사항을 고려할 때 카테고리에서 상품으로의 집합 연관은 필요하지 않고, 상품에서 카테고리의 집합 연관만 존재하면 된다. 즉 개념적으로는 상품과 카테고리의 양방향 M:N 연관이 존재하지만 실제 구현에서는 상품에서 카테고리의 단방향 M:N 연관만 적용하면 되는 것이다.

public class Product {
  private Set<CategoryId> categoryIds;
  ...
}

RDBMS 를 이용해 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;
  ...
}

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

온라인 쇼핑몰에서 고객이 여러 차례 신고를 해서 특정 상점이 더 이상 물건을 등록하지 못하도록 차단한 상태라고 해보자.

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;
	}
}

코드가 나빠 보이지는 않지만 중요한 도메인 로직 처리가 응용 서비스에 노출되었다.
Store가 Product를 생성할 수 있는지 여부를 판단하고 Product를 생성하는 것은 논리적으로 도메인의 기능인데 이를 응용서비스에서 구현하고 있는 것이다. 이러한 Product를 생성하는 기능을 Store 애그리거트에 위임할 수 있다.

public class Store extends Member {
	public Product createProduct(ProductId id, ... ) {
		if (!account.isBlocked()) {
			throw new StoreBlockedException();
		}
		return new Product(id, account.getId(), ...);
	}
}

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;
	}
}

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



Similar Posts

Comments

-->