책/도메인 주도 개발 시작하기

[DDD] 2. 아키텍처 개요

키크니개발자 2023. 1. 8. 17:30

 

2.1 네 개의 영역

  • 표현, 응용, 도메인, 인프라스트럭처는 아키텍처를 설계할 떄 출현하는 전형적인 네 가지 영역이다.
  • 표현 영역
    • 사용자의 요청을 받아 응용 영역에 전달하고, 응용 내역의 처리 결과를 다시 사용자에게 보여주는 역할(대표적으로 스프링 MVC 프레임워크가 여기에 해당)
  • 응용 영역
    • 시스템이 사용자에게 제공해야 할 기능을 구현한다.(주문 등록, 주문 취소, 상품 상세 조회 등)
    • 기능을 구현하기 위해 도메인 영역의 도메인 모델을 사용한다.
    • 응용 서비스는 로직을 직접 수행하기 보다는 도메인 모델에 로직 수행을 위임한다.

public class CancelOrderService {

    @Transactional
    public void cancelOrder(String orderId) {
        Order order = findOrderById(orderId);
        if (order == null) throw new OrderNotFoundException(orderId);
        order.cancel()
    }
}
  • 도메인 영역
    • 도메인 모델을 구현한다. (1장에서 봤던 Order, OrderLine, ShippingInfo)
    • 도메인의 핵심 로직을 구현한다.
      • 예를 들면 주문 도메인은 ‘배송지 변경’, ‘결제 완료’, ‘주문 총액 계산’과 같은 핵심 로직을 도메인 모델에서 구현한다.
  • 인프라스트럭처 영역
    • 구현 기술에 대한 것을 다룬다.
      • RDBMS 연동 처리
      • 메시징 큐에 메시지를 전송 및 숮신
      • 몽고 DB나 레디스와의 데이터 연동 처리
      • SMTP를 이용한 메일 발송 기능 구현
      • HTTP 클라이언트를 이용해서 REST API를 호출하는 것도 처리
    • 논리적인 개념을 표현하기 보다는 실제 구현을 다룬다.

  • 도메인 영역, 응용 영역, 표현 영역은 구현 기술을 사용한 코드를 직접 만들지 않는다.
    • 대신 인프라스트럭처 영역에서 제공하는 기능을 사용해서 필요한 기능을 개발한다.
    • 예를 들어 DB에 보관된 데이터가 필요하면 인프라스트럭처 영역의 DB 모듈을 사용하여 데이터를 읽어온다.
    • 외부에 메일을 발송해야 한다면 인프라스트럭처가 제공하는 SMTP 연동 모듈을 이용해서 메일을 발송한다.

2.2 계층 구조 아키텍처

 

  • 네 영역을 구성할 때 많이 사용하는 아키텍처가 [그림 2.4]와 같은 계층 구조이다.
  • 계층 구조는 특성상 상위 계층에서 하위 계층으로의 의존만 존재하고 하위 계층은 상위 계층에 의존하지 않는다.
    • 계층 구조를 엄격하게 적용한다면 상위 계층은 바로 아래의 계층에만 의존을 가져야 하지만 구현의 편리함을 위해 계층 구조를 유연하게 적용하기도 한다.
    • 예를 들어 응용 계층은 바로 아래 계층인 도메인 계층에 의존하지만 외부 시스템과의 연동을 위해 더 아래 계층인 인스라스트럭처 계층에 의존하기도 한다.
    • 아래와 같이 의존하게 되면, 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라스트럭처 계층에 종속된다는 점이다.
      • 인프라스트럭처에 의존하면 ‘테스트 어려움’과 ‘기능 확장의 어려움’ 이라는 두 가지 문제가 발생하는 것을 알게되었다.
        • 이 두 문제는 DIP를 통해 해결할 수 있다.

