애그리거트
애그리거트를 활용하면 복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들려면 상위 수준에서 모델을 조망할 수 있다.
수많은 객체를 애그리거트로 묶어서 바라보면 상위 수준에서 도메인 모델 간의 관계를 파악할 수 있다.
애그리거트는 모델을 이해하는데 도움을 줄 뿐만 아니라 일관성을 관리하는 기준도 된다.
에그리거트 단위로 일관성을 관리하기 때문에, 애그리거트는 복잡한 도메인을 단순한 구조로 만들어준다.
애그리거트는 관련된 모델을 하나로 모았기 때문에, 한 애그리거트에 속하는 객체는 유사하거나 동일한 라이프 사이클을 갖는다.
즉, 애그리거트에 속한 구성 요소 대부분은 함께 생성하고 함께 제거한다.
경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다.
도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다.
예를 들어 주문할 상품 개수, 배송자 정보, 주문자 정보는 주문 시점에 함께 생성되므로 이들은 한 애그리거트에 속한다.
흔히 'A가 B를 갖는다'로 설계할 수 있는 요구사항이 있다면 A와 B를 한 애그리거트로 묶어서 생각하기 쉽다.
예시를 들면 Order가 ShippingInfo와 Orderer을 가지므로 이는 어느정도 타당해 보인다.
하지만 'A가 B를 갖는다'로 해석할 수 있는 요구사항이 있다고 하더라도 이것이 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미하는 것은 아니다.
좋은 예가 상품과 리뷰이다.
상품 상세 정보와 함께 리뷰 내용을 보여줘야한다라는 요구사항이 있을 때, Product 엔티티와 Review 엔티티가
하나의 애그리거트에 속한다고 생각할 수 있다.
하지만 Product와 Review는 함께 생성되지 않고, 함께 변경되지도 않는다.
게다가 Product를 변경하는 주체가 상품 담당자라면, Review를 생성하고 변경하는 주체는 고객이다.
애그리거트 루트
애그리거트는 여러 객체로 구성되기 때문에 한 객체만 상태가 정상이면 안된다.
도메인 규칙을 지키려면 애그리거트에 속한 모든 객체가 정상 상태를 가져야 한다.
애그리거트에 속한 모든 객체가 일관성을 유지하려면 애그리거트 전체를 관리할 주체가 필요한데
해당 책임을 지는 것이 바로 애그리거트의 루트 엔티티다.
애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직접 또는 간접적으로 속하게 된다.
도메인 규칙과 일관성
애그리거트 루트는 단순히 애그리거트에 속한 객체를 포함하는 것으로 끝나는 것이 아니다.
애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.
이를 위해 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 구현한다.
즉, 에그리거트 루트는 기능을 구현한 메서드를 제공한다.
애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음의 두 가지를 적용해야 한다.
- 단순히 필드를 변경하는 set 메서드를 public 범위로 만들지 않는다.
- 밸류 타입은 불변으로 구현한다.
public set 메서드는 도메인의 의미나 의도를 표현하지 못하고 도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산시킨다.
도메인 로직이 한 곳에 응집되지 않으므로 코드를 유지보수할 때 분석하고 수정하는 데 더 많은 시간이 소모된다.
또한 밸류 타입은 불변 타입으로 구현한다.
밸류 객체의 값을 변경할 수 없다면 애그리거트 루트에서 밸류 객체를 구해도 애그리거트 외부에서 밸류 객체의 상태를 변경할 수 없다.
트랜잭션 범위
트랜잭션 범위는 작을수록 좋다.
예를 들어 한 개 테이블을 수정하면 트랜잭션 충돌을 막기 위해 잠그는 대상이 한 개 테이블의 한 행으로 한정되지만
세 개의 테이블을 수정하면 잠금 대상이 더 많아진다.
잠금 대상이 많아진다는 것은 그만큼 동시에 처리할 수 있는 트랜잭션 갯수가 줄어든다는 것을 의미하고
이것은 전체 성능을 떨어뜨린다.
동일하게 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다.
한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 더 높아지기 때문에
한 번에 수정하는 애그리거트 개수가 많아질수록 전체 처리량이 떨어지게 된다.
한 트랜잭션에서 한 애그리거트만 수정한다는 것은 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것이다.
public class Order {
private Orderer orderer;
public void shipTo(ShippingInfo newShippingInfo, boolean isNew) {
setShippingInfo(newShippingInfo);
if (isNew) {
orderer.getMember().changeAddress(newShippingInfo.getAddress());
}
}
}
현재 주문 애그리거트는 회원 애그리거트의 정보를 변경하고 있다.
이것은 애그리거트가 자신의 책임 범위를 넘어 다른 애그리거트의 상태까지 관리하는 꼴이 된다.
애그리거트는 최대한 서로 독립적이어야 하는데 한 애그리거트가 다른 애그리거트 기능에 의존하기 시작하면 결합도가 높아진다.
만약 부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면 애그리거트에서 다른 애그리거트를 직접 수정하지 말고 응용 서비스에서 두 애그리거트를 수정하도록 구현한다.
public class ChangeOrderService {
@Transactional
public void changeShippingInfo(ShippingInfo newShippingInfo, boolean isNew) {
Order order = OrderRepository.findById(id);
order.shipTo(newShippingInfo);
if (isNew) {
Member member = findMember(order.getOrderer());
member.changeAddress(newShippingInfo.getAddress());
}
}
}
레포지토리와 애그리거트
애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 레포지토리는 애그리거트 단위로 존재한다.
Order와 OrderLine을 물리적으로 각각 별도의 DB 테이블에 저장한다고 해서 Order와 OrderLine을 위한 레포지토리를 각각 만들지 않는다.
Order가 에그리거트 루트고 OrderLine은 에그리거트에 속하는 구성요소이므로 Order를 위한 레포지토리만 존재한다.
에그리거트를 영속화할 저장소로 무엇을 사용하든지 간에 애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 한다.
ID를 이용한 애그리거트 참조
한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다.
애그리거트 관리 주체는 애그리거트 루트이므로 애그리거트에서 다른 애그리거트를 참조한다는 것은 다른 애그리거트의 루트를 참조한다는 것과 같다.
애그리거트를 직접 참조할 때 발생할 수 있는 가장 큰 문제는 편리함을 오용할 수 있다는 것이다.
한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있게 된다.
한 애그리거트에서 다른 애그리거트의 상태를 변경하는 것은 애그리거트 간의 의존 결합도를 높여서 결과적으로 애그리거트의 변경을 어렵게 만든다.
이러한 문제를 완화할 때 사용할 수 있는 것이 ID를 이용해서 다른 애그리거트를 참조하는 것이다.
ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결된다.
ID를 이용한 참조와 조회 성능
다른 애그리거트를 ID로 참조하면 참조하는 여러 애그리거트를 읽을 때 조회 속도가 문제될 수 있다.
Member member = memberRepository.findById(orderId);
List<Order> orders = orderRepository.findByOrderer(orderId);
List<OrderView> dtos = orders.stream()
.map(order -> {
ProductId productId = order.getOrderLines().get(0).getProductId();
Product product = productRepository.findById(productId);
return new OrderView(order, member, product);
}).collect(toList());
해당 코드는 주문 갯수가 10개라면 주문을 읽어오기 위한 1번의 쿼리와 각 주문별로 각 상품을 읽어오기 위한 10번의 쿼리를 실행한다.
'조회 대상이 N개일 때 N개를 읽어오는 한 번의 쿼리와 연관된 데이터를 읽어오는 쿼리를 N번 실행한다' 해서 N+1 문제가 발생한다.
ID를 활용한 애그리거트 참조는 지연 로딩과 같은 효과를 만드는데, 지연 로딩과 관련된 대표적인 문제가 이런 N+1 문제이다.
해당 문제가 발생하지 않도록 하려면 조인을 사용해야 한다.
조인을 사용하는 가장 쉬운 방법은 ID 참조 방식을 객체 참조 방식으로 바꾸고 즉시 로딩을 사용하도록 매핑 설정을 바꾸는 것이다.
하지만 이런 방식은 결국엔 애그리거트 간 참조를 ID 참조 방식에서 다시 객체 참조 방식으로 되돌린 것 뿐이다.
ID 참조 방식을 사용하면서 N+1 조회와 같은 문제가 발생하지 않도록 하려면 조회 전용 쿼리를 사용하면 된다.
쿼리가 복잡하거나 SQL에 특화된 기능을 사용해야 한다면 조회를 위한 부분만 따로 QueryDSL이나 JPQL 등을 활용해 구현할 수 있다.
애그리거트 간 집합 연관
에그리거트 간 1-N 관계는 Set 같은 컬렉션을 이용해서 표현할 수 있다.
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);
}
}
해당 코드를 실제 DB와 연결해서 구현하면 category에 속한 모든 products를 조회하게 되므로 문제가 생긴다.
개념적으로 애그리거트 간에 1-N 연관이 있더라도 이런 성능 문제 때문에 애그리거트 간의 1-N 연관을 실제 구현에 반영하지 않는다.
카테고리에 속한 상품을 구할 필요가 있다면, 상품 입장에서 자신이 속한 카테고리를 N-1로 연관 지어 구하면 된다.
public class Product {
private CategoryId categoryId;
}
public class ProductListService {
public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
Category category = categoryRepository.findById(categoryId);
List<Product> products = productRepository.findByCategoryId(category.getId(), page, size);
int totalCount = productRepository.countsByCategoryId(category.getId());
return new Page(page, size, totalCount, products);
}
}
애그리거트를 팩토리로 사용하기
public class RegisterService {
public ProductId registerNewProduct(NewProductRequest req) {
Store store = storeRepository.findById(req.getStoreId());
// Product 생성 가능 판단 코드
if (store.isBlocked()) {
throw new StoreBlockedException();
}
// Product 생성 코드
ProductId id = productRepository.nextId();
Product product = new Product(...);
productRepository.save(product);
return id;
}
}
Store가 Product를 생성할 수 있는지 판단하고 Product를 생산하는 것은 논리적으로 하나의 도메인 기능인데
이 도메인 기능을 응용 서비스에서 구현하고 있다.
즉, 중요한 도메인 로직 처리가 응용 서비스에 노출되었다.
해당 기능을을 Store 에그리거트에서 구현해보자.
public class Store {
public Product createProduct(ProductId newProductId, ...) {
if(isBlocked()) {
throw new StoreBlockedException();
}
return new Product(newProductId, getId(), ...);
}
}
Store 애그리거트의 createProduct()는 Product 애그리거트를 생성하는 팩토리 역할을 한다.
팩토리 역할을 하면서도 중요한 도메인 로직을 구현하고 있다.
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store store =storeRepository.findById(req.getStoreId());
ProductId id = productRepository.nextId();
Product product = store.createProduct(id, ...);
productRepository.save(product);
return id;
}
}
Store가 Product를 생성할 수 있는지를 확인하는 도메인 로직은 Store에서 구현하고 있다.
이제 Product 생성 가능 여부를 확인하는 도메인 로직을 변경해도 도메인 영역의 store만 변경하면 되고 응용 서비스는 영향을 받지 않는다.
애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드를 구현하는 걸 고려해보자.
'책 정리 > 도메인 주도 개발 시작하기' 카테고리의 다른 글
Chapter 6. 응용 서비스와 표현 영역 (1) | 2023.12.12 |
---|---|
Chapter 5. 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.12.08 |
Chapter 4. 리포지터리와 모델 구현 (0) | 2023.12.05 |
Chapter 2. 아키텍처 개요 (0) | 2023.11.28 |
Chapter 1. 도메인 모델 시작하기 (0) | 2023.11.23 |