최범균 님의 DDD Start를 읽고 정리한 내용입니다.

DDD 아키텍처

네 가지 영역

아키텍처를 설계할 때 등장하는 4가지의 전형적인 영역이 있다.

  1. 표현
    • 사용자의 요청을 응용 영역에 전달
    • 응용 영역의 처리 결과를 (시각화하여) 사용자에게 전달
  2. 응용
    • 사용자에게 제공해야 할 기능 구현
  3. 도메인
    • 도메인 모델을 로직으로 구현
  4. 인프라 스트럭처
    • DBMS, Message Queue 등 인프라 영역과의 연계

Spring에 빗대면 아래 그림과 같은 구조를 이룬다.

 

 

표현 계층은 응용 계층에 의존적이고, 응용은 도메인에, 도메인은 인프라 스트럭처에 의존한다.

상황에 따라 상위 계층이 하위 계층에 의존하지 않을 수 있지만, 하위 계층은 상위 계층을 의존하지는 않는다.


아래 예시 코드는 금액 계산 로직이 복잡하여 '인프라 스트럭처' 계층에 속한 룰 엔진을 적용한 것이라고 가정한다.

Drools 룰 엔진의 evaluate 메소드는 연산을 수행하는 코드라고만 이해하고 예시를 보자.

  1. DroolsRuleEngine

     public class DroolsRuleEngine {
         ...
    
         public void evaluate(String sessionName, List<?> facts) {
             // evaluate value
         }
     }
  2. CalculateDiscountService

     public class CalculateDiscountService {
         private DroolsRuleEngine ruleEngine;
    
         public CalculateDiscountService() {
             ruleEngine = new ruleEngine();
         }
    
         public Money calculateDiscount(List<OrderLine> orderLines, String customerId) {
             Customer customer = findCustomer(customerId);
             MutableMoney money = new MutableMoney(0);
             List<?> facts = Arrays.asList(customer, money);
             facts.addAll(orderLines);
             ruleEngine.evaluate("discountCalculation", facts);
             return money.toImmutableMoney();
         }
     }

위의 코드는 당연히 잘 동작하겠지만, 몇 가지 문제점이 있다.

  1. CalculateDiscountService를 단독으로 테스트하기 어렵다.

    • DroolsRuleEngine이 잘 작동함을 보장해야 CalculateDiscountService의 동작도 보장할 수 있다.
  2. 구현 방식을 변경하기 어렵다

    • 만약 룰 엔진을 변경한다고 가정한다.
    • calculateDiscount 메소드를 변경해야 한다.

CalculateDiscountServiceDroolsRuleEngine에 강하게 결합되어 있기 때문에 생기는 문제들이다.

의존 역전 원칙 (Dependency Inversion Principle)

고수준(상위 계층) 모듈을 사용하기 위해선 저수준(하위 계층)이 제대로 동작해야 한다.

하지만 이렇게 된다면 위 예제의 문제가 발생한다.

이 문제를 해결하기 위한 전략이 바로 DIP이다.

저수준 모듈이 고수준 모듈에 의존하도록 바꾸는 것이다.

예제의 코드를 약간 수정해보자.

  1. RuleDiscounter

     public interface RuleDiscounter {
         public Money applyRules(Customer customer, List<OrderLine> orderLines);
     }
  2. CalculateDiscountService

     public class CalculateDiscountService {
         private RuleDiscounter ruleDiscounter;
    
         public CalculateDiscountService(RuleDiscounter ruleDiscounter) {
             this.ruleDiscounter = ruleDiscounter;
         }
    
         public Money calculateDiscount(List<OrderLine> orderLines, String customerId) {
             Customer customer = findCustomer(customerId);
             return ruleDiscounter.applyRules(customer, orderLines);
         }
     }
  3. DroolsRuleEngine

     public class DroolsRuleEngine implements RuleDiscounter {
         ...
    
         @Override
         public Money applyRules(Customer customer, List<OrderLine> orderLines) {
             // Do something...
         }
     }

인터페이스를 활용하여 의존 구조를 완전히 바꾸었다.

더불어 calculateDiscount 메소드에서 처리하던 로직을 ruleDiscounter에 분배하여 코드도 조금 더 깔끔해졌다.

이제 CalculateDiscountService 를 테스트할 때 RuleDiscounter 에 적당한 Mock Object를 주입하면 된다.

구현을 바꾸고 싶다면 DroolsRuleEngine 대신 다른 구현체를 사용하면 된다.

제시했던 2가지 문제는 DIP로 해결하였다.

고수준 - 의미 있는 기능을 제공하는 모듈

