본문 바로가기

Dev Book Review/DDD START!

[DDD START!] Chap2. 아키텍처 개요

네 개의 영역

아키텍처를 설계할 때 출현하는 전형적인 영역은 표현, 응용, 도메인, 인프라스트럭처의 네 영역이다.

표현 영역

표현 영역은 사용자의 요청을 받아 응용 영역에 전달하고 응용 영역의 처리 결과를 다시 사용자에게 보여주는 역할을 한다.

웹 애플리케이션에서 표현 영역은 HTTP 요청을 응용 영역이 필요로 하는 형식으로 변환해서 응용 영역에 전달하고, 응용 영역의 응답을 HTTP 응답으로 변환해서 전송한다.

 

응용 영역/도메인 영역

표현 영역을 통해 사용자의 요청을 전달 받는 응용 영역은 시스템이 사용자에게 제공해야 할 기능을 구현한다.

응용 영역은 기능을 구현하기 위해 도메인 영역의 도메인 모델을 사용한다.

public class CancelOrderService() {
	
    @Transactional
    public void cancelOrder(String orderId) {
    	Order order = findOrderById(orderId);
        if (order == null) throw new OrderNotFoundException(orderId);
        order.cancel();
	}
}

예를 들어 위 코드에서 주문 취소 기능을 제공하는 응용 서비스는 주문 도메인 모델을 사용해서 기능을 구현했다.

한마디로 응용 서비스는 로직을 직접 수행하기보다는 도메인 모델에 로직 수행을 위임한다.

인프라스트럭처 영역

마지막으로 인프라스트럭처 영역은 논리적인 개념을 표현하기보다 실제 구현 기술에 대한 것을 다룬다.

 

RDBMS 연동을 처리하고, 메시징 큐에 메시지를 전송하거나 수신하는 기능을 구현하고, 몽고DB 또는 HBase를 사용해서 데이터베이스 연동을 처리한다. 또한 SMTP를 이용한 메일 발송 기능을 구현하거나 HTTP 클라이언트를 이용해서 REST API를 호출하는 것도 처리한다.

계층 구조 아키텍처

앞서 설명한 네 영역을 구성할 때 많이 사용하는 아키텍처는 다음과 같은 계층 구조이다.

계층 구조는 상위 계층에서 하위 계층으로의 의존만 존재하고 하위 계층은 상위 계층에 의존하지 않는다.

 

물론 구현의 편리함을 위해 계층 구조를 다음과 같이 유연하게 적용할 수 있다.

 

하지만 이렇게 했을 때 표현/응용/도메인 계층이 상세한 구현 기술을 다루는 인프라스트럭처 계층에 종속된다.

따라서 1) 테스트가 어렵다 2) 구현 방식을 변경하기 어렵다 라는 단점을 가지게 된다.

 

예를 들어 특정 클래스가 의존하고 있는 클래스를 먼저 구현하지 않으면 해당 클래스를 테스트 할 수 없다.

또한 구현 방식을 변경하기 위해서는 해당 코드의 많은 부분을 수정해야 한다.

 

이러한 문제를 해결하기 위해서는 DIP를 적용하면 된다.

DIP

다음과 같은 구조에서

CalculateDiscountService는 가격 할인 계산을 위한 고수준 모듈이다.

 

가격 할인 계산을 위해서는 여러 하위 기능이 필요하다.

위 예시에서는 1) 고객 정보를 구한다 2) 룰을 이용해서 할인 금액을 구한다  이다.

이때 하위 기능을 실제로 구현한 모듈이 저수준 모듈이 된다.

 

따라서 자연스럽게 고수준 모듈이 제대로 동작하기 위해서는 저수준 모듈을 사용해야 한다.

하지만 이렇게 되면 앞서 언급했던 2가지 문제가 발생한다.

 

따라서 DIP를 사용하여 저수준 모듈이 고수준 모듈을 의존하도록 바꿔야 한다. (의존 방향을 뒤집는다)

또한 이 방법은 추상화한 인터페이스!를 통해 구현한다.

 

public interface RuleDiscounter {
	public Money appliyRules(Customer customer, List<OrderLine> orderLines);
}
public class DroolsRuleDiscounter implements RuleDiscounter {
	private KieContainer kContainer;
    
	@Override
	public void applyRules(Customer customer, List<OrderLine> orderLines) {
		...
	}
}
public class CalculateDiscountService {
	private RuleDiscounter ruleDiscounter;
    
    public CalculateDiscountService(RuleDiscounter ruleDiscounter) {
    	this.ruleDiscounter = ruleDiscounter;
	}
    
    public Money calculateDiscount(OrderLine orderLines, String customerId) {
    	Customer customer = findCustomer(customerId);
        return ruleDiscounter.applyRules(customer, orderLines); // 여기!
	}
    ...
}