2.3 DIP (Dependency Inversion Principle) : 의존 역전 법칙

  • 가격 할인 계산을 하려면 [그림 2.7]의 왼쪽과 같이 고객 정보를 구해야 하고, 구한 고객 정보와 주문 정보를 이용해서 룰을 실행해야 한다.
    • CalculateDiscountService는 고수준 모듈이며, ‘가격 할인 계산’이라는 기능을 구현한다.
    • 고수준 모듈은 의미 있는 단일 기능을 제공하는 모듈이다.
      • 고수준 모듈의 기능을 구현하려면 여러 하위 기능이 필요하다. (하위 기능 : 고객 정보를 구하고, 룰을 실행한다.)
    • 저수준 모듈은 하위 기능을 실제로 구현한 것이다.
      • JPA를 이용해서 고객 정보를 읽어오는 모듈과 Drools로 룰을 실행하는 모듈이 저수준 모듈이다.
    • 고수준 모듈이 저수준 모듈을 사용하면 앞서 계층 구조 아키텍처에서 언급했던 두 가지 문제, 즉 구현 변경과 테스트가 어렵다는 문제가 발생한다.
  • DIP는 이 문제를 해결하기 위해 저수준 모듈이 고수준 모듈에 의존하도록 바꾸며, 이는 추상화 인터페이스를 통해 이루어진다.

2.2 계층 구조 아키텍처 예시

public class DroolsRuleEngine {
    private KieContainer kContainer;
    
    public DroolsRuleEngine() {
        KieService ks = KieServices.Factory.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.dispoise();
        }
    }
}

public class CalculateDiscountService {
    private DroolsRuleEngine ruleEngine;
    
    public CalculateDiscountService() {
        ruleEngine = new DroolsRuleEngine();
    }
    
    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.evalute("discountCalculation", facts);
        return money.toImmutableMoney();
        
    }
}

DIP를 사용해서 수정 

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


public class DroolsRuleDiscounter implements RuleDiscounter {
    private KieContainer kContainer;

    public DroolsRuleDiscounter() {
        KieService ks = KieServices.Factory.get();
        kContainer = ks.getKieClasspathContainer();
    }

    @Override
    public Money applyRules(Customer customer, List<OrderLine> orderLines) {
        KieSession kSession = kContainer.newKieSession("discountSession");
        try {
            // .. 코드 생략
            kSession.fireAllRules();
        } finally {
            kSession.dispoise();
        }
        return money.toImmutableMoney();
    }
}

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

    }
}
  • CalculateDiscountService에는 기존 Drools에 의존하는 코드가 없다. 단지 RuleDiscounter가 룰을 적용한다는 사실만 알뿐이다.
  • 실제 RuleDiscounter의 구현 객체는 생성자를 통해서 전달 받는다.

  • 먼저 고수준 모듈의 개념에서 필요한 수준으로 인터페이스를 추상화한다.
  • 고수준 모듈은 이 인터페이스를 의존하고, 저수준 모듈은 이 인터페이스를 구현한다.
  • 실제 사용할 저수준 구현 객체는 의존 주입을 통해서 전달받는다.
  • DIP를 적용하면 [그림 2.9]와 같이 저수준 모듈이 고수준 모듈에 의존하게 된다. (그림에선 저수준과 고수준 글씨가 반대로 적혀있다)
  • 저수준 모듈이 고수준 모듈에 의존한다고 해서 DIP(Dependency Inversion Principle), 의존역전 원칙 이라고 부른다
  • 의존역전 원칙을 통해 구현 변경의 어려움을 해결할 수 있다.
    • (컴파일 타임에) 고수준 모듈은 실제 구현체가 어떤 것인지 모른 채 주입받는다.
    • 스프링과 같은 의존 주입을 지원하는 프레임워크를 사용하면 쉽게 구현체를 변경 할 수 있다.
    // 1. 사용할 저수준 객체 생성 (RuleDiscounter는 인터페이스)
    RuleDiscounter ruleDiscounter = new DroolsRuleDiscounter();
    
    // 2. 생성자 방식으로 주입
    CalculateDiscountService disService = new CalculateDisocuntService(ruleDiscounter);
    
    // 3. 사용할 저수준 구현 객체 변경
    RuleDiscounter ruleDiscounter = new SimpleRuleDiscounter();
    
    // 4. 사용할 저수준 모듈을 변경해도 고수준 모듈을 수정할 필요가 없음
    CalculateDiscountService disService = new CalculateDisocuntService(ruleDiscounter);
    
  • 테스트의 어려움 또한 해결할 수 있다
    • CustomerRepository와 RuleDicounter 등의 고수준의 인터페이스를 사용하면 대역 객체를 사용해서 테스트를 진행할 수 있다
      ➡️ 대역객체란? 다른말로 Test Double을 의미한다. 테스트 목적으로 real object를 흉내내는 객체를 의미한다. 대표적인 예로는 mock을 들 수 있다.

