도메인 주도 개발 시작하기 - 2. 아키텍처 개요

 Date: 2022-09-19

2.1 아키텍처
2.2 계층 구조 아키텍처
2.3 DIP
2.4 도메인 영역의 주요 구성요소
2.5 요청 처리 흐름
2.6 인프라스트럭처
2.7 모듈 구성

2.1 아키텍처

  • 아키텍처를 설계할 때 전형적인 네 가지 영역

    ① 표현 영역

    • HTTP 요청요청 변환응용 영역에 전달응답 변환응답 전송
    • 스프링 MVC 프레임워크가 표현 영역을 위한 기술에 해당한다.

    ② 응용 영역

    • 시스템에서 사용자에게 제공해야 할 기능을 구현한다.
    • 기능을 구현하기 위해 도메인 영역의 도메인 모델을 사용한다.
    • 응용 서비스는 로직을 직접 수행하기보다 도메인 모델에 로직 수행을 위임한다.

    ③ 도메인 영역

    • 도메인 영역은 도메인 모델을 구현한다.
    • 도메인 모델은 도메인의 핵심 로직을 구현한다.
    • 주문 도메인은 배송지 변경, 결제 완료, 주문 총액 계산과 같은 핵심 로직을 도메인 모델에서 구현한다.

    ④ 인프라스트럭처 영역

    • 인프라스트럭처 영역은 구현 기술에 대한 것을 다룬다.
    • RDBMS 연동, 메세징 큐, 몽고DB, 레디스 연동, SMTP 메일 발송, HTTP 클라이언트
    • 표현 영역, 응용 영역, 도메인 영역은 구현 기술을 사용한 코드를 직접 만들지 않고, 인프라스트럭처 영역에서 제공하는 기능을 사용해서 필요한 기술을 개발한다.
    • DB 모듈을 사용하여 데이터를 조회, SMTP 모듈을 이용해서 메일을 발송

2.2 계층 구조 아키텍처

  • 계층 구조는 그 특성상 상위 계층에서 하위 계층으로만 의존한다.
  • 인프라스트럭처 계층이 도메인을 의존하거나 도메인이 응용 계층에 의존하지 않는다.
  • 계층 구조를 엄격하게 적용한다면 상위 계층은 바로 아래의 계층에만 의존을 가져야 하지만 구현의 편리함을 위해 계층 구조를 유연하게 적용하기도 한다.
  • 응용 계층은 도메인 계층에 의존하면서 외부 시스템과의 연동을 위해 더 아래 계층인 인프라스트럭처 계층에 의존하기도 한다.
  • 계층 구조를 사용하면 아키텍처를 직관적으로 이해하기 쉽다는 장점이 있다.
  • 계층 구조의 중요한 특징 중 하나는 표현, 응용, 도메인 계층은 인프라스트럭처 계층에 종속된다.
  • 도메인의 가격 계산 규칙의 예
    • 룰 엔진을사용해서 계산 로직을 수행하는 인프라스트럭처 영역의 코드
    • Drools : 할인 금액을 계산하기 위한 룰 엔진
    • DroolsRuleEngine : 룰 엔진의 가격 계산을 수행 (* Drools는 전방 및 후방 추론 기반 룰 엔진을 갖춘 BRMS이다.)

        public class DroolsRuleEngine {
            private KieContainer kContainer;
            
            public DroolsRuleEngine() {
                KieServices ks = KieServices.get();
                kContainer = ks.getKieClasspathContainer();
            }
            
            public void evalute(String sessionName, List<?> facts) {
                KieSession kSession = kContainer.newKieSession(sessionName);
                try {
                    facts.forEach(x -> kSession.insert(x));
                    kSession.fireAllRules();
                } finally {
                    kSession.dispose();
                }
            }
        }
      
        public class CalculateDiscountService {
            private DroolsRuleEngine ruleEngine;
            
            public CalculateDiscountService(DroolsRuleEngine ruleEngine) {
                ruleEngine = 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();
            }
            ...
        }
      
      • Drools의 세션 이름을 변경하면 CalculateDiscountService의 코드도 함께 변경해야 한다.
      • MutableMoney는 연산 결과값을 보관하기 위한 데이터 타입으로, 다른 방식을 사용하면 필요 없다.

      ➡️  CalculateDiscountService 가 직접적으로 인프라스트럭처 기술에 의존하는것 같지 않아보여도, 실제로는 의존하고 있는 코드로 인해 변경사항에 유연하지 못하게 된다.

      상위 계층이 인프라스트럭처 계층에 종속되면 두 가지 문제점이 있다.
      1. 테스트하기 어렵다.
      2. 구현 방식을 변경하기 어렵다.