CalculateDiscountService는 Drools에 의존하는 코드를 전혀 포함하지 않는다.

단순히 RuleDiscounter를 통해 룰을 적용하여 할인 금액을 구한다는 사실만 알고 있다. 해당 구현 객체는 생성자로 전달 받는다.

 

이때 실제 룰 적용을 구현한 클래스는 RuleDiscounter 인터페이스를 구현하게 된다.

DIP 적용

이제 구조는 다음과 같다.

CalculateDiscountService는 더 이상 구현 기술(Drools)에 의존하지 않는다.

 

'룰을 이용해서 할인 금액을 구한다' 라는 하위 기능을 추상화한 RuleDiscounter 인터페이스를 의존한다.

또한 '룰을 이용한 할인 금액 계산'이라는 개념은 고수준 모듈의 개념이다. 해당 개념을 구현한 클래스는 저수준 모듈이다.

따라서 드디어 저수준 모듈이 고수준 모듈을 의존하게 되는 것이다. (상속은 의존의 다른 형태)

 

이제 앞에서 언급했던 2가지 문제를 모두 해결할 수 있게 되었다. (테스트, 구현 방식 교체)

해당 클래스만 테스트하고 싶다면 Mock과 같은 대용 객체를 사용할 수 있다. (인터페이스이기 때문)

또한 구현 방식을 교체하고 싶다면 저수준 구현 객체를 생성하는 부분의 코드만 변경하면 된다. (고수준 모듈은 수정할 필요가 없다)

DIP 주의사항

DIP를 구현한다는 걸, 단순하게 인터페이스와 구현 클래스를 분리하는 정도로 받아들이면 안된다.

핵심은 고수준 모듈이 저수준 모듈을 직접 의존하지 않도록 하는 것이다.

 

잘못된 DIP 적용

이러한 구조는 DIP를 잘못 적용한 예시이다.

도메인 영역은 구현 기술을 다루는 인프라스트럭처 영역에 의존하고 있다.

결과적으로 여전히 고수준 모듈이 저수준 모듈을 의존하고 있는 것이다.

 

DIP를 적용할 때 하위 기능을 추상화한 인터페이스는, 고수준 모듈 관점에서 도출해야 한다.

CalculateDiscountService 입장에서 할인 금액을 구하기 위해 어떤 방법(구현)을 하는지는 중요하지 않다.

DIP와 아키텍처

앞서 적용하기로 했던 계층형 구조에서는 인프라스트럭처 계층이 제일 하단에 위치했다.

그런데 DIP를 적용해버리면 의존 방향이 거꾸로 흐른다. 즉, 인프라스트럭처 영역이 응용/도메인 영역을 의존하게 된다.

응용/도메인 영역에 정의한 인터페이스를, 인프라스트럭처 영역에 위치한 클래스가 상속 받아 구현한다.

따라서 응용/도메인 영역에 영향을 주지 않고 (또는 최소화하며) 구현 기술을 교체할 수 있다.

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

도메인의 핵심 모델은 도메인 영역에서 구현한다. 도메인 영역을 구성하는 요소는 다음과 같다.

요소 설명
엔티티 도메인 고유의 개념을 표현한다. 도메인 모델의 데이터를 포함하며 해당 데이터와 관련된 기능을 함께 제공한다.
밸류 주로 개념적으로 하나인 도메인 객체의 속성을 표현할 때 사용한다.
애그리거트 관련된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다.
리포지터리 도메인 모델의 영속성을 처리한다.
도메인 서비스 특정 엔티티에 속하지 않은 도메인 로직을 제공한다. 예를 들어 '할인 금액 계산'은 상품/쿠폰/회원 등급/구매 금액 등 다양한 조건을 이용해서 구현하게 되는데, 이렇게 도메인 로직이 여러 엔티티와 밸류를 필요로 하는 경우 도메인 서비스에서 로직을 구현한다.

1. 엔티티와 밸류

도메인 모델의 엔티티는 데이터와 함께 도메인 기능을 함께 제공한다.

따라서 도메인 모델의 엔티티는 단순히 데이터를 담고 있는 구조라기보다 데이터와 함께 기능을 제공하는 객체이다.

도메인 관점에서 기능을 구현하고, 기능 구현을 캡슐화해서 데이터가 임의로 변경되는 걸 막는다.

 

또한 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표현할 수 있다.

이때 밸류는 불변으로 구현하는 걸 권장한다. (밸류 타입 데이터를 변경할 때 새로운 객체를 할당)

2. 애그리거트

애그리거트는 관련 객체를 하나로 묶은 군집이다. 예를 들어 주문/배송지 정보/주문자/주문 목록/총 결제 금액으로 이루어진 하위 모델들은 '주문'이라는 상위 개념으로 표현할 수 있다.

 

