[DDD] Chap 03 Aggregate

2021. 8. 15. 21:58DDD

이번 장에선 애그리거트 개요, 애그리거트 설계 원칙, 애그리거트간 참조 방식에 대해서 정리해보겠습니다.

 

애그리거트란?

애그리거트는 특정 도메인에 관련된 엔티티와 밸류 객체들의 묶음입니다.

프로그래밍 세계에선 항상 극단적으로 생각해볼 필요가 있습니다.

특정 시스템에 구성된 객체가 10억 개가 있다면 개발자가 미로같이 얽힌 시스템을 이해하기 매우 어려울겁니다.

따라서 애그리거트 단위로 도메인을 관리하면 객체 수준이 아닌 상위 수준에서 시스템을 바라볼 수 있어서 개발자가 시스템을 이해하기 쉽게 됩니다.

 

애그리거트 설계 원칙

  1. 한 애그리거트에 속한 객체는 동일한 라이프 사이클를 가짐
  2. 애그리거트 루트를 통한 일관성 보장
  3. ID를 통해 애그리거트간 참조

 

애그리거트는 동일한 라이프 사이클을 가진 객체들끼리 묶는 것을 원칙으로 합니다.

온라인 쇼핑몰을 예를 들면, 상품 주문 Aggregate, 결제 Aggregate, 상품 Aggregate, 회원 Aggregate, 리뷰 Aggregate 등으로 나눌 수 있습니다.

각각의 Aggregate는 생성되고 종료되는 시점(라이프사이클)을 고려하여 분류되었습니다.

주의할 점은 단순히 'A가 B를 갖는다'라고 해서 A와 B를 한 애그리거트로 묶을 수 없다는 것입니다.

상품은 리뷰 정보를 갖지만 상품 등록 주체는 상품 담당자이고 리뷰 등록 주체는 소비자이며 둘의 라이프 사이클 또한 다르기 때문에 다른 애그리거트에 속합니다.

도메인 모델에 능숙해질수록 한 애그리거트를 하나의 엔티티로 구성하게 됩니다.

 

애그리거트 루트

애그리거트 루트는 루트 엔티티라고도 부르며, 애그리거트 전체를 관리하는 주체입니다.

애그리거트에 속한 객체들은 루트 엔티티에 직/간접적으로 속합니다.

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

외부의 접근을 막고 애그리거트 루트를 통해서만 객체의 상태를 변경하며, 핵심 도메인 로직은 애그리거트 루트에 위치합니다.

 

아래의 코드는 주문 애그리거트의 루트 엔티티 Order를 정의한 코드입니다.

public class Order{
    private String orderId;
    private ShippingInfo shippingInfo;  //밸류 타입(immutable 객체)

    public void changeShippingInfo(ShippingInfo newShippingInfo){
    	verifyNotYetShipped();
    	setShippingInfo(newShippingInfo);
    }

    private void setShippingInfo(ShippingInfo newShippingInfo){  //setter는 private으로 제한
    	this.shippingInfo = newShippingInfo;
    }
}

 

애그리거트 루트를 둠으로써 애그리거트의 일관성을 보장할 수 있지만, 애그리거트 루트에게 너무 많은 책임이 주어질 수 있다는 단점도 존재합니다. 

아래의 코드는 애그리거트 루트에 너무 많은 책임이 몰려서 코드의 가독성이 낮아진 상태입니다.

public class Order{  //Root Entity
    private Money totalAmounts;  //주문 총액
    private List<OrderLine> orderLines;  //주문 목록
	
    pivate void calculateTotalAmounts(){  //총액 계산
    	int sum = orderLines.stream()
                            .mapToInt(ol -> ol.getPrice() * ol.quantity())
                            .sum();
    	this.totalAmounts = new Money(sum);
    }
}

 

따라서 아래의 코드처럼 외부의 접근을 막는 선에서 다른 엔티티에도 기능을 위임할 수 있습니다.

public class OrderLines{  //불변 객체
    private List<OrderLine> lines;
    
