참고
- DDD-START! (지앤선 - 최범균 지음)
- 개발자가 반드시 정복해야 할 객체 지향과 디자인패턴 (인투북스 - 최범균 지음)
DIP
정의
- 고수준 모듈은 저수준 모듈의 구현에 의존하면 안된다. 저수준 모듈이 고수준 모듈에서 정의한 추상타입에 의존해야 한다.
- 변하기 쉬운것에 의존하지 마라.
먼저 아래 코드를 보자.
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;
}
...
}
Service 라는 고수준 모듈에서 DroolRuleDiscounter라는 저수준 모듈을 직접 의존하고 있는 코드다. 위 코드는 두가지 문제를 가지고 있다.
- 구현방식 변경이 어렵다.
- 강하게 결합되어 있기 때문에 RuleDiscounter 변경시 service 코드도 함께 변경해줘야 한다.
- 테스트 하기 어렵다.
- DroolsRuleDiscounter 대역을 세울 수 없다
- 따라서 DroolsRuleDiscounter 가 완벽하게 동작해야 테스트가 가능하다.
위 코드를 예로 설명하면 인프라스트럭처(여기에선 RuleDiscounter의 구현체인 DroolRuleDiscounter를 말한다) 에 의존하면 테스트가 어렵고 기능확장이 어렵다고 한다. 이 어려움을 해결할 수 있는 방법이 DIP 다.
고수준 모듈이 제대로 동작하려면 저수준 모듈을 사용해야 한다. 그런데 위와 같이 고수준 모듈이 저수준 모듈의 구현을 직접 사용하면 위에서 말한 구현변경과 테스트 어려움의 문제가 발생한다. 미래의 변경에 대비할 수 있는 유연한 코드를 만드려면 위 2가지 문제를 해결하고 가는 것이 좋다.
DIP 를 적용하면 저수준 모듈이 고수준 모듈에 의존하는 구조로 변경이 된다. DIP를 어떻게 적용할까? 추상화한 인터페이스를 사용하면서 DIP를 적용할 수 있다. 추상화한 인터페이스를 세워 기존의 고수준 모듈이 저수준 모듈을 의존했던 구조를 역전시키는 것이다.
public interface RuleDiscounter {
Money applyRules(Customer customer, List<OrderLine> orderLines);
}
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);
}
...
}
public class DroolsRuleDiscounter implements RuleDiscounter {
@Override
public Money applyRules(Customer customer, List<OrderLine> orderLines) {
...
return new Money();
}
...
}
RuleDiscounter 라는 추상화한 인터페이스를 세우고 변경한 Service 는 Drools에 의존하는 코드를 포함하고 있지 않다. 단지 룰을 적용한다는 사실만 알 뿐이다. Service 에서 사용해야 하는 실제 RuleDiscounter의 구현체는 생성자를 통해서 외부에서 전달받는다. 위 코드를 그림으로 표현하면 아래와 같다.
DIP 가 어떻게 구현변경 기술의 어려움, 테스트의 어려움을 어떻게 해결하는지 보자
구현기술의 변경 해결
- 고수준 모듈이 저수준 모듈을 의존하지 않게 되었다.(직접 생성하지 않는다)
- 외부에서 주입해주는 방식으로 변경함으로써 해결해 주었다.
@Test
@DisplayName("생성자 주입을 통해 구현변경의 어려움을 해결")
public void constructor() {
RuleDiscounter ruleDiscounter = new DroolsRuleDiscounter();
CalculateDiscountService discountService = new CalculateDiscountService(ruleDiscounter);
RuleDiscounter ruleDiscounter2 = new SimpleRuleDiscounter();
CalculateDiscountService discountService2 = new CalculateDiscountService(ruleDiscounter2);
}
생성단계에서 다른 구현을 주입해주면서 저수준 모듈인 구현객체를 쉽게 변경할 수 있는 구조로 되었다.
테스트의 어려움
먼저 고수준 모듈이 저수준 모듈에 의존할 때의 테스트를 확인해보자
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;
}
...
}
@Test
@Description("목으로 대체할 객체를 의존성 주입으로 받지 않을때")
public void not_using_DI() {
CustomerRepository stubRepo = mock(CustomerRepository.class);
when(stubRepo.findById("noCustId")).thenReturn(null);
RuleDiscounter stubRule = (cust, lines) -> null;
CalculateDiscountService cs = new CalculateDiscountService(stubRule);
assertThatExceptionOfType(NoCustomerException.class)
.isThrownBy( () -> cs.calculateDiscount(orderLines, "noCustId"));
}
현재 고수준 모듈(CalculateDiscountService)이 저수준 모듈(CustomerRepostiroy)에 의존하고 있다.(service 에서 new 를 사용해서 직접 객체를 생성한다)
위 코드는 고객 정보를 찾을 수 없을 때 NoCustomerException 을 일으키는 메서드이고 , 아래는 그 메서드가 올바르게 동작하는지 테스트 하는 코드이다. (테스트는 Mockito, AssertJ 를 사용)
위 테스트 코드에서는 stupRepo 라는 customerRepository 대역을 세웠다. 대역을 세우면 특정 상황을 연출하여 구현 객체가 완성되지 않아도 테스트를 진행할 수 있다. stubRepo 라는 대역을 세우고 noCustId 라는 id 가 주어졌을 때 null을 리턴하고 NoCustomerException 이라는 익셉션을 발생시키는 테스트코드다. 하지만 위 테스트 코드는 그렇게 동작하지 않는다.
null 을 리턴하지 않아 NoCustomerException 이 발생하지 않아 테스트가 통과하지 않았다. 이 테스트를 통과시키려면 DIP 를 적용하면 된다. 그런데 추상화한 인터페이스를 꼭 사용해야만 테스트를 통과시킬 수 있는건가?
추상화한 인터페이스를 세우지 않고 DroolsRuleDiscounter를 의존해도 테스트는 통과시킬 수 있지만, 이러면 구현변경의 어려움이 발생하게 된다.
아래 처럼 추상화한 인터페이스를 생성자에 추가하고 위 테스트 코드를 다시 실행하면 통과할 것이다.
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);
}
...
}
stubRepo 가 정상적으로 동작하여 NoCustomerException 을 발생시킨다. 테스트가 정상적으로 통과했다.
고수준 모듈이 저수준 모듈에 의존했을 때 발생하는 2가지 문제를 DIP 를 통해 해결하는 과정을 간단하게 볼 수 있었다. DIP는 미래의 변경에 유연하게 대처할 수 있는 코드를 만들기 위해서 필요한 기법이기 때문에 잘 기억해놓자.