이렇게 관련된 객체를 애그리거트로 묶으면 복잡한 도메인 모델을 관리하는 데 도움이 된다.

 

애그리거트는 군집에 속한 객체들을 관리하는 루트 엔티티를 가진다.

루트 엔티티는 애그리거트에 속해 있는 엔티티와 밸류 객체를 이용해서 애그리거트가 구현해야 할 기능을 제공한다.

따라서 애그리거트를 사용하는 코드는 애그리거트 루트가 제공하는 기능을 실행하고, 간접적으로 애그리거트 내의 다른 엔티티나 밸류 객체에 접근하게 된다. 결과적으로 애그리거트의 내부 구현을 숨겨서 애그리거트 단위로 캡슐화할 수 있도록 도와준다.

3. 리포지터리

리포지터리는 구현을 위한 도메인 모델이다. 애그리거트 단위로 객체를 저장하고 조회하는 기능을 정의한다.

public class cancelOrderService {
	private OrderRepository orderRepository;
    
    public void cancel(OrderNumber number) {
    	Order order = orderRepository.findByNumber(number);
        if (order == null ) throw new NoOrderException(number);
        order.cancel();
	}
}

도메인 모델을 사용해야 하는 코드는 리포지터리를 통해서 도메인 객체를 구한 뒤에 도메인 객체의 기능을 실행한다.

전체 구조는 다음과 같다.

이때 도메인 모델 관점에서 보면 OrderRepository는 도메인 모델을 영속화하기 위한 기능을 추상화한 고수준 모듈이다.

JPA와 같은 기술을 이용해서 OrderRepository를 구현한 클래스는 저수준 모듈로 인프라스트럭처 영역에 속한다.

 

자연스럽게 리포지터리는 응용 서비스와 밀접한 연관이 있다.

  • 응용 서비스는 필요한 도메인 객체를 구하거나 저장할 때 리포지터리를 사용한다.
  • 응용 서비스는 트랜잭션을 관리하는데, 트랜잭션 처리는 리포지터리 구현 기술에 영향을 받는다.

요청 처리 흐름

 

인프라스트럭처 개요

인프라스트럭처는 표현/응용/도메인 영역을 지원한다.

예를 들어 도메인 객체의 영속성 처리, 트랜잭션, SMTP 클라이언트, REST 클라이언트 등 다른 영역에서 필요로 하는 프레임워크, 구현 기술, 보조 기능을 지원하는 역할을 한다.

 

DIP에서 언급한 것처럼 응용/도메인 영역에서 인프라스트럭처의 기능을 직접 의존하지 않는 게 유연한 설계를 돕는다.

하지만 무조건적으로 인프라스트럭처에 대한 의존을 없애는 게 좋은 것만은 아니다. 예를 들어, 스프링을 사용할 경우 응용 서비스는 트랜잭션 처리를 위해 스프링이 제공하는 @Transactional을 사용하는 게 편리하다. 영속성 처리를 위해 JPA를 사용할 경우는 @Entity나 @Table과 같은 JPA 전용 어노테이션을 도메인 모델 클래스에 사용하는 것이 편리하다.

 

따라서 DIP의 장점을 해치지 않는 범위에서 적절히 의존 기술에 대한 구현을 가짐으로써 구현의 편리함을 누리면 된다.

모듈 구성



패키지 구성 규칙에는 정답이 없다.

위 그림에서는 도메인을 여러 개의 하위 도메인으로 나눠서 패키지를 구성했다.

또한 domain 모듈은 도메인에 속한 애그리거트를 기준으로 다시 패키지를 구성할 수 있다.

카탈로그 하위 도메인을 위한 도메인은 상품과 카테고리로 구성된다. 따라서 domian을 두 개의 하위 패키지로 나눴다.

 

애그리거트와 모델과 리포지터리는 같은 패키지에 위치시킨다.

예를 들어 주문과 관련된 Order, OrderLine, Orderer, OrderRepository는 com.myshop.order.domain 패키지에 위치시킨다.

 

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

  • com.myshop.order.domain.order: 애그리거트 위치
  • com.myshop.order.domain.service: 도메인 서비스 위치

응용 서비스 또한 도메인 별로 패키지를 구분할 수 있다.

 

모듈 구조를 얼마나 세분화해야 하는지에 대한 정해진 규칙은 없다. 단지, 한 패키지에 너무 많은 타입이 몰려서 불편한 정도만 아니면 된다. 저자는 개인적으로 한 패키지에 가능하면 10개 미만으로 타입 개수를 유지하려고 노력하는 편이라고 한다.

'Dev Book Review > DDD START!' 카테고리의 다른 글

[DDD START!] Chap3. 애그리거트  (0) 2021.10.25
[DDD START!] Chap1. 도메인 모델 시작  (0) 2021.10.20