2.3 DIP

  • 앞서 살펴본 계층 구조 아키텍처에서의 문제점을 DIP를 통해 해결할 수 있다.
  • DIP 개념을 이해하기 위해 고수준 모델과 저수준 모델의 차이를 이해할 필요가 있다.

    • 고수준 모듈
      • 의미 있는 단일 기능을 제공하는 모듈이다.
      • CalculateDiscountService가격 할인 계산 기능
      • 고수준 모듈의 기능을 구현하려면 여러 하위 기능이 필요하다.
      • 가격 할인 계산 기능을 구현하기 위해서는 고객 정보할인 룰 엔진이 필요
    • 저수준 모듈
      • 하위 기능을 실제로 구현한 것이다.
      • JPA를 이용해 고객 정보 조회, Drools를 이용해 룰을 실행

    ➡️  고수준 모듈이 제대로 동작하려면 저수준 모듈을 사용해야 한다. 고수준 모듈이 저수준 모듈을 사용하면 앞서 계층 구조 아키텍처의 문제점이 발생하게 된다.

  • DIP는 저수준 모듈이 고수준 모듈에 의존하도록 추상화한 인터페이스를 사용한다.

  • 계층형 아키텍처에 DIP를 적용한 예
    • 룰을 적용하기 위해 추상화된 인터페이스를 사용하도록 한다.

        public interface RuleDiscounter {
            Money applyRules(Customer customer, List<OrderLine> orderLines);
        }
      
    • 룰을 적용하는 RuleDiscounter의 구현 객체는 생성자를 통해 전달받는다.

        public class CalculateDiscountService {
            private CustomerRepository customerRepository;
            private RuleDiscounter ruleDiscounter;
            
            public CalculateDiscountService(CustomerRepository customerRepository, 
                                                                                RuleDiscounter ruleDiscounter) {
                        this.customerRepository = customerRepository;
                this.ruleDiscounter = ruleDiscounter;
            }
                
            public Money calculateDiscount(List<OrderLine> orderLines, String customerId) {
                Customer customer = findCustomer(customerId);
                return ruleDiscounter.applyRules(customer, orderLines);
            }
                ...
        }
      
    • 할인 룰 적용을 구현한 클래스는 RuleDiscounter 인터페이스를 상속받아 구현한다.

        public class DroolsRuleDiscounter implements RuleDiscounter {
            private KieContainer kContainer;
            
            public DroolsRuleDiscounter() {
                KieServices ks = KieServices.get();
                kContainer = ks.getKieClasspathContainer();
            }
            
            @Override
            public Money applyRules(Customer customer, List<OrderLine> orderLines) {
                KieSession kSession = kContainer.newKieSession("discountCalculation");
                try {
                    ... 코드 생략
                    kSession.fireAllRules();
                } finally {
                    kSession.dispose();
                }
                return money.toImmutableMoney();
            }
        }
      
  • DIP를 적용하면 저수준 모듈이 고수준 모듈에 의존하게 된다. 고수준 모듈이 저수준 모듈을 사용하는데 반대로 저수준 모듈이 고수준 모듈을 의존한다고 해서 이를 DIP(Dependency Injection, 의존 역전 원칙)이라고 부른다.

  • DIP를 적용하면 고수준 모듈이 저수준 모듈에 의존할 때 발생했던 두 가지 문제를 해소할 수 있다.
    • 구현 기술을 변경하더라도 CalculateDiscountService를 수정할 필요가 없다.

        // 사용할 저수준 구현 객체 변경
        RuleDiscounter ruleDiscounter = new SimpleRuleDiscounter();
        // 사용할 저수준 모듈을 변경해도 고수준 모듈을 수정할 필요 없음
        CalulateDiscountService discountService = new CalculateDiscountService(ruleDiscounter);
      
    • CalculateDiscountService가 구현 객체가 아닌 인터페이스를 사용하므로, 대역 객체를 사용해서 테스트 할 수 있다.

        @Test
        public void noCustomer_thenExceptionsShouldBeThrown() {
            // 테스트 목적의 대역 객체
            CustomerRepository stubRepo = mock(CustomerRepository.class);
            when(stubRepo.findById("noCustId")).thenReturn(null);
            
            RuleDiscounter stubRule = (cust, lines) -> null;
            
            // 대용 객체를 주입 받아 테스트 진행
            CalculateDiscountService calculateDiscountService = new CalculateDiscountService(stubRepo, stubRule);
            assertThrows(NoCustomerException.class,
                    () -> calculateDiscountService.calculateDiscount(someLines, "noCustId"));
        }
      

