Heeveloper

[DDD Start]7장. 도메인 서비스


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

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

  • 상품 애그리거트 : 구매하는 상품의 가격. 또는 상품에 따라 추가되는 배송비
  • 주문 애그리거트 : 상품별 구매 개수
  • 할인 쿠폰 애그리거트 : 쿠폰별 지정된 할인 금액, 비율과 중복 사용 여부 등의 조건
  • 회원 애그리거트 : 회원 등급

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

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

public class Order {
  ...
  private Orderer orderer;
  private List<OrderLine> orderLines;
  private List<Coupon> usedCoupons;
  
  private Money calculatePayAmounts() {
    ...
  }
  
  private Money calculateDiscount() {
    ...
  }
  ...
}

여기서 고민거리는 결제 금액 계산 로직이 주문 애그리거트의 책임이 맞느냐에 대한 것이다.

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

즉, 애그리거트는 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에 코드가 길어지고 외부에 대한 의존이 높아지게 되어 유지보수를 어렵게 만들고 도메인 개념이 명시적으로 드러나지 않게 된다.

2. 도메인 서비스

할인 금액 규칙 계산처럼 한 애그리거트에 넣기 애매한 도메인 개념을 구현하기 위해, 도메인 서비스를 사용한다.

도메인 서비스가 도메인 영역의 애그리거트나 밸류 등과 다른 점이 있다면, 상태없이 로직만 구현한다는 것이다. 도메인 서비스를 구현하는 데 필요한 상태는 애그리거트나 다른 방법으로 전달받는다.

public class DiscountCalculationService {
  public Money calculateDiscountAmounts(
    List<OrderLine> orderLines,
    List<Coupon> coupons,
    MemberGrade grade
  ) {
    ...
  }
  
  private Money calculateDiscount(Coupon coupon) {
    ...
  }
  
  private Money calculateDiscount(MemberGrade grade) {
    ...
  }
}

할인 계산 서비스를 사용하는 주체는 애그리거트가 될 수도 있고, 응용 서비스가 될 수도 있다.

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);
  }
}

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

public class OrderService {
  private DiscountCalculationService discountCalculationService;
  
  @Transactional
  public OrderNo placeOrder(OrderRequest orderRequest) {
    OrderNo orderNo = orderRepository.nextId();
    Order order = createOrder(orderNo, orderReuqest);
    orderRepository.save(order);
    return orderNo;
  }
  
  private Order createOrder(OrderNo orderNo, OrderRequest orderReq) {
    Member member = findMember(orderReq.getOrdererId);
    Oder order = new Order(orderNo, orderReq.getOrderLines(), orderReq.getCoupons(), createOderer(member), orderReq.getShippingInfo());
    order.calculaetAmounts(this.discountCalculationService, member.getGrade());
    return order;
  }
  ...
}


도메인 서비스 객체를 애그리거트에 주입하지 않기

애그리거트의 메서드를 실행할 때 도메인 서비스 객체를 파라미터로 전달하는 것은 애그리거트가 도메인 서비스에 의존한다는 것을 뜻한다. 스프링의 DI와 AOP를 공부하다보면 애그리거트가 의존하는 도메인 서비스를 의존성 주입으로 처리하고 싶어질 수 있다.

하지만, 개인적으로 좋은 방법이 아니라고 생각한다. 아래와 같이 애그리거트 루트 엔티티에 도메인 서비스에 대한 참조를 의존주입으로 필드에 추가했다고 하자.

public class Order {
  @Autowired
  private DiscountCalculationService discountCalculationService;
  ...
}

도메인 객체는 필드(프로퍼티)로 구성된 데이터와 메서드를 이용한 기능을 이용해 개념적으로 하나의 모델을 표현한다.

그런데 discountCalculationService 필드는 데이터 자체와는 관련이 없다. Order 객체를 DB에 보관할 때 저장 대상도 아니다.

또한 Order 가 제공하는 모든 기능에서 discountCalculationService 를 필요로 하는 것도 아니다. 일부 기능을 위해 굳이 도메인 서비스 객체를 애그리거트에 의존 주입할 필요는 없다.

애그리거트 메서드를 실행할 때 도메인 서비스를 인자로 전달하지 않고, 반대로 도메인 서비스이 기능을 실행할 때 애그리거트를 전달하기도 한다. 이런 식으로 동작하는 것 중 하나가 계좌 이체 기능이다. 계좌 이체의 경우, 두 계좌 애그리거트가 도메인 서비스의 인자로 전달된다.

public class TransferService {
  public void transfer(Account fromAcc, Account toAcc, Money amounts) {
    fromAcc.withdraw(amounts);
    toAcc.credit(amounts);
  }
  ...
}

응용 서비스는 두 Account 애그리거트를 구한 뒤 해당 도메인 영역의 TransferService를 이용해서 계좌 이체 도메인 기능을 실행할 것이다. 즉, 도메인 서비스는 도메인 로직을 수행하며, 트랜잭션 처리와 같은 응용 로직은 응용 서비스에서 처리한다.

특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울 때는 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트 상태값을 계산하는지 검사하면 된다.

예를 들어, 계좌 이체 로직은 계좌 애그리거트의 상태를 변경한다. 결제 금액 로직은 주문 애그리거트의 주문 금액을 계산한다. 두 로직은 각각 애그리거트를 변경하고, 애그리거트의 값을 계산하는 도메인 로직이다. 도메인 로직이면서 한 애그리거트에 넣기 적합하지 않으므로 이 두 로직은 도메인 서비스로 구현하게 된다.

도메인 서비스의 패키지 위치

다른 도메인 구성요소(ex. Order, OrderRepository,.. )와 동일한 패키지에 위치한다.

domain 패키지 밑에 model, service, repository와 같이 하위 패키지를 구분해서 위치시켜도 된다.

도메인 서비스의 인터페이스와 클래스

도메인 서비스의 로직이 고정되어 있지 않은 경우, 도메인 서비스 자체를 인터페이스로 구현하고 이를 구현한 클래스를 둘 수도 있다. 특히 도메인 로직을 외부 시스템이나 별도 엔진을 이용해 구현해야 할 경우 인터페이스와 클래스를 분리하게 된다.

예를 들어, 할인 금액 계산 로직을 룰 엔진을 이용해 구현한다면 아래 그림처럼 도메인 영역에는 도메인 서비스 인터페이스가 위치하고 실제 구현은 인프라스트럭처 영역에 위치시킬 수 있다.

이를 통해 도메인 영역이 특정 구현에 종속되는 것을 방지할 수 있고 도메인 영역에 대한 테스트가 수월해진다.


Similar Posts

Comments

-->