Aggregate, 애그리거트

706__·2023년 11월 30일

DDD

목록 보기
3/11

Aggregate

시스템을 개발할 때, 상위 수준 개념을 이용해서 전체 모델을 정리하면 도메인 모델의 복잡한 관계를 전반적으로 이해하는 데 도움이 된다.
상위 수준에 대한 이해 없이 개별 객체만으로 상위 수준에서 개념을 파악하려면 오랜 시간이 걸린다.
더 많은 코드를 보고 도메인 전문가와 더 많은 대화를 나눠야 비로소 상위 수준에서 모델 간의 관계가 이해되기 시작한다.

도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 된다.
결국 도메인 모델의 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워진다.
주요 도메인 요소 간의 관계를 파악하기 어렵다는 말은 곧, 코드 변경과 확장이 어려워짐을 의미한다.
상위 수준에서 모델이 어떻게 엮여 있는지 이해하고 있어야 전체 모델을 망가뜨리지 않으면서 추가 요구사항을 모델에 잘 반영할 수 있다.

그렇다면 복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들려면 어떻게 해야 할까?
정답은 바로 Aggregate 를 활용하는 것이다.

Aggregate 는 상위 수준에서 모델을 조망할 수 있는 방법으로, 관련된 객체를 하나의 군집으로 묶어 준다.
수많은 객체를 관련 객체의 군집 단위로 묶어서 바라보면 상위 수준에서 도메인 모델 간의 관계를 쉽게 파악할 수 있다.


애그리거트는 모델을 이해하는 데 도움을 줄 뿐만 아니라, 일관성을 관리하는 기준도 된다.
모델을 보다 잘 이해할 수 있고, 애그리거트 단위로 일관성을 관리하기 떄문에 복잡한 도메인을 단순한 구조로 만들어준다.
복잡도가 낮아지는 만큼 도메인 기능을 확장하고 변경하는 데 필요한 노력도 줄어든다.

하나의 애그리거트에 속한 객체들은 유사하거나 동일한 라이프 사이클을 갖는다.
이 말은 곧, 주문 애그리거트를 만들려면 관련 객체를 함께 생성해야 한다는 것이다.
도메인 규칙에 따라 최초 주문 시점에 일부 객체를 만들 필요가 없는 경우도 있지만, 대부분 함께 생성하고 함께 제거된다.

애그리거트는 독립된 객체 군집으로, 하나의 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다.
각 애그리거트는 자기 자신을 관리할 뿐, 다른 애그리거트를 관리하지 않는다.
주문 애그리거트는 '배송지 변경' 혹은 '주문 상품의 수 변경' 등 자기 자신은 관리하지만, 회원의 비밀번호를 변경하지 않는 것처럼 말이다.

그렇다면 애그리거트를 구성하기 위한 경계 설정의 기본은 무엇일까?
바로 도메인 규칙요구사항이다.

도메인 규칙에 따라 함께 생성되는 구성요소는 하나의 애그리거트에 속할 가능성이 높다.
또한, 함께 변경되는 빈도가 높은 객체들도 한 애그리거트에 속할 가능성이 높다.

흔히 'A가 B를 갖는다'로 설계할 수 있는 요구사항이 있다면 A와 B를 하나의 애그리거트로 묶어서 생각하기 쉽다.
하지만 이것이 반드시 A와 B가 하나의 애그리거트에 속한다는 것을 의미하지는 않는다.

좋은 예가 상품과 리뷰다.
상품 상세 페이지에는 상품 상세 정보와 함께 리뷰 내용을 보여줘야 한다는 요구사항이 있다고 가정하자.
우리는 ProductReview 가 하나의 애그리거트에 속한다고 생각하기 쉽다.
하지만 이들은 함께 생성되지도, 함께 변경되지도 않으며, 심지어 이들을 변경하는 주체도 각각 다르다.
각 엔티티의 변경이 서로에게 영향을 주지 않기 때문에 서로 다른 애그리거트에 속하는 것이 옳다.

처음 도메인 모델을 만들기 시작하면 큰 애그리거트로 보이는 것들이 많을 것이다.
도메인에 대한 경험이 생기고 도메인 규칙을 제대로 이해할수록 애그리거트의 실제 크기는 줄어든다.

Aggregate root

