도메인 주도 설계 (7) - 도메인 서비스

gentledot·2021년 11월 28일
0

도메인 서비스

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

  • 도메인 영역의 코드를 작성하다 보면 한 애그리거트로 기능을 구현할 수 없을 때가 있다.
    • 대표적인 예가 결제 금액 계산 로직.
      • 상품 애그리거트: 구매하는 상품의 가격이 필요하다. 또한 상품에 따라 배송비가 추가되기도 한다.
      • 주문 애그리거트: 상품별로 구매 개수가 필요하다.
      • 할인 쿠폰 애그리거트: 쿠폰별로 지정한 할인 금액이나 비율에 따라 주문 총 금액을 할인한다. 할인 쿠폰을 조건에 따라 중복 사용할 수 있다거나 지정한 카테고리의 상품에만 적용할 수 있다는 제약 조건이 있다면 할인 게산이 복잡해진다.
      • 회원 애그리거트: 회원 등급에 따라 추가 할인이 가능하다.
  • 실제 결제 금액을 계산해야 하는 주체는? 결제 금액 계산 로직이 주문 애그리거트의 책임이 맞는가?
    • 총 주문 금액을 계산하는 것은 주문 애그리거트가 할 수 있지만
    • 총 주문 금액에서 할인 금액을 계산해야 하는 경우는? 할인 쿠폰 애그리거트로는 단일 쿠폰까진 가능하겠지만 여러 쿠폰이 적용되는 경우에는 계산이 어렵다.
    • 특별 세일로 전 품목에 대해 한 달간 세일을 적용하기로 했다면 주문 애그리거트의 구성요소와는 관련이 없음에도 결제 금액 계산 책임을 가지게 된다면 주문 애그리거트의 코드를 수정해야 한다.
  • 한 애그리거트에 넣기에 애매한 도메인 기능을 특정 애그리거트에서 억지로 구현하면 안 된다.
    • 이 경우 애그리거트는 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에 코드가 길어지고 외부에 대한 의존이 높아지게 된다.
    • 이는 코드를 복잡하게 만들어 수정을 어렵게 만드는 요인이 된다.
    • 애그리거트의 범위를 넘어서는 도메인 개념이 애그리거트에 숨어들어서 명시적으로 드러나지 않게 된다.
  • 이런 문제를 해소하는 가장 쉬운 방법이 바로 도메인 서비스를 별도로 구현하는 것이라 한다.