저수준 - 고수준의 기능을 구현하는 하위 기능의 실제 구현

  • CalculateDiscountService 은 할인 금액을 계산하는 응용 계층인 고수준 모듈이다.
  • RuleDiscounter 는 '룰을 이용한 할인 금액 계산'이라는 의미와 기능을 지닌 고수준 모듈이다.
  • DroolsRuleEngine 은 고수준 모듈의 하위 기능인 RuleDiscounter 의 구현체이므로 저수준 모듈이다.

다이어그램으로 그려보면 아래와 같다.

 

 

저수준 모듈이 고수준 모듈에 의존하는 형태가 되며 의존 관계가 역전되는 것이다.

DIP는 객체 지향 5대 원칙 SOLID에도 속하는 아주 중요한 개념이므로 잘 알아둘 필요가 있다.

DIP 주의사항

DIP의 핵심은 저수준 모듈이 고수준 모듈에 의존하는 것이다.

그러나 잘못된 구조를 설계한다면 DIP가 아니라 일반적인 의존 계층을 지닐 수 있다.

 

 

인터페이스가 RuleDiscounter 에서 RuleEngine 으로 바뀌었다.

RuleEngine 의 개념은 사용할 룰 엔진을 정의하는 것이므로 저수준 모듈에 속한다.

이런 경우 DIP가 무너지며 맨 처음에 작성했던 코드가 갖는 문제를 똑같이 갖게 된다.

DIP 위반을 고려해도 되는 경우

DIP는 코드에 내재된 문제를 해결해주는 좋은 전략이다.

그러나 DIP가 항상 정답인 것은 아니다.

  • @Transactional

    @Transactional 은 Spring에서 트랜잭션 처리를 도와주는 어노테이션으로, 단 한 줄 써놓기만 하면 복잡한 설정을 일정 영역 내에 전부 적용할 수 있다.

    그러나, @Transaction의 사용은 Spring에 대한 의존도가 굉장히 높아질 수밖에 없다.

두 가지 선택지를 고려해보자.

  • DIP를 위해 설정하는 과정을 직접 구현한다. 단, 구현 과정은 매우 복잡하다.

무엇이 나은지는 개발자가 선택할 일이지만, 대부분의 경우 2가 더 낫다.

도메인 영역의 구성요소

  1. Entity
    • 고유 식별자는 갖는 객체로, 도메인의 고유한 개념 표현
    • 도메인 모델의 데이터와 관련 기능 포함
  2. Value
    • 식별자가 없는 객체
    • 개념적으로 속성을 표현할 때 사용
  3. Aggregate
    • 관련된 entity와 value의 묶음
    • 이전 포스트의 예제에서 Order , OrderLine 등 주문과 관련된 요소는 '주문' 애그리거트로 묶을 수 있다.
  4. Repository
    • 도메인 모델의 영속성 처리 (DB 등 활용)
  5. Domain Service
    • 특정 엔티티에 속하지 않는 도메인 로직 제공

애그리거트 (Aggregate)

애플리케이션의 크기가 커질수록 도메인은 복잡해질 수밖에 없다.

이럴 땐 개별적인 요소보다 큰 군집인 애그리거트의 관점에서 바라보는 것이 전체 구조를 이해하는데 도움이 된다.

 

 

주문의 예시를 다시 한번 살펴보자.

  • 도메인 개념'주문'
    1. 주문
    2. 배송지 정보
    3. 주문자
    4. 주문 목록
    5. 총 결제 금액

'주문'은 하위 5개 도메인 및 밸류를 묶어 표현하기에 적당하다고 여겨진다.

애그리거트에 진입하기 위해선 적절한 루트 엔티티를 선정할 필요가 있다.

'주문'은 하위 개념인 배송지 정보, 주문자 등을 입력받아 루트 엔티티로 사용하기 적절하며, 모든 정보를 갖는 완전한 객체로 사용해야 한다.

  • 주문 정보에서 배송지 정보나 주문 목록, 총 결제 금액이 빠질 수는 없다.

레포지토리 (Repository)

도메인 객체를 계속 사용하려면 DB와 같은 외부 저장소를 활용해야 한다.

외부 저장소를 활용하는 도메인 모델을 레포지토리라고 한다.

레포지토리는 인프라 스트럭처 영역에 속하며 애그리거트 단위로 CRUD를 수행한다.

  • 주문 애그리거트를 위한 레포지토리

      public interface OrderRepository {
          Order findByNumber(OrderNumber number);
          void save(Order order);
          void delete(Order order);
      }

+ Recent posts