주문 애그리거트는 다음을 포함한다.

  • 총 금액 totalAmounts 를 갖고 있는 Order 엔티티
  • 개별 구매 상품의 개수 quantity 와 금액 price 를 갖고 있는 OrderLine 밸류
  • 주문 총 금액은 개별 상품의 주문 개수 X 가격의 합이다.

너무 당연한 말이지만, 애그리거트는 여러 객체의 군집이므로 모든 객체의 상태가 정상 상태를 가져야 한다.
애그리거트에 속한 모든 객체가 일관된 상태를 유지하기 위해, 애그리거트 전체를 관리할 주체가 필요하다.
이 책임을 갖는 주체가 바로 Aggregate root 이다.

root 엔티티는 애그리거트의 대표 엔티티이다.
애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직접적으로, 또는 간접적으로 속하게 된다.
주문 애그리거트에서의 루트 엔티티가 바로 Order 이다.


도메인 규칙과 일관성

루트 엔티티의 역할은 단순히 애그리거트에 속한 객체를 포함하는 것만이 아니다.
핵심은 일관성이 깨지지 않도록 하는 것이다.
이를 위해 루트 엔티티는 애그리거트가 제공해야 할 도메인 기능을 구현한다.
루트 엔티티가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다.

애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경해서는 안 된다!
이는 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다.

ShippingInfo shippingInfo = order.getShippingInfo();
shippingInfo.setAddress(newAddress);

다음 코드는 루트에서 ShippingInfo 를 가져와 직접 정보를 변경하고 있다.
주문 상태에 상관없이 배송지 정보를 변경하는데, 이는 도메인 규칙을 무시하고 직접 DB 테이블의 데이터를 수정하는 것과 같은 결과를 만든다.
다시 말해, 논리적인 데이터 일관성이 깨지게 된다.

ShippingInfo shippingInfo = order.getShippingInfo();

if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
	throw new IllegalArgumentException();
}
shippingInfo.setAddress(newAddress);

그렇다면 다음과 같이 상태 확인 로직을 Service 에 구현하면 되지 않을까?
이렇게 되면 동일한 검사 로직이 여러 서비스에서 중복으로 구현될 가능성이 높아 유지보수에 좋지 않다.

불필요한 중복을 피하고 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음과 같은 요소를 습관적으로 적용해야 한다.

  • 단순히 필드를 변경하는 setter 를 공개 범위로 만들지 않는다.
  • 밸류 타입은 Immutable 하게 구현한다.

공개적인 setter 는 도메인의 의미나 의도를 표현하지 못하고 도메인 로직을 도메인 객체가 아닌 응용이나 표현 영역으로 분산시킨다.
도메인 로직이 한 곳에 응집되지 않으므로 유지 보수에 많은 시간과 노력이 필요하다.

도메인 모델의 엔티티나 밸류에 공개적인 setter 를 넣지 않는 것만으로도 일관성이 깨질 가능성이 줄어든다.
공개적인 setter 를 사용하지 않으면 의미가 드러나는 이름의 메서드를 사용해서 구현할 가능성이 높아진다.

public class Order {

	private ShippingInfo shippingInfo;
    
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
    	verifyNotYetShipped();
        setShippingInfo(newShippingInfo);
    }
    
    // setter의 접근 허용 범위는 private!
    private void setShippingInfo(ShippingInfo newShippingInfo) {
    	// 밸류가 immutable이면 새로운 객체를 할당해서 값을 변경해야 한다.
        this.shippingInfo = newShippingInfo;
    }
}    

밸류 타입의 내부 상태 변경은 오직 루트를 통해서만 가능해야 한다.
애그리거트 루트가 도메인 규칙을 올바르게만 구현한다면 애그리거트 전체의 일관성을 올바르게 유지할 수 있다.


루트의 기능 구현

root 엔티티는 애그리거트 내부의 다른 객체들과 조합해서 기능을 완성한다.
루트는 기능 구현을 위해 구성요소의 상태를 참조하기도 하지만, 기능 실행을 위임하기도 한다.
이 과정에서, 애그리거트 외부에서 내부 상태를 변경하는 기능을 실행하게 되면 일관성을 무너뜨리는 버그를 만들 수 있다.
이러한 버그가 생기지 않도록 Immutable 하게 구현하는 것이 좋다.

팀 표준이나 구현 기술의 제약으로 인해 불변 객체를 구현할 수 없다면 변경 기능을 패키지나 protected 범위로 한정해서 외부에서 실행할 수 없도록 제한하는 방법도 있다. 보통 한 애그리거트에 속하는 모델은 한 패키지에 속하므로 애그리거트 외부에서 상태 변경을 방지할 수 있다.