도메인 서비스 별도 구현

  • 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면 된다.
    • 응용 영역의 서비스가 응용 로직을 다룬다면 도메인 서비스는 도메인 로직을 다룬다.
  • 도메인 서비스가 도메인 영역의 애그리거트나 밸류와 같은 다른 구성요소와 비교할 때 다른 점이 있다면 상태 없이 로직만 구현한다는 점이다.
    • 도메인 서비스를 구현하는 데 필요한 상태는 애그리거트나 다른 방법으로 전달받는다.
  • ex) 할인 금액 계산 로직을 위한 도메인 서비스
    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.calculateDiscajrrtAnouits(this.orderLines, this.coupons, grade);
              this.paymentAmounts = totaBVnounts.minus(discountAmounts);
          }
          ...
      }
  • 애그리거트 메서드를 실행할 때 도메인 서비스를 인자로 전달하지 않고 반대로 도메인 서비스의 기능을 실행할 때 애그리거트를 전달하기도 한다.
  • ex) 계좌 이체 기능
    public class TransferService {
        public void transfer(Account fromAcc, Account toAcc, Money amounts) {
            fromAcc.withdraw(amounts);
            toAcc.credit(amounts);
        }
        ...
    }
    • 계좌 이체의 경우 두 계좌 애그리거트가 관여하는데
      • 한 애그리거트는 금액을 출금하고
      • 한 애그리거트는 금액을 입금한다.
    • 응용 서비스는 두 Account 애그리거트를 구한 뒤에 해당 도메인 영역의 TransferService 를 이용해서 게좌 이체 도메인 기능을 실행할 것이다.
  • 도메인 서비스는 도메인 로직을 수행하지 응용 로직을 수행하지는 않는다.
    • 트랜잭션 처리와 같은 로직은 응용 로직이므로 도메인 서비스가 아닌 응용 서비스에서 처리해야 한다.
  • 특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울 때는 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사해 보면 된다.
    • 계좌 이체 로직은 계좌 애그리거트의 상태를 변경한다.
    • 결제 금액 로직은 주문 애그리거트의 주문 금액을 계산한다.
    • 이 두 로직은 각각 애그리거트를 변경하고 애그리거트의 값을 계산하는 도메인 로직이다. 도메인 로직이면서 한 애그리거트에 넣기 적합하지 않으므로 이 두 로직은 도메인 서비스로 구현하게 된다.

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

  • 애그리거트의 메서드를 실행할 때 도메인 서비스 객체를 파라미터로 전달한다는 것은 애그리거트가 도메인 서비스에 의존한다는 것을 뜻한다.
  • 스프링의 DI와 AOP를 공부하다 보면 애그리거트가 의존하는 도메인 서비스를 의존 주입으로 처리하고 싶어질 수 있다. 관련 기술에 빠져 있으면 특히 그렇다. 프레임워크가 제공하는 의존 주입 기능을 사용해서 도메인 서비스를 애그리거트에 주입해야 기술적으로 나은 것 갈은 착각도 한다.
  • 하지만, 책 저자의 생각으로는 이는 좋은 방법이 아니라고 언급한다. (주관적인 생각이며 의존 자동 주입과 같은 방식을 선호하는 개발자도 물론 있다는 점을 언급한다.)
  • 의존 주입을 하기 위해 애그리거트 루트 엔티티에 도메인 서비스에 대한 참조를 필드로 추가했다고 하자.
    public class Order {
    		@Autowired
    		private DiscountCalculationService discountCalculationService;
    }
    • 도메인 객체는 필드(프로퍼티)로 구성된 데이터와 메서드를 이용한 기능을 이용해서 개념적으로 하나인 모델을 표현한다.
    • 모델의 데이터를 담는 필드는 모델에서 중요한 구성요소이다.
    • 그런데, discountCalculationService 필드는 데이터 자체와는 관련이 없다. 그리고 DB 저장 대상도 아니다. 또한 Order 애그리거트가 제공하는 모든 기능에서 discountCalculationServicefmf 필요로 하는 것도 아니다.
    • 일부 기능만 필요로 한다. 일부 기능을 위해 굳이 도메인 서비스 객체를 애그리거트에 의존 주입할 이유는 없다.
  • 정리하면 도메인 서비스 객체가 대상 애그리거트의 모든 기능에서 사용되는 것이 아니라면 필요한 method에 param으로 전달하는 정도로 구현해두는게 유지보수에 용이한 방식이라 생각된다.

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

  • 도메인 서비스는 도메인 로직을 실행하므로 도메인 서비스의 위치는 다른 도메인 구성 요소와 동일한 패키지에 위치한다. 즉, 도메인 서비스는 도메인 영역에 위치한다.
  • 도메인 서비스의 개수가 많거나 엔티티나 밸류와 같은 다른 구성요소와 명시적으로 구분하고 싶다면 domain 패키지 밑에 하위 패키지를 구분해서 위치시켜도 된다.
    • domain.model
    • domain.service
    • domain.repository

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

  • 도메인 서비스의 로직이 고정되어 있지 않은 경우 도메인 서비스 자체를 인터페이스로 구현하고 이를 구현한 클래스를 둘 수도 있다.
    • 특히 도메인 로직을 외부 시스템이나 별도 엔진을 이용해서 구현해야 할 경우에 인터페이스와 클래스를 분리하게 된다.
    • ex) 할인 금액 계산 로직이 특정 기술에 종속되는 경우 인터페이스와 구현 클래스로 분리한다. 그림 7.2 도메인 서비스 구현 시 인터페이스와 구현 클래스로 분리
  • 도메인 서비스의 구현이 특정 구현 기술에 의존적이거나 외부 시스템의 API를 실행한다면 도메인 영역의 도메인 서비스는 인터페이스로 추상화해야 한다.
    • 이를 통해 도메인 영역이 특정 구현에 종속되는 것을 방지할 수 있고
    • 도메인 영역에 대한 테스트가 수월해진다. (인터페이스의 테스트 구현체로 확인)
profile
그동안 마신 커피와 개발 지식, 경험을 기록하는 공간

0개의 댓글