2.3.1) DIP 주의 사항

  • DIP를 잘못 생각하면 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들일 수 있다.

      public class CalculateDiscountService {
          private CustomerRepository customerRepository;
          private RuleDiscounter ruleDiscounter;
        
          public CalculateDiscountService(CustomerRepository customerRepository, RuleDiscounter ruleDiscounter) {
              this.customerRepository = customerRepository;
              this.ruleDiscounter = ruleDiscounter;
          }
        
          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);
        
              ruleDiscounter.evaluate("discountCalculation", facts);
              return money.toImmutableMoney();
          }
      		...
      }
    
  • DIP를 적용할 때, 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출한다.

2.3.2) DIP와 아키텍처

  • 인프라스트럭처 계층이 가장 하단에 위치하는 계층형 구조와 달리 DIP를 적용하면 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조가 된다.
  • 그래서 도메인과 응용 영역에 대한 영향을 최소화하면서 구현 기술을 변경하는 것이 가능하다.
  • DIP를 항상 적용할 필요는 없다. 구현 범위와 추상화 정도에 따라 DIP의 이점을 얻는 수준에서 적용 범위를 검토해보자.

2.4 도메인 영역의 주요 구성요소

요소 설명 사용
엔티티
ENTITY
- 고유의 식별자를 갖는다.
- 라이프 사이클을 갖는다.
- 도메인의 데이터를 포함하며, 해당 데이터와 관련된 기능을 함께 제공한다.
- 주문(Order)
- 회원(Member)
- 상품(Product)
밸류
VALUE
- 고유식 식별자를 갖지 않는다.
- 개념적으로 하나인 값을 표현할 때 사용된다.
- 엔티티의 속성으로 사용할 뿐 아니라 다른 밸류 타입의 속성으로도 사용할 수 있다.
- 주소(Address)
- 금액(Money)
애그리거트
AGGREGATE
- 연관된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다. [주문 애그리거트]
- Order 엔티티
- OrderLine 밸류
- Orderer 밸류
리포지터리
REPOSITORY
- 도메인 모델의 영속성을 처리한다. - 엔티티 객체를 조회
- 엔티티 객체를 저장
도메인 서비스
DOMAIN SERVICE
- 특정 엔티티에 속하지 않은 도메인 로직을 제공한다.
-도메인 로직이 여러 엔티티와 밸류를 필요로 하면 도메인 서비스에서 로직을 구현한다.
[할인 금액 계산]
- 상품
- 쿠폰
- 회원 등급