2.3.1 DIP 주의사항

  • DIP를 잘못 사용하면 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들일 수 있다.
  • DIP의 핵심은 고수준 모듈이 저수준 모듈에 의존하지 않도록 하기 위함인데 DIP를 적용한 결과 구조만 보고 [그림2.10]과 같이 저수준 모듈에서 인터페이스를 추출하는 경우가 있다.
    • [그림 2.10]은 잘못된 구조이다. 이 구조에서 도메인 영역은 구현 기술을 다루는 인프라스트럭처 영역에 의존하고 있다. RuleEngiine 인터페이스는 고수준 모듈인 도메인 관점이 아니라 룰 엔진이라는 저수준 모듈에서 도출한 것이다.
  • DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출한다.
    • CalculateDiscountService 입장에서 봤을 때 할인 금액을 구하기 위해 룰 엔진을 사용하는지 직접 연산하는지는 중요하지 않다.
    • 단지 규칙에 따라 할인 금액을 계산한다는 것이 중요하다.
    • 따라서 '할인 금액 계산'을 추상화한 인터페이스는 고수준 모듈에 위치한다.

2.3.2 DIP와 아키텍처

  • 인프라스트럭처 계층이 가장 하단에 위치하는 계층형 구조와 달리 아키텍처에 DIP를 적용하면 [그림2.12]와 같이 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조가 된다.
  • 인프라스트럭처에 위치한 클래스가 도메인이나 응용 영역에 정의한 인터페이스를 상속받아 구현하는 구조가 되므로 도메인과 응용 영역에 대한 영향을 주지 않거나 최소화하면서 구현 기술을 변경하는 것이 가능하다.

 

  • EmailNotifier 클래스는 응용 영역의 Notifier 인터페이스를 상속받고 있다.
  • 주문 시 통지 방식에 SMS를 추가해야 한다는 요구사항이 들어왔을 떄 응용 영역의 OrderService는 변경할 필요가 없다.

  • 두 통지방식을 함께 제공하는 Notifier 구현 클래스를 인프라스트럭처 영역에 추가하면 된다.
  • 비슷하게 마이 바티스 대신 JPA를 구현 기술로 사용하고 싶다면 JPA를 이용한 OrderRepository 구현 클래스를 인프라스트럭처 영역에 추가하면 된다.

💡 DIP를 항상 적용할 필요는 없다.

  • 사용하는 기술에 따라 완벽한 DIP를 적용하기보다는 구현 기술에 의존적인 코드를 도메인에 일부 포함하는 게 효과적일 때도 있다.
  • 추상화 대상이 잘 떠오르지 않을 때도 있다.
  • 이럴 때는 무조건 DIP를 적용하려고 시도하지 말고 DIP의 이점을 얻는 수준에서 적용 범위를 검토해보자.

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

  • 위 구성요소는 에릭 에반스가 [도메인 주도 설계]에서 소개한 패턴에 기반하고 있다.
  • 도메인 영역의 모델은 도메인의 주요 개념을 표현하며 핵심 로직을 구현한다.

2.4.1 엔티티와 밸류

  • 도메인 모델의 엔티티와 DB 모델의 엔티티는 다르다.
  • 두 모델의 가장 큰 차이점은 도메인 모델의 엔티티는 데이터와 함께 도메인 기능을 함께 제공한다는 점이다.
    • 예를 들어 주문을 표현하는 엔티티는 주문과 관련 된 데이터뿐만 아니라 배송지 주소 변경을 위한 기능을 함께 제공한다.
    • 도메인 모델의 엔티티는 단순히 데이터를 담고 있는 데이터구조라기보다는 데이터와 함께 기능을 제공하는 객체이다.
    • 도메인 관점에서 기능을 구현하고 기능 구현을 캡슐화해서 데이터가 임의로 변경되는 것을 막는다.
  • 또 다른 차이점은 도메인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표현할 수 있다는 것이다.
    • RDBMS와 같은 관계형 데이터베이스는 밸류 타입을 제대로 표현하기 힘들다.
    • 1장에서 설명한 것처럼 밸류는 불변으로 구현할 것을 권장하며, 이는 엔티티의 밸류 타입 데이터를 변경할 때는 객체 자체를 완전히 교체한다는 것을 의미한다.
      • 예를 들어 배송지 정보를 변경하는 코드는 기존 객체의 값을 변경하지 않고 다음과 같이 새로운 객체를 필드에 할당한다.

