독후감

[독후감] 도메인 주도 개발 시작하기 [챕터 01, 02] (1)

Choony 2023. 9. 22. 21:48

이 글은 챕터 02 (아키텍처 개요)를 중점으로 다룰 예정이며, 스프링부트와 관련되어 추가 설명을 할 것이다.

 

네 개의 영역

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

 

스프링부트에서 네 가지 영역에 대한 예시는 이렇다.

표현 영역 - Controller

응용 영역 - Service

도메인 영역 - Entity

인프라스트럭쳐 영역 - Repository

 

말로 표현해보자면

표현 영역 - 사용자의 요청을 받는 위치

응용 영역 - 시스템이 사용자에게 제공해야 할 기능을 구현하는 위치

도메인 영역 - 도메인 모델을 구현

인프라스트럭쳐 영역 - 구현 기술에 대한 것을 다룸 (ex. RDBMS 연동처리)

 

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

간단한 예시로는 Service 클래스에서 서비스로직을 구현할 때 Repository를 이용하는 것을 들 수 있다.

@Service
public class TmpService {
  private final TmpRepository tmpRepository;
  
  ...
  
  public User getUser(Long id) {
  	tmpRepository.findById(id)... // 생략
  }
}

 

계층 구조 아키텍처

앞서 말한 네 영역을 구성할 때 많이 사용하는 아키텍처는 아래의 그림과 같은 계층 구조이다.

출처 : https://dev-coco.tistory.com/166

도메인의 복잡도에 따라 응용과 도메인을 분리하기도 한 계층으로 합치기도 하지만 전체적인 아키텍처는 위의 계층 구조를 따른다.

 

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

표현 계층은 응용 계층에 의존하지만 응용 계층은 표현 계층에 의존하지 않는다는 말이다.

 

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

위의 그림을 보면 도메인과 응용 계층은 룰 엔진과 DB 연동을 위해 인프라스트럭처 모듈에 의존하게 되는 걸 볼 수 있다.

 

응용 영역과 도메인 영역은 DB나 외부 시스템 연동을 위해 인프라스트럭처의 기능을 사용하므로 위의 계층 구조를 사용하는 것은 직관적으로 이해하기 쉽다는 장점이 있다. 하지만 짚고 넘어가야 할 것은 바로 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라스트럭처 계층에 종속된다는 점이다.

 

인프라스트럭처에 의존하게 되면 '테스트 어려움'과 '기능 확장의 어려움'이라는 두 가지 문제가 발생할 가능성이 매우 높다.

1. 테스트 어려움

// 코드 1
@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public List<Product> getProductsByCategory(String category) {
        // 데이터베이스에 연결하여 데이터를 조회
        return productRepository.findByCategory(category);
    }

    // 다른 서비스 메서드들
}

위의 코드는 테스트를 실행할 때마다 데이터베이스에 연결을 계속해야 하는 단점이 있다.

 

2. 기능 확장의 어려움

@Service
public class PaymentService {
    public boolean processPayment(Order order) {
        // 외부 결제 게이트웨이 서비스 호출
        PaymentResult result = PaymentGatewayService.processPayment(order);
        return result.isSuccess();
    }
    
    // 다른 서비스 메서드들
}

위의 코드를 보면 PaymentService는 PaymentResult에 직접 의존적인 것을 볼 수 있다. 이때 기능이 추가되거나 변경되는 경우에 전체 코드를 수정해야 하는 불상사가 발생할 수 있다.

 

이는 어떻게 해결해야 할까?

 

D I P

DIP에 대해 다루기 전 고수준과 저수준 모듈에 대해서 설명하겠다.

 

고수준 모듈 - 의미 있는 단일 기능을 제공하는 모듈 (고객 정보를 구한다)

저수준 모듈 - 하위 기능을 실제로 구현한 것 (RDBMS에서 JPA로 구한다)

 

이때 고수준 모듈이 제대로 동작하기 위해선 저수준 모듈을 사용해야 한다. 그런데 고수준 모듈이 저수준 모듈을 사용하면 앞서 계층 구조 아키텍처에서 언급했던 두 가지 문제, '테스트 어려움'과 '구현 변경 어려움'에 직면하게 된다.

 

이것을 해결하기 위해 나온 것이 DIP(Dependency Inversion Principle)이다. 

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

 

이때, 고수준 모듈을 구현하려면 저수준 모듈을 사용해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존하도록 하려면 어떻게 해야 할까?

비밀은 추상화한 인터페이스에 있다.

 

말로만 설명하고 이해하기엔 어려움이 있다. 코드를 통해 이해하도록 해보자