트랜잭션 범위

트랜잭션 범위는 작을수록 좋다.
한 트랜잭션이 하나의 테이블을 수정하는 것과 세 개의 테이블을 수정하는 것을 비교하면 성능에서 차이가 발생한다.
여러 개의 테이블을 수정하는 것은 잠금 대상이 더 많아짐을 의미하고, 그만큼 동시에 처리할 수 있는 트랜잭션 개수가 줄어드는 것을 의미한다.
이것은 당연하게도 처리량, 즉 전체적인 성능을 떨어뜨린다.

한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다.
한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 높아지므로 전체 처리량이 떨어지게 된다.
이는 곧, 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것을 의미한다.
한 애그리거트에서 다른 애그리거트를 수정하면 결과적으로 두 개의 애그리거트를 한 트랜잭션에서 수정하게 되므로, 애그리거트 내부에서 다른 애그리거트의 상태 변경 기능을 실행해서는 안 된다.

여러 번 강조해서 얘기하지만,
한 트랜잭션에서는 하나의 애그리거트만 수정해야 하며, 한 애그리거트는 자신이 아닌, 다른 애그리거트를 변경해서는 안된다.
자기 자신이 아닌 다른 애그리거트의 상태 변경은 결과적으로 한 트랜잭션에서 여러 애그리거트를 수정하는 것을 의미한다.
애그리거트 내부에서 다른 애그리거트의 상태를 변경하는 기능을 실행하면 안 된다.

자신이 속한 애그리거트가 아닌, 다른 애그리거트의 상태를 변경하는 것은 다음과 같은 이유로 사용을 금지한다.

  • 자신의 책임 범위를 넘어 다른 애그리거트의 상태까지 관리하는 꼴!
  • 하나의 트랜잭션에서 여러 애그리거트를 수정하기 때문에 성능 저하를 유발한다.
  • 애그리거트는 최대한 독립적이어야 하는데, 다른 애그리거트의 기능에 의존하면 애그리거트 간의 결합도가 높아진다.
  • 결합도가 높아질수록 향후 수정 비용이 증가한다.

부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면 어떻게 해야 할까?
애그리거트에서 직접 수정하지 말고, Service 에서 두 애그리거트를 수정하도록 구현하면 된다.

public class ChangeOrderService {
  
  	// 두 개 이상의 애그리거트를 변경해야 할 경우, service에서 각 애그리거트의 상태를 변경하자.
  	@Transactional
  	public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo, boolean useNewShippingAddressAsMemberAddr) {
  		Order order = orderRepository.findbyId(id);
  		if (order == null) throw new OrderNotFoundException();
  		order.shipTo(newShippingInfo);
  		if (useNewShippingAddressAsMemberAddr) {
  			Member member = findMember(order.getOrder());
  			member.changeAddress(newShippingInfo.getAddress());
  		}
  	}
}  

도메인 이벤트를 사용하면 한 트랜잭션에서 하나의 애그리거트를 수정하면서 동기 · 비동기로 다른 애그리거트의 상태를 변경할 수 있다.
이에 대한 내용은 뒤에서 다루도록 하겠다.

한 트랜잭션에서 한 개의 애그리거트만을 변경할 것을 권장하지만, 다음 경우에는 둘 이상의 애그리거트 변경을 고려할 수 있다.

  • 팀 표준 팀이나 조직의 표준에 따라 사용자 유스케이스와 관련된 서비스의 기능을 한 트랜잭션에서 실행해야 하는 경우
  • 기술 제약 기술적으로 이벤트 방식을 도입할 수 없는 경우
  • UI 구현의 편리 운영자의 편리함을 위해 주문 목록 화면에서 여러 주문의 상태를 한 번에 변경하고 싶은 경우

Repository와 Aggregate

Aggregate 는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 Repository 는 애그리거트 단위로 존재한다.
새로운 애그리거트를 만들면 저장소에 영속화해야 하고, 사용하려면 저장소에 읽어와야 한다.
때문에 리포지토리는 보통 다음의 두 메서드를 기본으로 제공한다.

  • save
  • findById

두 메서드 외에 필요에 따라 다양한 조건으로 애그리거트 검색 메서드나 삭제 메서드를 추가할 수 있다.