2.4.2 애그리거트

  • 도메인이 커질수록 개발한 도메인 모델도 커지면서 많은 엔티티와 밸류가 출현하며, 엔티티와 밸류 개수가 많아질수록 모델은 점점 더 복잡해진다.
  • 도메인 모델이 복잡해지면 개발자가 전체 구조가 아닌 한 개 엔티티와 밸류에만 집중하는 상황이 발생한다.
    • 이때 상위 수준에서 모델을 관리하지 않고 개별 요소에만 초점을 맞추다 보면 큰 수준에서 모델을 이해하지 못해 큰 틀에서 모델을 관리할 수 없는 상황에 빠질 수 있다.
  • 도메인 모델에서 전체 구조를 이해하는 데 도움이 되는 것이 바로 애그리거트(AGGREGATE)이다.

  • 애그리거트를 사용하면 개별 객체가 아닌 관련 객체를 묶어서 객체 군집 단위로 모델을 바라볼 수 있게 된다.
    • 개별 객체 간의 관계가 아닌 애그리거트 간의 관계로 도메인 모델을 이해하고 구현하게 되며, 이를 통해 큰 틀에서 도메인 모델을 관리할 수 있다.
  • 애그리거트는 군집에 속한 객체를 관리하는 루트 엔티티를 갖는다.
    • 루트 엔티티는 애그리거트에 속해 있는 엔티티와 밸류 객체를 이용해서 애그리거트가 구현해야 할 기능을 제공한다.
    • 애그리거트를 사용하는 코드는 애그리거트 루트가 제공하는 기능을 실행하고, 애그리거트 루트를 통해서 간접적으로 애그리거트 내의 다른 엔티티나 밸류 객체에 접근한다.
    • 이는 애그리거트의 내부 구현을 숨겨서 애그리거트 단위로 구현을 캡슐화할 수 있도록 돕는다.

  • 애그리거트 루트인 Order는 주문 도메인 로직에 맞게 애그리거트의 상태를 관리한다.
    • 예를 들어 Order의 배송지 정보 변경 기능은 배송지를 변경할 수 있는지 확인한 뒤에 배송지 정보를 변경한다.
  • 애그리거트를 구현할 때는 고려할 것이 많다.
    • 애그리거트를 어떻게 구성했느냐에 따라 구현이 복잡해지기도 하고, 트랜잭션 범위가 달라지기도 한다.
    • 선택한 구현 기술에 따라 애그리거트 구현에 제약이 생기기도 한다.

2.4.3 리포지터리

  • 도메인 객체를 지속적으로 사용하려면 RDBMS, NoSQL, 로컬 파일과 같은 물리적인 저장소에 도메인 객체를 보관해야 한다.
  • 이를 위한 도메인 모델이 리포지터리(Repository)이다.
    • 엔티티나 밸류가 요구사항에서 도출되는 도메인 모델이라면 리포지터리는 구현을 위한 도메인 모델이다.
  • 리포지터리는 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.

  • [그림 2.19]의 전체 모듈 구조
    • 도메인 모델 관점에서 OrderRepository는 도메인 객체를 영속화하는 데 필요한 기능을 추상화한 것으로 고수준 모듈에 속한다.
    • 기반 기술을 이용해서 OrderRepository를 구현한 클래스는 저수준 모듈로 인프라스트럭처 영역에 속한다.
  • 응용 서비스와 리포지터리는 밀접한 연관이 있다.
    • 응용 서비스는 의존 주입과 같은 방식을 사용해서 실제 리포지터리 구현 객체에 접근한다.
    • 응용 서비스는 필요한 도메인 객체를 구하거나 저장할 때 리포지터리를 사용한다
    • 응용 서비스는 트랜잭션을 관리하는데, 트랜잭션 처리는 리포지터리 구현 기술의 영향을 받는다
  • 리포지터리를 사용하는 주체가 응용 서비스이므로 리포지터리는 응용 서비스가 필요로 하는 메서드를 제공하는데, 다음 두 메서드가 기본이 된다(필요에 따라 다른 메서드를 추가적으로 제공한다)
    • 애그리거트를 저장하는 메서드
    • 애그리거트 루트 식별자로 애그리거트를 조회하는 메서드

