애매한 도메인 로직의 문제점
앞 챕터에서 말한것처럼 좋은 도메인 주도 개발이란 애그리거트에 해당 도메인 로직을 구현하는게 좋다라고 설명을 했다. 하지만, 도메인 영역의 코드를 작성하다 보면, 한 애그리거트로 기능을 구현을 할 수 없을 때가 있다. 결제 금액 계산 로직을 통해 알아보자.
- 상품 애그리거트: 구매하는 상품의 가격이 필요하다. 또한 배송비가 추가되기도 한다.
- 주문 애그리거트: 상품별로 구매 개수가 필요하다.
- 할인 쿠폰 애그리거트: 쿠폰별로 지정한 할인 금액이나 비율에 따라 주문 총 금액을 할인한다. 또한 쿠폰 조건에 따라 중복/ 지정한 카테고리 상품에만 사용 등 과 같은 제약조건이 있을 수 있다.
- 회원 애그리거트: 회원 등급에 따른 추가 할인이 가능하다.
위와 같이 애그리거트가 나눠져 있다면 실제 결제 금액을 계산해야하는 주체는 어떤 애그리거트일까? 총 주문 금액을 계산하는 것은 주문 애그리거트가 할 수 있지만 실제 결제 금액 이야기는 다르다. 총 주문 금액에서 할인 금액을 계산해야하는데 이 할인 금액을 구하는 것은 누구 책임일까? 할인 쿠폰 애그리거트가 할인 규칙을 갖고 있으나 할인 쿠폰이 두 개 이상 적용할 수 있다면 단일 할인 쿠폰 애그리거트로는 총 결제 금액을 계산할 수 없다.
어떻게 위와 같이 애그리거트의 책임에 따른 도메인 로직을 구할 수 있을까?
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
- 도메인 주도 개발 시작하기 by 최범균 (http://www.yes24.com/Product/Goods/108431347)
'책 > 도메인 주도 개발' 카테고리의 다른 글
[DDD] 도메인 주도 개발 시작하기 CH_8. 애그리거트 트랜잭션 관리 (0) | 2023.04.17 |
---|---|
[DDD] 도메인 주도 개발 시작하기 CH_6. 응용 서비스와 표현영역 2편 (0) | 2023.03.06 |
[DDD] 도메인 주도 개발 시작하기 CH_6. 응용 서비스와 표현영역 1편 (1) | 2023.03.02 |
[DDD] 도메인 주도 개발 시작하기 CH_5. 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.02.18 |
[DDD] 도메인 주도 개발 시작하기 CH_4. 리포지터리와 모델 구현 2편 (0) | 2023.02.13 |