73p 그림3.3을 보면 복잡했던 그림3.2를 애그리거트로 묶음으로 인하여 모델 간의 관계를 개별 모델 수준과 함께 상위 수준에서도 이해할 수 있게 된다.
애그리거트의 객체들은 유사하거나 동일한 라이프사이클을 가지게 된다.
경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다.
애그리거트를 대표하는 엔티티 객체를 루트 엔티티
라고 한다.
애그리거트 루트
이다.캡슐화
할 수 있도록 돕는다.// 주문 상태, 규칙을 무시하고 주소를 바꾸고 있다.
ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress);
이는 도메인 규칙을 무시하고 DB테이블에서 직접 데이터를 수정하는 것과 같다. 즉, 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 꺠는 원인이 된다. 그렇다고 이를 막는 상태확인 로직(배송상태 확인)을 응용 서비스에 구현하게 되면, 해당 로직이 여러 응용 서비스에서 중복해서 구현할 가능성이 높아진다.
따라서, 가급적이면 응집성 있게 도메인 클래스에서 구현을 담당하도록 해야 한다. 이를 지키기 위해서는 setter 메서드 사용 지양
그리고 벨류 타입에 대한 불변 클래스 적극적 활용
하는 방법을 습관화 시켜야 한다.
setter 메서드를 외부에서 접근하게 되면, 엔티티의 상태 변화 제어가 어려워진다. 다시 말해, 상태 변경에 대하여 왜/어떻게 변했는지에 대한 추적이 어려워진다. 실제 초기 단계에서는 도메인에 대한 구현이나 요구사항이 복잡하지 않아 생기는 문제가 적겠지만, 추후 도메인과 비즈니스가 확장됨에 따라 변경이 발생하고 코드 베이스가 길어지면 관리하기 힘들어 지는 것이 자명한 사실이다. 코드 베이스가 길어지면 어쩔 수 없는 일이라고 생각하는 사람도 있겠지만 길어지더라도 변경에 대한 이유와 근거가 명확한 것이 추후 유지보수성이나 확장에 유리하다고 생각한다. e.g. changeShippingInfo()가 배송지 정보를 새로 변경한다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 뜻한다. completePayment() <-> setOrderState() 이러한 차원에서 가급적이면 setter 사용을 지양하고 도메인 클래스 혹은 도메인 서비스에서 상태 변경을 제어하도록 하여 일관성 있는 구현을 유지하는 것이 중요하다고 할 수 있다.
public class Order{
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo){
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
private void setShippingInfo(ShippingInfo newShippingInfo){
this.shippingInfo = newShippingInfo;
}
}
위의 코드와 같이 밸류 타입의 내부 상태를 변경하려면
새로운 밸류 객체를 할당
하는 것뿐이다. 즉, 애그리거트 루트를 통해서만 가능한다. 그러므로, 애그리거트 루트가 도메인 규칙을 올바르게만 구현하면 애그리거트 전체의 일관성을 올바르게 유지할 수 있다.
애그리거트 루트는 다른 객체들을 조합해서 기능을 완성한다.
Order는 총 주문 금액을 구하기 위해 OrderLine 목록을 사용한다.
public class Order{
private Money totalAmounts;
private List<OrderLine> orderLines;
private void calculateTotalAmounts(){
int sum = orderLines.stream()
.mapToInt(o1 -> o1.getPrice() * o1.quantity())
.sum();
this.totaAmounts = new Money(sum);
}
}
class Order {
private OrderLines orderLines;
private Long totalAmounts;
public void changeOrderLines(List<OrderLine> newOrderLines) {
orderLines.changeOrderLines(newOrderLines); // delegate
this.totalAmounts = orderLines.getTotalAmounts();
}
}
class OrderLines {
private List<OrderLine> orderLines;
public Money getTotalAmounts() { ......; }
public void changeOrderLines(List<OrderLine> newOrderLines) {
this.orderLines = newOrderLines;
}
}
트랜잭션의 범위는 작을수록 좋다. 따라서 가급적이면 애그리거트 단위로 트랜잭션을 제한하는 것이 좋다.
한 트랜잭션에서 두 개 이상의 애그리거트를 수정하게 된다면?
트랜잭션 충돌이 발생할 가능성이 높아 진다. 결국, 전체 처리량이 떨어지게 된다. 애그리거트간의 의존이 생기기 시작하면 결국 결합도가 높아지게 되고 이는 곧 수정 비용이 증가하된다. 따라서 가급적이면 트랜잭션의 범위는 하나의 애그리거트로 제한하는 것이 좋다.
public class ChangeOrderService{
@Transactional
public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo,
boolean useNewShippingAddr){
.............
}
}
도메인 이벤트
를 구현하는 방법을 고려해보는 것이 좋다. 도메인 이벤트 구현을 사용하면 애그리거트간의 트랜잭션을 분리할수도 있으며 또한 구현 자체를 나눠서 할 수 있기 떄문에 코드에 대한 복잡성 역시 낮아지게 된다.애거리거트를 영속화하고 애그리거트를 사용하려면 저장소에서 애그리거트를 읽어야 하므로 리포지터리는 적어도 다음의 두 메서드를 제공해야한다.
// 리포지터리에 애그리거트를 저장하면 애그리거트 전체를 영속화해야 한다.
orderRepository.save(order);
// 리포지터리는 완전한 order를 제공해야 한다.
Order order = orderRepository.findById(orderId);
한 객체가 다른 객체를 참조하듯 애그리거트도 다른 애그리거트를 참조한다.
ORM 기술 덕에 다른 애그리거트 루트에 대한 참조를 쉽게 구하고, 필드를 이용해 쉽게 접근 및 사용하면 편리하겠지만 다음과 같은 문제점이 있다
한 애그리거트 루트에서 타 애그리거트 루트를 접근 할 수 있으면, 상태 또한 쉽게 변경 가능
하지만 서비스가 커져 도메인별로 시스템을 분리하는 등 확장하게 될 경우, 도메인 별로 별도 DBMS를 사용할 가능성이 높다.
다른 애그리거트를 직접 참조하지 않기 때문에 로딩 방식 (LAZY, EAGER)등을 고민할 필요가 없어진다.
JPA를 사용한다해서 반드시 객체 참조를 통해 모든것을 처리하지 않아도 된다.
애그리거트마다 서로 다른 저장소를 사용하는 경우엔, 한 쿼리로 조회하는 것은 불가능하다.
public class Category {
private Set<Product> products; // 1:N 객체 직접 참조
}
위처럼 구현한 후 Category 조회 -> Product 조회 순으로 작업하게 되면 테이블의 모든 Product를 조회하게됨
public class Product {
private CategoryId categoryId; // N:1 ID를 통한 참조
}
public class ProductListService {
public List<Product> getProductOfCategory(CategoryId id) {
Category category = categoryRepository.findById(id);
checkCategory(category);
List<Product> products = productRepository.findByCategoryId(category.getId());
return products;
}
}
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store account = accountRepository.findStoreById(req.getStoreId());
if (account.isBlock()) throw new StoreBlockedException();
Product product = new Product(...);
productRepository.save(product);
return product.getId();
}
}
public class Store {
// 도메인 핵심 기능을 구현하는 팩토리 메소드
public Product createProduct(...) {
if (isBlocked()) throw new StoreBlockedException();
return new Product(...);
}
}
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store account = accountRepository.findStoreById(req.getStoreId());
Product product = account.createProduct(...);
productRepository.save(product);
return product.getId();
}
}