어떤 기술을 이용해서 리포지토리를 구현하느냐에 따라서 애그리거트의 구현도 영향을 받는다.
JPA를 사용하면 데이터베이스 관계형 모델에 객체 도메인 모델을 맞춰야 할 때도 있다.
특히, 레거시 DB, 팀 내 DB 설계 표준을 따라야 하는 경우에는 DB 테이블 구조에 맞게 모델을 변경해야 한다.

애그리거트는 개념적으로 하나이므로 Repository 는 애그리거트 전체를 저장소에 영속화해야 한다.

// repository에 aggregate를 저장하면 aggregate 전체를 영속화해야 한다.
orderRepository.save(order);

애그리거트를 구하는 메서드는 완전한 애그리거트를 제공해야 한다.
완전한 애그리거트를 제공하지 않으면 필드가 값이 올바르지 않아 기능 실행 도중에 NPE 같은 문제가 발생할 수 있다.

// repository는 완전한 order를 제공해야 한다.
Order order = orderRepository.findById(orderId);

// order가 온전한 애그리거트가 아니면, 실행 도중에 NPE같은 문제가 발생한다.
order.cancel();

애그리거트를 영속화할 저장소로 어떤 기술을 사용하든지 간에 애그리거트 상태가 변경되면 모든 변경을 원자적으로 반영해야 한다.
원자적으로 반영되지 않으면 데이터의 일관성이 깨지는 문제가 발생한다.

  • RDBMS를 이용한 구현은 트랜잭션을 이용해 변경의 원자적 변경을 보장할 수 있다.
  • mongoDB를 이용한 구현은 하나의 애그리거트를 하나의 문서에 저장함으로써 변경의 손실을 방지할 수 있다.

ID를 이용한 Aggregate 참조

한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다.
정확하게 말하자면, 애그리거트에서 다른 애그리거트의 루트를 참조한다.

필드를 이용해 다른 애그리거트를 직접 참조하는 것은 개발자에게 구현의 편리함을 제공한다.
예를 들어, 주문 정보 조회 화면에서 회원 ID를 이용해 링크를 제공해야 한다면 다음과 같이 구할 수 있을 것이다.

order.getOrder().getMember().getId();

JPA는 @ManyToOne, @OneToMany 같은 어노테이션을 이용한 연관객체 로딩을 제공하므로 필드로 다른 애그리거트를 쉽게 참조할 수 있다.
ORM 기술 덕분에 루트 참조를 쉽게 구현할 수 있고 데이터를 쉽게 조회할 수 있지만 다음과 같은 문제를 야기할 수 있다.

  • 편한 탐색 오용
  • 성능에 대한 고민 --> 다양한 경우의 수를 고려한 연관매핑과 JPQL/Criteria 쿼리의 로딩 전략 결정
  • 확장 어려움 --> 분산 서버를 적용하면서 하위 도메인별로 다른 기술 적용

한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트 상태 변경을 쉽게 할 수 있다.
다른 애그리거트의 상태를 변경하는 것은 애그리거트 간의 의존 결합도를 높이고 결과적으로 애그리거트 변경을 어렵게 만든다.
한 애그리거트가 관리하는 범위는 자기 자신으로 한정해야 한다.

이런 세 가지 문제를 완화할 때 사용할 수 있는 방법이 ID 를 이용해서 다른 애그리거트를 참조하는 것이다.
ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결된다.
이를 통해 얻을 수 있는 이점은 다음과 같다.

  • 애그리거트의 경계가 명확해짐
  • 애그리거트 간의 물리적인 연결을 제거함으로써 모델의 복잡도가 낮아짐
  • 애그리거트 간의 의존을 제거함으로써 응집도를 높임
public class ChangeOrderService {

	@Transactional
    public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo, boolean useNewShppingAddrAsMemberAddr) {
    	Order order = orderRepository.findbyId(id);
        if (order == null) throw new OrderNotFoundException();
        order.changeShippingInfo(newShippingInfo);
        if (useNewShippingAddrAsMemberAddr) {
        	// ID를 이용해 참조하는 애그리거트 구하기
            Member member = memberRepository.findById(order.getOrder().getMemberId());
            member.changeAddress(newShippingInfo.getAddress());
        }
    }
}

다른 애그리거트를 직접 참조하기 않기 때문에 애그리거트 간 참조를 위해 어떤 로딩 전략을 사용할지 고민할 필요가 없어졌다.
Service 에서 필요한 애그리거트를 로딩하므로 애그리거트 수준에서 지연 로딩을 하는 것과 동일한 결과를 만든다.