출처 : https://kjgleh.github.io/ddd/2019/06/15/ddd_start_02.html

 

CalculateDiscountService 입장에서 봤을 때 룰 적용을 Drools로 구현했는지 자바로 직접 구현했는지는 전혀 중요하지 않다.

그저 '고객 정보와 구매 정보에 룰을 적용해서 할인 금액을 구한다'라는 것만 중요할 뿐이다. 이를 추상화한 인터페이스를 봐보자

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

 

이제 CalculateDiscountService가 RuleDiscounter를 이용하도록 바꿔보자.

<Before>

public class CalculateDiscountService {

  private DroolsRuleDiscounter droolsRuleDiscounter;

  public CalculateDiscountService() {
    droolsRuleDiscounter = new DroolsRuleDiscounter();
  }

  public Money calculateDiscountUsingDroolsDiscounter(List<OrderLine> orderLines,
      String customerId) {
    Customer customer = findCustomer(customerId);
    Money money = droolsRuleDiscounter.calc();
    return money;
  }
  ...
}

<After>

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

<After> 코드를 봐보자. CaculateDiscountService는 더 이상 Drools에 의존하는 코드가 없다. 단지 RuleDiscounter가 룰을 적용한다는 사실만 알뿐이다. 실제 RuleDiscounter의 구현 객체는 생성자를 통해서 전달받게 된다.

출처 : https://kjgleh.github.io/ddd/2019/06/15/ddd_start_02.html

DIP를 적용하게 되면 위의 그림처럼 저수준 모듈이 고수준 모듈에 의존하게 된다. 

 

다시 말하지만 이는 '테스트의 어려움'과 '구현 교체가 어렵다'의 해결책이다.

 

위에서 예시를 들었던 코드(코드 1)를 고쳐보자!

@Service
public class ProductService {
    private final ProductRepository productRepository;

    @Autowired
    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public List<Product> getProductsByCategory(String category) {
        // 추상화된 데이터 액세스 레이어를 통해 데이터를 조회
        return productRepository.findByCategory(category);
    }

    // 다른 서비스 메서드들
}

 

 

이렇게 코드를 짜게 되면 테스트시에 interface의 실제 구현 class가 없더라도 거의 문제없이 테스트를 수행할 수 있게 된다.

이렇게 실제 구현 없이 테스트를 할 수 있는 이유는 DIP를 적용해서 고수준 모듈이 저수준 모듈에 의존하지 않게 했기 때문에 가능한 것이다.

 

DIP 주의사항

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

DIP의 핵심은 고수준 모듈이 저수준 모듈에 의존하지 않도록 하기 위함인데 DIP를 적용한 결과 구조만 보고 아래 그림과 같이 저수준 모듈에서 인터페이스를 추출하는 경우가 있다.

이 구조에서 도메인 영역은 구현 기술을 다루는 인프라스트럭처 영역에 의존하고 있다. 이는 여전히 고수준 모듈(도메인)이 저수준 모듈(저수준)에 의존하고 있는 것이다. RuleEngine 인터페이스는 고수준 모듈인 도메인 관점이 아니라 저수준 모듈인 룰 엔진이라는 관점에서 도출한 것이다.

 

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

ClaculateDiscountService 입장에서 봤을 때 할인 금액을 구하기 위해 룰 엔진을 사용하는지 직접 연산하는지는 중요하지 않다고 했다.

단지 규칙에 따라 할인 금액을 계산한다는 것이 중요할 뿐이다. 즉 '할인 금액 계산'을 추상화한 인터페이스는 저수준 모듈이 아닌 고수준 모듈에 위치하게 되며 아래의 그림과 같이 변경시킬 수 있다.

 

DIP와 아키텍처

DIP를 적용하게 되면 저수준 모듈이 고수준 모듈에 의존하는 모습을 볼 수 있다.

이는 인프라스트럭처에 위치한 클래스가 도메인이나 응용 영역에 정의한 인터페이스를 상속받아 구현하는 구조가 되므로 도메인과 응용 영역에 대한 영향을 주지 않거나 최소화하면서 구현 기술을 변경하는 것이 가능하다.

이런 구조가 있다고 보자.

 

EmailNotifier 클래스는 Notifier 인터페이스를 상속받고 있다. 이때, 주문 시 통지 방식에 SMS를 추가해야 한다는 요구사항이 들어왔을 때 응용 영역의 OrderService를 변경할 필요가 없다. 단지 두 통지 방식을 함께 제공하는 Notifier 구현 클래스를 인프라스트럭처 영역에 추가하면 되기 때문이다.