2.4.1) 앤티티와 밸류

  • DB 테이블의 엔티티 VS 도메인 모델의 엔티티
    • 도메인 모델의 엔티티는 단순히 데이터를 담고 있는 데이터 구조가 아니라 데이터와 함께 기능을 제공하는 객체이다.

        public class Order {
            // 주문 도메인 모델의 데이터
            private OrderNo number;
            private Orderer orderdr;
            private ShippingInfo shippingInfo;
            ...
            
            // 도메인 모델 엔티티는 도메인 기능도 함께 제공
            public void changeShippingInfo(ShippingInfo newShippingInfo) {
                ...
            }
        }
      
    • 도메인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표현할 수 있다.

        public class Orderer {
            private String name;
            private String email;
            ...
        }
      
    • RDBMS 같은 관계형 데이터베이스는 밸류 타입을 제대로 표현하기 힘들다.

      • 한 테이블에 주문자 정보를 함께 넣으면 ‘주문자’ 개념이 드러나지 않는다.
      • 다른 테이블에 주문자 정보를 저장하면 엔티티처럼 보여, 밸류 타입의 의미가 들어나지 않는다.
  • 밸류는 불변으로 구현할 것을 권장하며, 이는 엔티티의 밸류 타입 데이터를 변경할 때는 객체 자체를 완전히 교체한다는 것을 의미한다.

      public class Order {
          private ShippingInfo shippingInfo;
          // 도메인 모델 엔티티는 도메인 기등도 함께 제공		
          public changeShippingInfo(ShippingInfo newShippingInfo) {
      		checkShippingInfoChangeable();
              setShippingInfo(newShippingInfo);
          }
        		
          public void setShippingInfo(ShippingInfo newShippingInfo) {
              if (newShippingInfo == null) throw new IllegalArgumentException();
              // 밸류 타입의 데이터를 변경할 때는 새로운 객체로 교체한다.
              this.shippingInfo = newShippingInfo;
          }
      	...
      }
    

2.4.2) 애그리거트

  • 도메인이 커질수록 엔티티와 밸류 개수가 많아져 도메인 모델이 복잡해진다.
  • 도메인 모델이 복잡해지면 개발자가 전체 구조가 아닌 엔티티와 밸류에만 집중하는 상황이 발생한다.
  • 상위 수준에서 모델을 관리하지 않고 개별 요소에만 초점을 맞추다 보면, 모델을 제대로 이해하고 관리할 수 없게 된다.
  • 지도에서도 대축척 지도와 소축척 지도를 함께 봐야 현재 위치를 보다 정확하게 이해할 수 있다.
  • 도메인 모델에서 전체 구조를 이해하는데 도움이 되는 것이 애그리거트이다.
    • 에그리거트는 관련 객체를 하나로 묶은 군집이다.
    • 애그리거트를 사용하면 개별 객체가 아닌 관련 객체를 묶어서 객체 군집 단위로 모델을 바라볼 수 있다.
    • 애그리거트는 군집에 속한 객체를 관리하는 루트 엔티티를 갖는다.
    • 애그리거트 루트를 통해서 간접적으로 애그리거트 내의 다른 엔티티나 밸류 객체에 접근한다.
      ➡️  애그리거트 단위로 구현을 캡슐화

      public class Order {
          ...
          private ShippingInfo shippingInfo;
              
          // 도메인 모델 엔티티는 도메인 기등도 함께 제공		
          public changeShippingInfo(ShippingInfo newShippingInfo) {
              checkShippingInfoChangeable();  // 배송지 변경 가능 여부 확인
              setShippingInfo(newShippingInfo);
          }
          		
          private void checkShippingInfoChangeable() {
              // 배송지 정보를 변경할 수 있는지 여부를 확인하는 도메인 규칙 구현
          }
          ...
      }
      
    • 주문 애그리거트는 Order를 통하지 않고는 ShippingInfo를 변경할 수 있는 방법을 제공하지 않는다. 즉, 배송지를 변경하려면 루트 엔티티인 Order를 사용해야 하므로, 배송지 정보를 변경할 때는 Order가 구현한 도메인 로직을 항상 따르게 된다.
  • 애그리거트를 어떻게 구성했느냐에 따라 구현 복잡도와 트랜잭션 범위가 달라진다.