ID를 이용한 참조 방식은 복잡도를 낮춤과 동시에 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 근원적으로 방지한다.
외부 애그리거트를 직접 참조하지 않기 때문에 애초에 다른 애그리거트의 상태를 변경할 수 없다.

애그리거트별로 다른 구현 기술을 사용하는 것도 가능해진다.


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

ID를 이용한 참조 방식은 여러 애그리거트를 읽어야할 때 조회 속도가 문제될 수 있다.

주문 목록을 보여주려면 상품과 회원 애그리거트를 함께 읽어야 하는데, 주문마다 상품과 회원 애그리거트를 읽어온다고 가정하자.
한 DBMS에 데이터가 있다면 조인을 이용해 한 번에 모든 데이터를 가져올 수 있음에도 불구하고 주문마다 상품 정보를 읽어오는 쿼리를 실행하게 된다.

Member member = memberRepository.findById(ordererId);
List<Order> orders = orderRepository.findByOrderer(ordererId);
List<OrderView> dtos = orders.stream()
	.map(order -> {
		ProducId prodId = order.getOrderLines().get(0).getProductId();
        // 각 주문마다 첫 번째 주문 상품 정보 로딩을 위한 쿼리를 실행
        Product product = productRepository.findById(prodId);
        return new OrderView(order, member, product);
	}).collect(toList());

주문 개수가 10개면 주문을 읽어오기 위한 쿼리와 주문별 상품을 읽어오는 10번의 쿼리가 실행될 것이다.
ID를 이용한 애그리거트 참조는 지연 로딩과 같은 효과를 만들기 때문에 지연 로딩과 관련된 대표적인 문제인 N + 1 이슈를 발생시킨다.
N + 1 조회 문제는 많은 쿼리의 실행으로 전체 조회 속도를 느리게 만드는 원인이 된다.
이 문제를 해결하기 위해서 Join을 사용해야 한다.

ID 참조 방식을 사용하면서 N + 1 이슈를 해결하려면 조회 전용 쿼리를 사용하면 된다.
예를 들어, 데이터 조회를 위한 별도 DAO 를 만들고 DAO 조회 메서드에서 조인을 이용한 하나의 쿼리로 데이터를 로딩하는 것이다.

@Repository
public class JpaOrderViewDao implements OrderViewDao {
	
	@PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public List<OrderView> selectByOrderer(String ordererId) {
    	String selectQuery = 
        	"select new OrderView(o, m, p) from Order o join o.orderLInes ol, Member m, Product p " +
            "where o.orderer.memberId.id = :orderId and o.orderer.memberId = m.id and index(ol) = 0 and ol.productId = p.id " + 
            "order by o.number.number desc";
        TypedQuery<OrderView> query = entityManaber.createQuery(selectQuery, OrderView.class);
        query.setParameter("ordererId", ordererId);
        return query.getResultList();
    }
}    

애그리거트마다 서로 다른 저장소를 사용하면 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다.
이때는 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 별도로 구성한다.
코드가 복잡해지지만 시스템 처리량을 높일 수 있다.
특히 한 대의 DB 장비로 대응할 수 없는 수준의 트래픽이 발생하는 경우 캐시나 조회 전용 저장소는 필수 선택 기법이다.


Aggregate 간 집합 연관

애그리거트 간 1:NM:N 연관은 컬렉션을 이용한 연관이다.

애그리거트 간 일대다 연관관계는 Set 같은 컬렉션을 이용해서 표현할 수 있다.

public class Category {
	private Set<Product> products; // 다른 애그리거트에 대한 1:N 연관관계
}    

개념적으로 존재하는 일대다 연관을 실제 구현에 반영하는 것이 요구사항의 충족과 무관할 때가 있다.

특정 카테고리에 속한 상품 목록을 보여주는 요구사항을 가정해보자.
보통 목록 관련 요구사항은 페이징을 이용해 상품을 나눠서 보여준다.
이 기능을 일대다 연관을 이용해 구현하면 다음과 같이 코드를 작성할 것이다.

public class Category {

	private Set<Product> products;
    
    public List<Product> getProducts(int page, int size) {
    	List<Product> sortedProducts = sortById(products);
        return sortedProduct.subList(page - 1) * size, page * size);
    }
    ...
}    