    public OrderLines(List<OrderLines> lines){
    	this.lines = lines
    }

    //외부로부터의 접근을 막기위해 protected로 제한
    
    protected Money getTotalAmounts(){...}

    protected void changeOrderLines(List<OrderLine> newLines){
    	this.lines = newLines;
    }
}

//OrderLines 타입 필드에 애그리거트 루트의 기능을 위임
public class Order{
    private OrderLines orderLines;
	
    public void changeOrderLines(List<OrderLine> newLines){
    	orderLines.changeOrderLines(newLines);
    	this.totalAmounts = orderLines.getTotalAmounts();
    }
}

 

 


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

지금까지 객체간 참조는 클래스안에 참조할 클래스를 포함 관계(Composite)로 필드에 추가하여 매핑했습니다.

JPA를 사용하면 @ManyToOne, @OneToOne 등의 어노테이션으로 쉽게 객체간 연관관계를 매핑할 수 있습니다.

하지만 애그리거트간 매핑은 어떨까요?

애그리거트간 연관 관계 매핑을 포함 관계로 구현하면 아래의 문제를 야기합니다.

  1. 편한 탐색 오용
  2. 성능에 대한 고민
  3. 확장의 어려움

 

1. 편한 탐색 오용

필드를 통한 애그리거트 참조는 간편하지만 애그리거트 안에 다른 애그리거트를 포함시키기 때문에 의존 결합도가 증가하고 이는 유지보수성과 확장성을 낮춥니다.

 

2. 성능에 대한 고민

연관된 객체를 한번에 조회하려면 조인을 통한 즉시 로딩(Eager)이 유리합니다.

하지만 애그리거트에서 다른 애그리거트의 상태를 변경시엔 즉시 로딩을 사용하면, 불필요한 객체를 함께 로딩해야해서 지연 로딩(Lazy)이 유리합니다.

매번 개발자는 성능을 고려하여 즉시/지연 로딩을 사용할지 고민해야 합니다.

 

3. 확장의 어려움

서비스가 커짐에 따라 부하 분산을 위해 하위 도메인마다 서로 다른 저장소나 DBMS를 사용할 가능성이 높아집니다.

시스템 확장이 일어날 때마다 강한 결합으로 연결된 애그리거트를 전부 수정해야한다면 개발자의 수고가 늘어날 겁니다.

 

이러한 문제를 해결할 수 있는 것이 ID를 통한 애그리거트 참조입니다.

 

아래의 코드는 '주문' Aggregate와 '회원' Aggregate를 ID 참조를 통해 매핑한 것입니다.

//주문 애그리거트
public class Order{
    private Orderer orderer;
}

public class Orderer{
    private MemberId memberId;  //ID를 통한 애그리거트 참조
    private String name;
}
//회원 애그리거트
public class Member{
    private MemberId id;
}

애그리거트 내부에 속한 객체끼리는 포함 관계를 이용하여 매핑하고,

애그리거트끼리 참조할 상황에는 포함 관계가 아닌 애그리거트 루트의 ID를 참조하여 애그리거트간 결합도를 줄이고 도메인 로직을 한 곳에 모아 응집도를 높여줍니다.(1번 문제 해결)

즉시/지연 로딩을 고려할 필요없이 Application 계층에서 ID 참조로 애그리거트를 호출하여 애그리거트 단위로 지연 로딩합니다.(2번 문제 해결)

