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

겔로그·2023년 9월 29일
0

1. 네 개의 영역

웹 애플리케이션의 표현 영역

  • HTTP 요청을 응용 영역이 필요로 하는 형식으로 변환해 전달
  • 응용 영역의 응답을 HTTP 응답으로 변환하여 전송

웹 애플리케이션의 응용 영역

  • 표현 영역을 통해 사용자의 요청을 전달받아 시스템이 사용자에게 제공해야 할 기능을 구현
  • 기능을 구현하기 위해 도메인 영역의 도메인 모델을 사용
  • 로직을 직접 수행하기보다는 도메인 모델에 로직 수행을 위임

웹 애플리케이션의 도메인 영역

  • 도메인 영역은 도메인 모델을 구현
  • 도메인의 핵심 로직을 구현

웹 애플리케이션의 인프라스트럭처 영역

  • 구현 기술에 대한 것을 다루는 영역
  • RDBMS,SMTP 서버, 카프카 연동 등 논리적인 개념을 다루는 것이 아닌 실제 구현을 다룸

도메인 영역, 응용 영역, 표현 영역은 구현 기술을 사용한 코드를 직접 만들지 않습니다.
대신 인프라스트럭처 영역에서 제공하는 기능을 사용해 필요한 기능을 개발합니다.

예시

  • Email 발송
    • SMTP 연동 모듈을 이용해 메일 발송
  • DB를 이용한 시스템 개발
    • DBMS를 이용한 CRUD
  • API 호출
    • REST Client 사용을 통한 API 호출

2. 계층 구조 아키텍처

계층 구조 아키텍처의 네 영역을 구성할 때 아래 그림과 같은 계층 구조를 따르는 경우가 많습니다.

도메인의 복잡도에 따라 응용과 도메인 영역을 분리하기도, 통합하기도 하지만 전체적인 아키텍처는 위의 계층 구조를 따릅니다.

계층 구조는 특성상 상위 계층에서 하위 계층으로의 의존만 존재합니다. 하위 계층에서 상위 계층을 의존하지는 않습니다.

계층간 의존 관계

엄격하게 적용한다면 상위 계층은 바로 아래의 하위 계층에만 의존을 가져야 합니다. 하지만 구현의 편리함을 위해 계층 구조를 유연하게 적용하기도 합니다.

응용 계층과 도메인 계층은 상세한 구현 기술을 다루는 인프라스트럭처 계층에 종속된다는 특징을 가지고 있습니다.

문제점

앞서 계층 구조 아키텍쳐의 계층 의존 관계에서는 응용 계층과 도메인 계층은 상세한 구현 기술을 다루는 인프라스트럭처 계층에 종속된다는 특징을 가지고 있다는 이야기를 하였습니다.

하지만 이러한 특징으로 인해 발생할 수 있는 문제점이 존재하는데 아래 예제를 보며 문제점을 확인해 봅시다.

DroolsRuleEngine.java (인프라스트럭처 계층)

public class DroolsRuleEngine {
	private KieContainer kContainer;
    
    public DroolsRuleEngine() { 
    	KieServices ks = KieServices.Factory.get();
        kContainer = ks.getKieClasspathContainer();
    }
    
    public void evaluate(String sessionName, List<?> facts) {
    	KieSession kSession = kContainer.newKieSession(sessionName);
        try {
        	facts.forEach(x -> kSession.insert(x));
            kSession.fireAllRules();
        } finally {
        	kSession.dispose();
        }
    }
}

CalculateDiscountService.java (응용 계층)

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.evaluate("discountCalculation", facts);
        return money.toImmutableMoney();
    }
}

CalculateDiscountService의 calculateDiscount 메소드를 볼 경우 응용 계층에서 인프라스트럭처 계층의 DroolsRuleEngine을 이용하기 위해 일부 로직을 구현한 것을 볼 수 있습니다.

이것만으로 의존하지 않는다고 생각할 수 있지만 discountCalculation는 DroolsRuleEngine의 session 이름을 의미하며 session 이름이 변경될 경우 응용 계층의 코드 또한 변경해야 하는 간접 의존이 발생합니다.

결과적으로는 CalculateDiscountService은 DroolsRuleEngine에 의존하지 않는 것처럼 보이지만 실제로는 완전하게 의존하고 있는 문제가 존재하게 됩니다.

