[Domain Driven Design] 5. DIP(Dependency Inversion Principle, 의존 역전 원칙)

반응형
728x90
반응형

인프라스트럭처 의존성 문제점 

표현 계층, 응용 계층, 도메인 계층이 상세한 구현 기술을 다루는 인프라스트럭처 계층에 의존성이 존재하는 경우의 문제점

https://devfunny.tistory.com/872

 

[Domain Driven Design] 4. 계층 구조 아키텍처 (표현, 응용, 도메인, 인프라스트럭처)

네 개의 영역 '표현', '응용', '도메인', '인프라스트럭쳐' 는 아키텍처를 설계할 때 출현하는 전형적인 네가지 영역이다. 표현 영역(또는 UI 영역)은 사용자의 요청을 받아 응용 영역에 전달하고

devfunny.tistory.com

 

 

문제 상황

가격 할인 계산을 하려면 고객 정보를 구해야하고, 구한 고객 정보와 주문 정보를 이용해서 룰을 실행해야한다.

https://yongdev.tistory.com/151

여기서 CalculateDiscountService는 고수준 모듈이다. 고수준 모듈은 의미 있는 단일 기능을 제공하는 모듈로 CalculateDiscountService는 '가격 할인 계산'이라는 기능을 구현한다. 고수준 모듈의 기능을 구현하려면 여러 하위 기능이 필요하다. 가격 할인 계산 기능을 구현하려면 고객 정보를 구해야하고 룰을 실행해야 하는데 이 두 기능은 하위 기능이다. 저수준 모듈은 하위 기능을 실제로 구현한 것이다. 

JPA를 이용해서 고객 정보를 읽어오는 모듈과 Drools로 룰을 실행하는 모듈이 저수준 모듈이 된다.

 

고수준 모듈이 제대로 동작하려면 저수준 모듈을 사용해야 한다. 그런데 고수준 모듈이 저수준 모듈을 사용하면, 구현 변경과 테스트가 어렵다는 문제가 발생한다. 

 

 

해결방안 - DIP(Dependency Inversion Principle, 의존 역전 원칙)

DIP는 이 문제를 해결하기 위해 저수준 모듈이 고수준 모듈에 의존하도록 바꾼다.

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

추상화한 인터페이스

CalculateDiscountService 입장에서 봤을때 룰 적용을 Drools로 구현했는지 자바로 직접 구현했는지는 중요하지 않다. "고객 정보와 구매 정보에 룰을 적용해서 할인 금액을 구한다"라는 것만 중요할 뿐이다.

 

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

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

public class CalculateDiscountService {
	private DroolsRuleEngine ruleEngine;
    
    public CalculateDiscountService() {
        ruleEngine = new DroolsRuleEngine();
    }

    public Money calculateDiscount(OrderLine orderLines, String customerId) {
        Customer customer = findCusotmer(customerId);

//        MutableMoney money = new MutableMoney(0);
//        List<?> facts = Arrays.asList(customer, money);
//        facts.addAll(orderLines);
//        ruleEngine.evalute("discountCalculation", facts);
//        return money.toImmutableMoney();

        return ruleDiscounter.applyRules(customer, orderLines);
    }
    ...
}

이제 CalculateDiscountService에는 Drools에 의존하는 코드가 없다. 단지 RuleDiscounter가 룰을 적용한다는 사실만 알 뿐이다.

실제 RuleDiscounter의 구현 객체는 생성자를 통해서 전달받는다.

 

룰 적용을 구현한 클래스는 RuleDiscounter 인터페이스를 상속받아 구현한다. 다시 말하지만 Drools 관련 코드를 이해할 필요는 없다. 여기서 중요한건 RuleDiscount를 상속받아 구현한다는 것이다.

 

DroolsRuleDiscounter.java
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.dispose();
        }
        
        return money.toImmutableMoney();
    }
    ...
}

RuleDiscounter 인터페이스가 출현하면서 바뀐 구조를 보여주고 있다.

https://heeveloper.github.io/2020/07/02/02-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EA%B0%9C%EC%9A%94/

 

