make it simple
article thumbnail

애매한 도메인 로직의 문제점

 

앞 챕터에서 말한것처럼 좋은 도메인 주도 개발이란 애그리거트에 해당 도메인 로직을 구현하는게 좋다라고 설명을 했다. 하지만, 도메인 영역의 코드를 작성하다 보면, 한 애그리거트로 기능을 구현을 할 수 없을 때가 있다. 결제 금액 계산 로직을 통해 알아보자.

  • 상품 애그리거트:  구매하는 상품의 가격이 필요하다. 또한 배송비가 추가되기도 한다.
  • 주문 애그리거트: 상품별로 구매 개수가 필요하다.
  • 할인 쿠폰 애그리거트: 쿠폰별로 지정한 할인 금액이나 비율에 따라 주문 총 금액을 할인한다. 또한 쿠폰 조건에 따라 중복/ 지정한 카테고리 상품에만 사용 등 과 같은 제약조건이 있을 수 있다.
  • 회원 애그리거트: 회원 등급에 따른 추가 할인이 가능하다.

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

 

어떻게 위와 같이 애그리거트의 책임에 따른 도메인 로직을 구할 수 있을까?

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){
    	...// 등급에 따라 할인 금액 계산
    }

}

위와 같이 주문 애그리거트에 할인 금액/ 정책이 적용된 총 주문 금액을 구하는 calculatePayAmounts()를 구현해봤다.

 

만약, 특별 감사 세일로 전 품목에 대해 한 달간 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){
     	 ...
    }
}
  • 도메인 서비스는 상태 없이 로직만 구현한다. 도메인 서비스를 구현하는데 필요한 상태는 다른 방법으로 전달한다. 위에서는 grade라는 멤버의 상태를 parameter로 전달했다.
  • 위와 같이 할인 금액 계산 로직은 도메인의 의미가 드러나는 용어를 타입과 메서드 이름으로 갖는다.

************* 주의점 ************************************************************************

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);
}
  • 위와 같이 DiscountCalculationService(도메인 서비스)를 Order(주문 애그리거트)에 parameter로 전달하는 것은 애그리거트가 도메인 서비스에 의존한다는 것을 의미한다. 스프링 DI & AOP를 공부하다보면 애그리거트가 의존하는 도메인 서비스를 의존 주입으로 처리하고 싶을 수 있다. 위와 같은 상황에서 해당 서비스를 애그리거트에 의존 주입은 불필요하다.
    • 도메인 객체는 필드로 구성된 데이터와 메서드를 이용해 개념적으로 하나인 모델을 표현한다. 그런데 discountCalculationService 필드는 데이터 자체와 관련이 없고 DB에 저장할 때 다른 필드처럼 저장되지도 않는다.
    • 또 위 필드는 Order라는 도메인에서 일부 기능만 필요하고 많은 기능에서 불필요한 서비스이다.

의존 주입을 예시로 설명했지만 항상 기술에 빠져있기 보다는 꼭 필요한가에 대해 생각하고 경계하자.

**********************************************************************************************


도메인 서비스와 응용 서비스를 구분

// 도메인 서비스
public class TransferService {
	public void transfer(Account fromAcc, Account toAcc, Money amounts) {
    	fromAcc.withdraw(amounts);
        toAcc.credit(amounts);
    }
}

// 응용 서비스
@RequiredArgsConstructor
public class AccountService {
	private final AccountRepository accountRepository;
    private final TransferService transferService;
    
    @Transactional
    public sendMoney(Long fromId, Long toId, Money amounts){
    	Account fromAcc = accountRepository.getById(fromId);
        Account toAcc = accountRepository.getById(toId);
        
    	transferService.transfer(fromAcc,toAcc,Money amounts);
    }
}
  • 위와 같이 계좌이체라는 도메인 서비스가 있다. 두 계좌 애그리거트가 관여하는데 한 애그리거트는 금액을 출금하고 다른 애그리거트는 금액을 입금한다.
  • 도메인 서비스느 도메인 로직을 수행하지 응용 로직을 수행하지 않는다. 트랜잭션 처리와 같은 로직은 응용 로직이므로 도메인 서비스가 아닌 응용 서비스에서 처리해야한다.
  • 응용 서비스/도메인 서비스인지 감을 잡기 어려울 때는 애그리거트의 상태를 변경하거나 값을 계산하는지 생각하면된다. 위와 같이 계좌 이체 로직은 계좌 애그리거트의 상태를 변경하고 결제 금액 로직은 주문 애그리거트의 주문 금액을 계산한다. 이 두 로직은 각각 애그리거트를 변경하고 값을 계산하는 도메인 로직이므로 도메인 서비스로 구현하게된다.

결론

  • 항상 구현을 할 때 프레임워크에 종속되지 않고 사용하면서 적합하고 타탕한지 경계해야된다. 좋은 도메인 설계를 하기 위해선 억지로 한 애그리거트에 도메인 로직을 떠 넘겨 책임의 범위를 넓히는것 좋지 못한다. 적절하게 판단하여 도메인 로직을 애그리거트에 종속 시킬지 아니면 도메인 서비스로 만들어 분리할지 고민을 잘 해야한다. 또한, 이러한 도메인 서비스는 상태를 변경하거나 값을 계산하는지에 대해서만 판별하고 트랜잭션 처리가 필요한 로직은 응용 로직에서 처리하면된다.

reference

profile

make it simple

@keep it simple

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!