이럴 경우 '테스트 어려움'과 '기능 확장의 어려움'이라는 두 가지 문제가 발생하는데 이러한 문제를 어떻게 해야 해결할 수 있을까요?

3. DIP (Dependency Injection Principle)

앞서 보여준 코드에서 CalculateDiscountService의 calculateDiscount 메소드는 다음과 같은 순서로 동작합니다.

  1. 고객 정보를 구한다
  2. 룰을 이용해서 할인 금액을 구한다.

여기서 CalculateDiscountService은 '가격 할인 계산'이라는 의미 있는 단일 기능을 제공하는 모듈로 고수준 모듈이라고 부릅니다.

해당 기능을 구현하기 위해서 고객 정보를 구하고 Drools로 룰을 적용하는 기능이 필요한데 이를 저수준 모듈이라 부릅니다.

고수준 모듈은 기능을 구현하기 위해 저수준 모듈 여러 개를 사용해야 합니다. 하지만 고수준 모듈이 저수준 모듈을 사용할 경우 앞서 언급한 문제인 '테스트 어려움'과 '기능 확장의 어려움' 이 발생합니다.

이러한 문제를 해결하기 위해 DIP(Dependency Injection Principle)라는 의존성 주입 원칙을 사용합니다. CalculateDiscountService은 사실 '가격 할인 계산'을 하는 모듈만 구현이 된다면 Drools를 사용할지 말지는 중요하지 않습니다.

고객 정보와 구매 정보에 룰을 적용해 할인 금액을 구한다 라는 것만 중요하죠. DIP를 통해 이러한 저수준 모듈에서 해야할 기능들을 추상화된 인터페이스를 만들어 관리합니다.

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

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);
    }
} 
public class DroolsRuleEngine implements RuleDiscounter{
	private KieContainer kContainer;
    
    public DroolsRuleEngine() { 
    	KieServices ks = KieServices.Factory.get();
        kContainer = ks.getKieClasspathContainer();
    }
    
    @Override
    public void applyRules(Customer customer, List<OrderLine> orderLines) {
    	KieSession kSession = kContainer.newKieSession("discountCalculation");
        try {
        	.. 코드 생략
            kSession.fireAllRules();
        } finally {
        	kSession.dispose();
        }
        return money.toImmutableMoney();
    }
}

다음과 같이 RuleDiscounter 인터페이스를 구현하여 DroolsRuleEngine이 구현체가 될 경우 다음과 같은 구조가 됩니다.

CalculateDiscountService는 DroolsRuleEngine에 의존하지 않으며 룰을 이용한 할인 금액 계산을 추상화한 RuleDiscounter 인터페이스에 의존합니다.

DIP를 적용하여 저수준 모듈이 고수준 모듈에 의존하게 되는 구조로 변경하였습니다. 이렇게 고수준 모듈이 저수준 모듈을 사용하려면 고수준 모듈이 저수준 모듈에 의존해야 하는데 반대로 저수준 모듈이 고수준 모듈에 의존한다하여 의존 역전 원칙, DIP(Dependency Injection Principle) 라고 부릅니다.

이를 통해 할인 금액 계산에 대한 구현 정보를 변경하더라도 CalculateDiscountService는 인터페이스를 의존하고 있기 때문에 별도의 수정이 필요하지 않는 이점이 존재합니다.

DIP 주의사항

DIP를 잘못 생각하면 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들일 수 있습니다.
DIP의 핵심은 고수준 모듈이 저수준 모듈에 의존하지 않도록 하기 위함인데 DIP를 적용한 결과 구조만 보고 아래와 같이 저수준 모듈에서 인터페이스를 추출하는 경우가 있습니다.

  • 저수준 모듈 관점에서가 아닌 고수준 모듈 관점에서의 인터페이스 도출이 DIP 적용의 핵심이니 주의해야 합니다.

DIP와 아키텍처

따라서 DIP를 적용한 계층 구조의 아키텍처에서는 인터페이스 활용으로 인해 도메인과 응용 계층에 영향을 주지 않는 구조로 구현 기술을 변경할 때 영향을 주지 않거나 최소화 할 수 있습니다.