또한 애그리거트간 결합도를 줄이고 확장성을 높였기 때문에 애그리거트별로 다른 구현 기술을 사용 가능합니다.(3번 문제 해결

 

N+1 조회 문제

ID를 이용한 참조는 N+1 문제를 야기할 수 있습니다.

조회 대상이 N개일 때 N개의 데이터를 한번에 읽어오는 쿼리 한 개와 연관된 데이터를 읽어오는 쿼리를 N번 실행합니다.

이는 조인을 사용하는 방식보다 더 많은 쿼리를 발생시켜 전체 조회 성능이 낮아집니다.

조인을 사용하는 즉시 로딩으로 바꾸자니 애그리거트 설계 원칙에 위배됍니다.

 

그렇다면 ID 참조 방식을 사용하면서 N+1 조회 문제를 해결하려면 어떻게 할까요?

첫 번째 방법은 JPQL과 같은 전용 조회 쿼리를 사용하는 것입니다.

두 번째 방법은 Cache로 조회 성능을 높이거나 조회 전용 저장소를 따로 구성하는 것입니다.

JPQL은 5장에서, CQRS는 11장에서 더 자세히 다루도록 하겠습니다.

 


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

해당 내용은 디자인 패턴중에서 팩토리 패턴의 이해가 필요합니다.

팩토리 패턴은 인스턴스를 생성하는 로직 따로 분리시켜 클라이언트 노출을 막고, 이를 추상화한 팩토리 클래스의 공통 메서드를 호출하는 것으로 유지보수성과 확장성을 높인 패턴입니다.

 

자세한 내용은 아래의 글을 참고하세요.

https://www.tutorialspoint.com/design_pattern/factory_pattern.htm

 

Design Pattern - Factory Pattern

Design Pattern - Factory Pattern Factory pattern is one of the most used design patterns in Java. This type of design pattern comes under creational pattern as this pattern provides one of the best ways to create an object. In Factory pattern, we create ob

www.tutorialspoint.com

 

아래의 코드는 Application 계층에서 상품 등록 Service를 구현한 것입니다.

Service단에서 상점 애그리거트의 정보를 통해 상품 애그리거트를 생성하고 있습니다.

public class RegisterProductService {
    public ProductId registerNewProduct(NewProductRequest req){
        Store account = accountRepository.findStoreById(req.getStoreId());
        checkNull(account);
        if(account.isBlocked()) {  //계정이 block돼있나요?
            throw new StoreBlockedException();
        }

        ProductId id = productRepository.nextId();
        Product product = new Product(id, account.getId(),...);
        productRePository.save(product);
        return id;
    }
}

상점 계정이 Block되있는지 확인하는 로직까지 구현해서 코드가 다소 복잡해보이고 유지보수성이 낮아보입니다.

 

아래의 코드는 Store 애그리거트 내부에 팩토리 메서드를 정의해서 코드를 개선했습니다.

public class Store extends Member{
    public Product createProduct(ProductId newProductId,...){  //Factory Method
        if(isBlocked()) {
            throw new StoreBlockedException();
        }

        return new Product(newProductId, getId()....);
    }
}
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, ...);  //인스턴스 생성 코드 분리
        productRepository.save(product);
        return id;
    }
}

store가 block돼있는지 확인하는 로직을 Store 애그리거트안에 넣음으로써 요구사항이 변경돼도 Store만 변경하면 되고 Application 계층에는 영향을 주지 않습니다.(의존성 낮추고 응집도 향상)

따라서 A 애그리거트의 정보를 통해 B 애그리거트를 생성해야한다면,

A 안에 팩토리 메서드를 추가해서 애그리거트 설계 원칙을 해치지 않고 도메인 로직 구현하는 것을 고려해야 합니다.

 

정리

- 애그리거트: 상위 수준에서 시스템 전체를 잘 이해하기 위한 엔티티와 밸류 객체의 묶음

- 애그리거트 설계 원칙:

  1. 라이프 사이클이 비슷한 엔티티 객체끼리 하나로 묶는다.
  2. 애그리거트 루트를 둬서 애그리거트 일관성을 보장하자
  3. 애그리거트간 참조는 ID 참조를 이용하자(의존성 낮추고 응집도 높임)

- 서비스단에서 B 애그리거트를 생성하기 위해 A 애그리거트가 선행되야 한다면,

A 애그리거트 안에 B 애그리거트를 리턴하는 팩토리 메서드를 만들자

 

 

 

Ref.

- 'DDD START! 도메인 주도 설계 구현과 핵심 개념 익히기' - 최범균

- https://www.tutorialspoint.com/design_pattern/factory_pattern.htm

- https://hesh1232.tistory.com/154