여러 애그리거트가 필요한 기능

도메인 영역의 코드를 작성하다 보면 한 애그리거트로 기능을 구현할 수 없을 때가 있다. 대표적인 예가 결제 금액 계산 로직이다. 실제 계산 금액을 계산할 때는 다음과 같은 내용이 필요하다.

이 상황에서 실제 결제 금액을 계산해야 하는 주체는 어떤 애그리거트일까? 총 주문 금액을 계산하는 것은 주문 애그리거트가 할 수 있지만 실제 결제 금액은 이야기가 다르다. 총 주문 금액에서 할인 금액을 계산해야 하는데 이 할인 금액을 구하는 것은 누구 책임일까? 할인 쿠폰이 할인 규칙을 갖고 있으니 할인 쿠폰 애그리거트가 계산해야 할까? 그런데, 할인 쿠폰을 두 개 이상 적용할 수 있다면 단일 할인 쿠폰 애그리거트로는 총 결제 금액을 계산할 수 없다.

생각해 볼 수 있는 방법은 주문 애그리거트가 필요한 애그리거트나 필요 데이터를 모두 가지도록 한 뒤 할인 금액 계산 책임을 주문 애그리거트에 할당하는 것이다.

public class Order {
    ...
    private Orderer orderer;
    private List<OrderLine> orderLines;
    private List<Coupon> usedCoupons;

    private Money calculatePayAmounts() {
        Money totalAmounts = calculateTotalAmounts();
        // 쿠폰별로 할인 금액을 구한다. 
        Money discount = coupons.stream()
                                .map(coupon -> calculateDiscount(coupon))
                                .reduce(Money(0), (v1, v2) -> v1.add(v2));
        // 회원에 따른 추가 할인을 구한다. 
        Money membershipDiscount = calculateDiscount(orderer.getMember().getGrade());
        // 실제 결제 금액 계산
        return totalAmounts.minus(discount).minus(membershipDiscount);
    }

    private Money calculateDiscount(Coupon coupon) {
        // orderLines의 각 상품에 대해 쿠폰을 적용해서 할인 금액 계산하는 로직. 
        // 쿠폰의 적용 조건 등을 확인하는 코드 
        // 정책에 따라 복잡한 if-else와 계산 코드 
        ...
    }

    private Money calculateDiscount(MemberGrade grade) {
        ...// 등급에 따라 할인 금액 계산
    }

여기서 고민거리는 결제 금액 계산 로직이 주문 애그리거트의 책임이 맞느냐에 대한 것이다. 예를 들어, 특별 감사 세일로 전 품목에 대해 한 달간 2% 추가 할인을 하기로 했다고 해보자. 이 할인 정책을 주문 애그리거트가 갖고 있는 구성요소와는 관련이 없음에도 불구하고 결제 금액 계산 책임이 주문 애그리거트에 있다는 이유로 주문 애그리거트의 코드를 수정해야 한다.

이렇게 한 애그리거트에 넣기에 애매한 도메인 기능을 특정 애그리거트에서 억지로 구현하면 안 된다. 이 경우 애그리거트는 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에 코드가 길어지고 외부에 대한 의존이 높아지게 된다. 이는 코드를 복잡하게 만들어 수정을 어렵게 만드는 요인이 된다. 게다가 애그리거트의 범위를 넘어서는 도메인 개념이 애그리거트에 숨어들어서 명시적으로 드러나지 않게 된다.

이런 문제를 해소하는 가장 쉬운 방법이 하나 있는데 그것은 바로 도메인 서비스를 별도로 구현하는 것이다.

도메인 서비스

할인 금액 규칙 계산처럼 한 애그리거트에 넣기 애매한 도메인 개념을 구현하려면 애그리거트에 억지로 넣기보다는 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면 된다. 응용 영역의 서비스가 응용 로직을 다룬다면 도메인 서비스는 도메인 로직을 다룬다.

도메인 서비스가 도메인 영역의 애그리거트나 밸류와 같은 다른 구성요소와 비교할 때 다른 점이 있다면 상태 없이 로직만 구현한다는 점이다. 도메인 서비스를 구현하는 데 필요한 상태는 애그리거트나 다른 방법으로 전달받는다.

할인 금액 계산 로직을 위한 도메인 서비스는 다음과 같이 도메인의 의미가 드러나는 용어를 타입과 메서드 이름으로 갖는다.

public class DiscountCalculationService {
    
    public Money calculateDiscountAmounts(
            List<OrderLine> orderLines,
            List<Coupon> coupons,
            MemberGrade grade) {
        Money couponDiscount = coupons.stream()
                                        .map(coupon -> calculatediscount(coupon))
                                        .reduce(Money(0), (v1, v2) -> v1.add(v2));
        
        Money membershipDiscount = calculateDiscount(orderer.getMember().getgrade());

        return couponDiscount.add(membershipDiscount);

    }

    private Money calculateDiscount(Coupon coupon) {
        ...
    }

    private Money calculateDiscount(MemberGrade grade) {
        ...
    }

할인 계산 서비스를 사용하는 주체는 애그리거트가 될 수도 있고 응용 서비스가 될수도 있다. DiscountCalculationService를 다음과 같이 애그리거트의 결제 금액 계산 기능에 전달하면 사용 주체는 애그리거트가 된다.

public class Order {
    
    public void calculateAmounts(
            DiscountCalculationService disCalSvc, MemberGrade grade) {
        Money totalAmounts = getTotalAmounts();
        Money discountAmounts = disCalSvc.calculateDiscountAmounts(this.orderLines, this.coupons, grade);
        this.paymentAmounts = totalAmounts.minus(discountAmounts);
    }
    ...

애그리거트 객체에 도메인 서비스를 전달하는 것은 응용 서비스 책임이다.