아래 구조에서 SMS 알림과 JPA로 변경하고 싶을 경우

EmailNotifier에서 CompositeNotifier로 변경되었으나 Notifier 인터페이스를 구현하고 있는 클래스이기 때문에 OrderService에서는 코드 변경 없이 변경된 CompositeNotifier를 사용할 수 있습니다.

또한 MyBatisOrderRepository를 JPAOrderRepository로 변경하였으나 OrderRepository를 구현하고 있기 때문에 실제로 응용계층의 OrderService에서는 변경에 영향을 받지 않습니다.

잠깐!

DIP를 적용하면 저수준 모듈과 고수준 모듈의 의존 관계가 역전되기에 인터페이스 도입이 무조건 좋게 보일 수 있습니다. 하지만 사용하는 구현 기술에 따라 DIP를 도입하기 어려운 케이스가 존재할 수 있습니다.

  • 추상화 대상이 명확하지 않은 경우
  • 구현 기술에 의존적인 코드를 도메인에 일부 포함하는게 효과적인 경우

다음과 같은 경우에는 DIP를 무조건 적용한다고 추상화를 고려하는 것보다 DIP를 적용해서 얻는 이점이 유지하는 이점보다 큰지 한 번 더 생각해보고 적용하는 것이 좋습니다.

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

엔티티와 밸류

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

도메인 관점에서 기능을 구현하고 기능 구현을 캡슐화해 데이터가 임의로 변경되는 것을 방지합니다. 또한 도메인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해 표현할 수 있습니다.

애그리거트

도메인이 커질수록 개발할 도메인 도메인도 커지면서 많은 에티티와 밸류가 출현합니다. 이 경우, 모델이 점점 복잡해지는 문제가 발생합니다.엔티티와 밸류에만 집중할 경우 큰 틀에서 모델을 관리할 수 없는 상황에 빠질 수 있습니다.

애그리거트는 관련 객체를 하나로 묶은 군집을 의미합니다. 하위 개념의 모델을 하나로 묶어 상위 개념으로 표현하는 것을 의미합니다.

애그리거트는 군집에 속한 객체를 관리하는 루트 엔티티를 가지며 루트 엔티티는 애그리거트에 속해 있는 엔티티와 밸류 객체를 이용해 애그리거트가 구현해야 할 기능을 제공합니다.

따라서 기능 제공은 애그리거트의 루트 엔티티를 통해 제공되며 루트 엔티티는 애그리거트 내 다른 엔티티와 밸류 객체에 접근하여 기능 제공을 합니다. 이를 통해 애그리거트 내부 구현을 숨겨 캡슐화할 수 있습니다.

리포지토리

도메인 모델의 영속성을 처리하는 부분이며 인프라스트럭처 계층에 해당합니다.DB에서는 CRUD를 담당하는 클래스라고 생각하시면 편할 것 같습니다.

5. 모듈 구성

아래와 같이 계층 구조에 따라 패키지를 구성해 관리할 수 있습니다.

만약 도메인이 클 경우, 각 계층별로 많은 패키지가 생성될 수 있기 때문에 다음과 같이 하위 도메인 기준으로 패키지를 분리할 수도 있습니다.

도메인 모듈은 도메인에 속한 애그리거트를 기준으로 다시 패키지를 구성합니다. 예를 들어 catalog 하위 도메인이 상품 애그리거트와 카테고리 애그리거트로 구성될 경우 아래와 같이 두 개의 하위 패키지로 구성합니다.

모듈 구조를 얼마나 세분화해야 하는지에 대해서는 정해진 규칙은 없습니다. 구현하려는 시스템의 규모에 따라 이 구조는 유동적으로 변경될 것이며 개발자의 관점에서 특정 클래스를 찾을 때 많은 타입이 있어 불편하지 않을 정도이면 됩니다.

profile
Gelog 나쁜 것만 드려요~

1개의 댓글

comment-user-thumbnail
2024년 5월 26일

글 잘 읽었습니다. 글 중간에 DIP가 Dependency Injection Principle로 적어주셧는데, 본문 상에서 다루는 DIP는 Dependency Injectino Principle으로 적는 것이 조금 더 문맥상 맞지 않을까 생각이 듭니다.
혹시 어떻게 생각하시나요?

답글 달기