2.5 요청 처리 흐름

  • 표현영역
    • 사용자가 애플리케이션에 기능 실행을 요청하면 그 요청을 처음 받는 영역이다.
    • 사용자가 전송한 데이터 형식이 올바른지 검사하고 문제가 없다면 데이터를 이용해서 응용 서비스에 기능 실행을 위임한다.
    • 사용자가 전송한 데이터를 응용 서비스가 요구하는 형식으로 변환해서 전달한다.
  • 응용 서비스
    • 도메인 모델을 이용해서 기능을 구현한다.
    • 기능 구현에 필요한 도메인 객체를 리포지터리에서 가져와 실행하거나 신규 도메인 객체를 생성해서 리포지터리에 저장한다.
    • 두 개 이상의 도메인 객체를 사용해서 구현하기도 한다.
    • 도메인의 상태를 변경하므로 변경 상태가 물리 저장소에 올바르게 반영되도록 트랙잭션을 관리해야 한다.

2.6 인프라스트럭처 개요

  • 인프라스트럭처는 표현 영역, 응용 영역, 도메인 영역을 지원한다.
    • 도메인 객체의 영속성 처리, 트랜잭션, SMTP 클라이언트, REST 클라이언트 등 다른 영역에서 필요로 하는 프레임워크, 구현 기술, 보조 기능을 지원한다.
  • DIP에서 언급한 것처럼 도메인 영역과 응용 영역에서 인프라스트럭처의 기능을 직접 사용하는 것보다는 이 두 영역에 정의한 인터페이스를 인프라스트럭처 영역에서 구현하는 것이 시스템을 더 유연하고 테스트하기 쉽게 만들어준다.
  • 하지만 무조건 인프라스트럭처에 대한 의존을 없앨 필요는 없다.
    • 예를 들어 스프링을 사용할 경우 응용 서비스는 트랜잭션 처리를 위해 스프링이 제공하는 @Transactional을 사용하는 것이 편리하다.
    • 영속성 처리를 위해 JPA를 사용할 경우 @Entity나 @Table과 같은 JPA 애너테이션을 도메인 모델 클래스에 사용하는 것이 XML 매핑 설정을 이용하는 것보다 편리하다.
  • 구현의 편리함은 DIP가 주는 다른 장점(변경의 유연함, 테스트가 쉬움)만큼 중요하므로 DIP의 장점을 해치지 않는 선에서 응용 영역과 도메인 영역에서 구현 기술에 대한 의존을 가져가는 것이 나쁘지 않다.
    • 응용 영역과 도메인 영역이 인프라스트럭처에 대한 의존을 완전히 갖지 않도록 시도하는 것은 자칫 구현을 더 복잡하고 어렵게 만들 수 있다.

2.7 모듈 구성

  • 아키텍처의 각 영역은 별도 패키지에 위치한다.
  • 패키지 구성 규칙에 정답이 존재하는 것은 아니지만 [그림 2.21]과 같이 영역별로 모듈이 위치할 패키지를 구성할 수 있을 것이다.

  • 도메인이 크면 [그림2.22]와 같이 하위 도메인으로 나누고 각 하위 도메인마다 별도 패키지를 구성한다.

  • 도메인 모듈은 도메인에 속한 애그리거트를 기준으로 다시 패키지를 구성한다.
    • 예를 들어 카탈로그 하위 도메인이 상품 애그리거트와 카테고리 애그리거트로 구성될 경우 [그림 2.23]과 같이 도메인을 두 개의 하위 패키지로 구성할 수 있다.

  • 애그리거트, 모델, 리포지터리는 같은 패키지에 위치시킨다.
    • 예를 들어 주문과 관련된 Order, OrderLine, Orderer, OrderRepository 등은 com.myshop.order.domain 패키지에 위치시킨다.
  • 도메인이 복잡하면 도메인 모델과 도메인 서비스를 다음과 같이 별도 패키지에 위치시킬 수도 있다.
    • com.myshop.order.domain.order: 애그리거트 위치
    • com.myshop.order.domain.service: 도메인 서비스 위치
  • 응용 서비스도 다음과 같이 도메인 별로 패키지를 구분할 수 있다.
    • com.myshop.catalog.domain.product
    • com.myshop.catalog.domain.category
  • 모듈 구조를 얼마나 세분화해야 하는지에 대해 정해진 규칙은 없다 한 패키지에 너무 많은 타입이 몰려서 코드를 찾을 때 불편한 정도만 아니면 된다.
 
 
  •  
반응형