2.4.3) 리포지터리

  • 도메인 객체를 지속적으로 사용하려면 물리적인 저장소에 도메인 객체를 보관해야 하는데, 이를 위한 도메인 모델이 리포지터리이다.
  • 리포지터리는 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.

      public interface OrderRepository {
          Order findByNumber(OrderNumber orderNumber);
          void save(Order order);
          void delete(Order order);
      }
    
    • 객체를 조회하고 저장하는 단위가 애그리거트 루트인 Order이다.
  • 도메인 모델 관점에서 OrderRepository는 도메인 객체를 영속화하는데 필요한 기능을 추상화한 것으로 고수준 모듈에 속한다.
  • 기반 기술을 이용해서 OrderRepositoy를 구현한 클래스는 저수준 모듈로 인프라스트럭처 영역에 속한다.
  • 응용 서비스와 리포지터리는 밀접한 연관이 있다.
    • 응용 서비스는 필요한 도메인 객체를 조회하거나 저장할 때 리포지터리를 사용한다.
    • 응용 서비스는 트랜잭션을 관리하는데, 트랜잭션 처리는 리포지터리 구현 기술의 영향을 받는다.

2.5 요청 처리 흐름

  • 도메인의 상태를 변경해야 하는 경우, 변경된 상태가 올바르게 반영되도록 트랜잭션 관리가 필요하다.
    • 스프링프레임워크를 사용하면 @Transactional 애너테이션으로 트랜잭션을 처리할 수 있다.

2.6 인프라스트럭처

  • 인프라스트럭처는 표현 영역, 응용 영역, 도메인 영역을 지원한다.
  • 도메인 영역과 응용 영역에서 인프라스트럭처 기능을 직접 사용하는 것보다 DIP를 적용하여 시스템을 더 유연하고 테스트하기 쉽게 만들 수 있다.
  • 하지만 무조건 인프라스트럭처에 대한 의존을 없앨 필요는 없다.
    • 트랜잭션 처리를 위한 @Transactional 애너테이션
    • JPA 전용 애너테이션 - @Entity, @Table
  • 구현의 편리함은 DIP가 주는 다른 장점 만큼 중요하기 때문에, DIP의 장점을 헤치치 않는 범위에서 사용하는 것이 좋다.

2.7 모듈 구성

  • 아키텍처의 각 영역은 별도 패키지에 위치한다.
  • 패키지 구성 규칙에 정답이 존재하는 것은 아니지만, 같은 영역별로 모듈이 위치할 패키지를 구성할 수 있다.
  • 도메인이 크면 하위 도메인으로 나누고, 각 하위 도메인마다 별도 패키지를 구성한다.
  • 도메인 모듈은 도메인이 속한 애그리거트를 기준으로 다시 패키지를 구성한다.
  • 애그리거트, 모델, 리포지터리는 같은 패키지에 위치시킨다.
    • shop.order.domain.order : 애그리거트 위치
    • shop.order.domain.service : 도메인 서비스 위치

    • 도메인이 복잡하면 도메인 모델과 도메인 서비스를 별도 패키지에 위치시킬 수도 있다.

      • shop.catalog.application.product
      • shop.catalog.application.catetory

    ➡️  모듈 구조를 얼마나 세분화해야 하는지에 대해 정해진 규칙은 없다. 보통 한 패키지에 10~15개 미만으로 유지하는 편이다.