이 코드는 상품 데이터가 많다면 실행 속도를 급격히 느리게 만들어 성능에 심각한 문제를 일으킬 것이다.
개념적으로 일대다 연관이 있더라도 이런 성능 문제 때문에 실제 구현에 반영하지 않는다.

카테고리에 속한 상품을 구해야 한다면 상품 입장에서 자신이 속한 카테고리를 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);
        checkCategory(category);
        List<Product> products = productRepository.findByCategoryId(category.getId() page, size);
        int totalCount = productRepository.countsByCategoryId(category.getId());
        return new Page(page, size, totalCount, products);
    }
}    

다대다 연관은 개념적으로 양쪽 애그리거트에 컬렉션으로 연관을 맺는다.
일대다 연관관계와 동일하게, 다대다 연관관계 역시 실제 요구사항을 고려해서 구현에 포함시킬지를 결정해야 한다.

보통, 특정 카테고리에 속한 상품 목록을 띄울 때 목록 화면에서 각 상품이 속한 모든 카테고리를 표시하지는 않는다.
제품이 속한 모든 카테고리가 필요한 화면은 상세 화면이다.
이러한 요구사항을 고려할 때 카테고리에서 상품으로의 집합 연관은 필요하지 않다.
개념적으로 상품과 카테고리의 양방향 다대다 연관이 존재하지만 실제 구현에서는 상품에서 카테고리로의 단방향 다대다 연관만 적용하면 되는 것이다.

업로드중..

public class Product {
	private Set<CategoryId> categoryId;
}
@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;
    ...
}    
@Repository
public class JpaProductRepository implements ProductRepository {

	@PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public List<Product> findByCategoryId(CategoryId cId, int page, int size) {
    	TypedQuery<Product> query = entityManager.createQuery(
        		"select p from Product p " +
            	"where :cId member of p.categoryIds order by p.id.id desc",
            	Product.class);
        query.setParameter("cId", cId);
        query.setFirstResult((page - 1) * size);
        query.setMaxResults(size);
        return query.getResultList();
    }
}    

Aggregate를 팩토리로 사용하기

중요한 도메인 로직 처리가 Service 에 노출되는 것은 좋지 않다.
도메인 로직은 논리적으로 하나의 도메인 기능이기 때문이다.

도메인 기능을 넣기 위해 별도의 도메인 서비스나 팩토리 클래스를 만들 수도 있다.
또 다른 방법으로, 도메인 기능을 다른 애그리거트에 구현할 수도 있다.

다음 코드는 Product 를 생성하는 기능을 Store 애그리거트에 옮긴 코드이다.

public class Store {

	public Product createProduct(ProductId newProductId, ...) {
    	if (isBlocked()) throw new StoreBlockedException();
        return new Product(newProductId, getId(), ...);
    }
}    
pubic class RegisterProductService {

	public ProductId registerNewProduct(NewProductRequest req) {
    	Store store = storeRepository.findById(req.getStoreId());
        checkNull(store);
        ProductId prodId = productRepository.nextId();
        Product product = store.createProduct(prodId, ...);
        productRepository.save(product);
        return id;
    }
    ...
}    

StoreProduct 를 생성할 수 있는지 확인하는 도메인 로직을 Store 에서 구현하고 있다.
createProduct()Product 생성을 담당하는 팩토리 역할을 하면서도 중요한 도메인 로직을 구현하고 있다.
이제 상품 생성 가능 여부를 확인하는 도메인 로직을 변경해도 도메인 영역의 Store 만 변경하면 되고 서비스는 영향을 받지 않는다.
도메인의 응집도도 높아졌다.
이것이 바로 애그리거트를 팩토리로 사용할 때 얻을 수 있는 장점이다.

애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 애그리거트 팩토리 메서드를 고려하자.
애그리거트를 팩토리로 사용함으로써 생성 시에 필요한 데이터의 일부를 직접 제공하면서도, 중요한 도메인 로직을 함께 구현할 수 있게 된다.
생성 시에 많은 정보를 알아야 한다면, 애그리거트 팩토리에서 직접 생성하지 않고 위임할 수 있다.
다른 팩토리에 생성을 위임하더라도 도메인 로직은 한 곳에 계속 위치한다.

public class Store {
	public Product createProduct(ProductId newProductId, ProductInfo pi) {
    	if (isBlocked()) throw new StoreBlockedException();
        return ProductFactory.create(newProductId, getId(), pi);
    }
}   

0개의 댓글