CalculateDiscountService는 더이상 구현 기술인 Drools에 의존하지 않는다. '룰을 이용한 할인 금액 계산'을 추상화한 RuleDiscounter 인터페이스에 의존할 뿐이다.

 

▶ 고수준 모듈 : RuleDiscounter 인터페이스

'룰을 이용한 할인 금액 계산'은 고수준 모듈의 개념이므로 RuleDiscounter 인터페이스는 고수준 모듈에 속한다.

 

 저수준 모듈 :  DroolsRuleDiscounter

DroolsRuleDiscounter는 고수준의 하위 기능인 RuleDiscounter를 구현한 것이므로 저수준 모듈에 속한다. 

 

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

고수준 모듈이 저수준 모듈을 사용하려면 고수준 모듈이 저수준 모듈에 의존해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존한다고 해서 이를 DIP(Dependency Inversion Principle, 의존 역전 원칙)이라고 부른다.

 

 

구현 기술 교체 문제

고수준 모듈은 더이상 저수준 모듈에 의존하지 않고 구현을 추상화한 인터페이스에 의존한다. 실제 사용할 저수준 구현 객체는 다음 코드처럼 의존 주입을 이용해서 전달받을 수 있다.

https://heeveloper.github.io/2020/07/02/02-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EA%B0%9C%EC%9A%94/

// 사용할 저수준 객체 생성
RuleDiscounter ruleDiscounter = new DroolsRuleDiscounter();

// 생성자 방식으로 주입
CalculateDiscountService disService = new CalculateDiscountService(ruleDiscounter);

 

구현 기술을 변경하더라도 CalculateDiscountService를 수정할 필요가 없다. 

다음처럼 사용할 저수준 구현 객체를 생성하는 코드만 변경하면 된다.

// 사용할 저수준 객체 생성
RuleDiscounter ruleDiscounter = new SimpleRuleDiscounter();

// 생성자 방식으로 주입
CalculateDiscountService disService = new CalculateDiscountService(ruleDiscounter);

 

CalculateDiscountService가 제대로 동작하려면 Customer를 찾는 기능도 구현해야한다. 이를 위한 고수준 인터페이스를 CustomerRepository라고 하자. CalculateDiscountService는 다음과 같이 두 인터페이스인 CustomerRepository와 RuleDiscounter를 사용해서 기능을 구현한다.

public class CalculateDiscountService {
    private CustomerRepository customerRepository;
    private RuleDiscounter ruleDiscounter;

    public CalculateDiscountService(RuleDiscounter ruleDiscounter) {
    	customerRepository = new CustomerRepository();
    	this.ruleDiscounter = ruleDiscounter;
    }

    public Money calculateDiscount(List<OrderLine> orderLines, String customerId) {
    	Customer customer = findCustomer(customerId);
    	return ruleDiscounter.applyRules(customer, orderLines);
    }

    private Customer findCustomer(String customerId) {
    	Customer customer = customerRepository.findById(customerId);
    	if (customer == null) {
            throw new NoCustomerException();
    	}

    	return customer;
    }
  
	...
}

CalculateDiscountService가 제대로 동작하는지 테스트하려면 CustomerRepository와 RuleDiscounter를 구현한 객체가 필요하다.  만약 CalculateDiscountService가 저수준 모듈에 직접 의존했다면 저수준 모듈이 만들어지기 전까지 테스트를 할 수 없었겠지만 CustomerRepository와 RuleDiscount는 인터페이스이므로 대역 객체를 사용해서 테스트를 진행할 수 있다. 

 

다음은 대역 객체를 사용해서 Customer가 존재하지 않는 경우 익셉션이 발생하는지 검증하는 테스트 코드의 예를 보여주고 있다.

