DDD 도메인 주도 설계 3장
in Development on DDD
DDD - 도메인 주도 설계
앞으로 적어갈 글은 최범균님의 저서 DDD START!를 읽고 요약 - 정리한 글입니다.
제 3장 : 애그리거트
제 3장 애그리거트
애그리거트
위 그림은 개별 객체 수준에서 주문 시스템을 표현한 그림이다.
확실히 객체 수준에서 모델을 바라보면 이해하기가 어렵다.
이번 장에서 배울 애그리거트는 관련 객체를 하나의 군으로 만들어준다.
위의 그림을 애그리거트로 묶었을 때의 모습
확실히 이해하는데 훨씬 쉽고 어려움이 없다.
함께 변경되는 빈도가 높은 객체는 한 애그리거트에 속할 가능성이 높다.
애그리거트의 흔한 오해들
그렇다고 해서 A가 B를 갖는다는 의미가 A와 B가 하나의 애그리거트에 속함을 의미하지는 않는다.
- e.q. 상품 판매 글과 리뷰는 함께 존재하지만 둘은 하나의 애그리거틀로 보지 않는다.
- 왜냐하면 상품 판매 글이 바뀐다고 리뷰가 바뀌지는 않으니까!
반드시 도메인 2개 이상일 때 하나의 애그리거트가 되는 것은 아니다.
일반적으로 다수의 애그리거트가 한 개의 엔터티 객체를 갖는 경우가 많으며, 두개 이상의 애그리거트는 드물게 존재한다.
애그리거트 루트
애그리거트는 여러 객체로 구성되기 때문에 반드시 모든 객체들의 상태가 정상이어야 한다.
애그리거트에 속한 모든 객체가 일관된 상태를 유지하기 위해서는 애그리거트 전체를 관리하는 루트가 필요하다.
도메인 규칙과 일관성
애그리거트 루트의 핵심은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.
이를 위해 모든 변경에 관한 인터페이스는 애그리거트가 제공한다.
외부의 객체들은 애그리거트 루트와 협력하여 애그리거트의 상태를 변경할 수 있게 된다.
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 참조를 사용한다는 점이다.
물론 애그리거트 내에서의 엔티티를 참조할 때는 객체 레퍼런스로 참조한다.
- 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로 연관지어 해결할 수 있다.
- 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