@Test
public void noCustomer_thenExceptionShouldBeThrown() {
    // 테스트 목적의 대역 객체 
    CustomerRepository stubRepo = mock(CustomerRepository.class);
    when(stubRepo.findById("noCustId")).thenReturn(null);

    RuleDiscounter stubRule = (cust, lines) -> null;

    // 대용 객체를 주입 받아 테스트 진행
    CalculateDiscountService calDivSvc = new CalculateDiscountService(stubRepo, stubRule);
    assertThrows(NoCustomerException.class, 
            () -> calDisSvc.calculateDiscouint(someLines, "noCustId"));
}

1) stubRepo : CustomerRepository, stubRule : RuleDiscounter

stubRepo는 Mockito라는 Mock 프레임워크를 이용해서 대역 객체를 생성했고 stubRule은 메서드가 한개여서 람다식을 이용해 객체를 생성했다.

CustomerRepository stubRepo = mock(CustomerRepository.class);
RuleDiscounter stubRule = (cust, lines) -> null;

 

2) stubRepo의 경우 findById("noCustId")를 실행하면 null을 리턴한다.

CalculateDiscountService cs = new CalculateDiscountService(stubRepo, stubRule);

calDisSvc를 생성할때 생성자로 stubRepo를 주입했다. 

calDisSvc.calculateDiscount(someLines, "noCustId") 코드를 실행하면 customerRepository.findById(customerId) 코드는 null을 리턴하고 결과적으로 NoCustomerException을 발생시킨다.

 

3) 위 테스트 코드는 CustomerRepository, RuleDiscounter의 실제 구현 클래스가 없어도 CalculateDiscountService를 테스트할 수 있음을 보여준다. 

 

이렇게 실제 구현 없이 테스트를 할 수 있는 이유는 DIP를 적용해서 고수준 모듈이 저수준 모듈에 의존하지 않도록 했기 때문이다. 고수준 모듈인 CalculateDiscountService는 저수준 모듈에 직접 의존하지 않기 때문에 RDBMS를 이용한 CustomerRepository 구현 클래스와 Drools를 이용한 RuleDiscounter 구현 클래스가 없어도 테스트 대역을 이용해 거의 모든 기능을 테스트할 수 있는 것이다.

 

 

DIP 주의사항

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

 

https://minkukjo.github.io/dev/2020/11/02/DDD-02/

위 그림은 잘못된 구조이다.

이 구조에서 도메인 영역은 구현 기술을 다루는 인프라스트럭처 영역에 의존하고 있다. 여전히 고수준 모듈이 저수준 모듈에 의존하고 있는 것이다. 

RuleEngine 인터페이스는 고수준 모듈인 도메인 관점이 아닌, 룰 엔진이라는 저수준 모듈 관점에서 도출한 것이다.

 

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

CalculateDiscountService 입장에서 봤을때 할인 금액을 구하기 위해 룰 엔진을 사용하는지 직접 연산하는지는 중요하지 않다. 단지 규칙에 따라 할인 금액을 계산한다는 것이 중요할 뿐이다. 

즉, "할인 금액 계산"을 추상화한 인터페이스는 저수준 모듈이 아닌 고수준 모듈에 위치해야한다.

 

https://minkukjo.github.io/dev/2020/11/02/DDD-02/

 

DIP와 아키텍처

인프라스트럭처 영역은 구현 기술을 다루는 저수준 모듈이고 응용 영역과 도메인 영역은 고수준 모듈이다.

인프라스트럭처 계층이 가장 하단에 위치하는 계층형 구조와 달리 아키텍처에 DIP를 적용하면 아래의 그림과 같이 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조가 된다.

https://minkukjo.github.io/dev/2020/11/02/DDD-02/

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

 

https://minkukjo.github.io/dev/2020/11/02/DDD-02/

EmailNotifier 클래스는 응용 영역의 Notifier 인터페이스를 상속받고 있다.

주문시 통지 방식에 SMS를 추가해야 한다는 요구사항이 들어왔을때 응용 영역의 OrderService는 변경할 필요가 없다.

위 그림과 같이 두 통지 방식을 함께 제공하는 Notifier 구현 클래스를 인프라스트럭처 영역에 추가하면 된다. 

 

 

반응형

Designed by JB FACTORY