본문 바로가기

JAVA/DDD

아키텍처

반응형

표현-응용-도메인-인프라스트럭처

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

웹 애플리케이션을 개발할 때 많이 사용하는 스프링 MVC프레임워크가 표현 영역을 위한 기술에 해당한다.

웹 애플리케이션에서 표현 영역의 사용자는 웹 브라우저를 사용하는 사람일 수도 있고, REST API를 호출하는 외부 시스템일 수도 있다.


웹브라우저 -> 표현(객체변환)   -> 응용(서비스)

웹브라우저 <- 표현(JSON 변환) <- 응용(서비스)


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

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

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

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


public class CancelOrderService {


public void cancelOrder(String orderId) {

Order order = findOrderById(orderId);

if(order == null) throw new OrderNotFoundException(orderId);

order.cancel();

}

}


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

주문 취소 로직을 직접 구현하지 않고 Order 객체에 취소 처리를 위임하고 있다.


도메인 영역은 도메인 모델을 구현한다.

도메인 모델은 도메인의 핵심 로직을 구현한다.

주문 도메인의 경우 '배송지 변경', '결제완료', '주문총액계산'과 같은 핵심 로직을 도메인 모델에서 구현한다.


인프라스트럭처 영역은 구현 기술에 대한 것을 다룬다.

이 영역은 RDBMS 연동을 처리하고, 메시징 큐에 메시지를 전송하거나 수신하는 기능을 구현하고, 몽고DB나 HBase를 사용해서 데이터베이스 연동을 처리한다.

이 영역은 SMTP를 이용한 메일 발송 기능을 구현하거나 HTTP 클라이언트를 이용해서 REST API를 호출하는 것도 처리한다.

인프라스트럭처 영역은 논리적인 개념을 표현하기보다는 실제 구현을 다룬다.


인프라스트럭처 -> 마리아DB, SMTP 서버, 카프카


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

예를 들어 응용 영역에서 DB에 보관된 데이터가 필요하면 인프라스트럭처 영역의 DB 모듈을 사용해서 데이터를 읽어온다.
비슷하게 외부에 메일을 발송해야 하면 인프라스트럭처가 제공하는 SMTP 연동 모듈을 이용해서 메일을 발송한다.


계층구조 아키텍처


표현

응용

도메인

인프라스트럭처


표현 영역과 응용 영역은 도메인 영역을 사용하고, 도메인 영역은 인프라스트럭처 영역을 사용하므로 계층 구조를 적용하기에 적당해 보인다.

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


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

예를들어 표현계층은 응용 계층에 의존하고 응용계층이 도메인 계층에 의존하지만, 반대로 인프라스트럭처 계층이 도메인에 의존하거나 도메인이 응용 계층에 의존하지는 않는다.


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

예를들어 응용 계층은 바로 아래 계층인 도메인 계층에 의존하지만 외부 시스템과 연동을 위해 더 아래 계층인 인프라스트럭처 계층에 의존하기도 한다.


계층 구조에 따르면 도메인과 응용 계층은 룰 엔진과 DB 연동을 위해 인프라스트럭처 모듈에 의존하게 된다.


응용(서비스)

                            도메인(엔티티)


인프라스트럭처(DB모듈)(룰 엔진)


응용 영역과 도메인 영역은 DB나 외부시스템 연동을 위해 인프라스트럭처의 기능을 사용하므로 이런 계층 구조를 사용하는 것은 직관적으로 이해해가 쉽다.

하지만, 짚고 넘어가야 할 것이 있는데 바로 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라스트럭처 계층에 종속된다는 점이다.


도메인의 가격 계산 규칙을 예로 할인 금액 계산 로직이 복잡해지면 객체 지향으로 로직을 구현하는 것 보다 룰 엔진을 사용하는 것이 더 알맞을 때가 있다.

다음은 Drools라는 룰 엔진을 사용해서 로직을 수행할 수 있는 인프라스트럭처 영역의 코드를 만들어 본것이다.

evalutate() 메서드에 값을 주면 별도 파일로 작성한 규칙을 이요해서 연산을 수행하는 코드라는 정도만 생각하고 넘어가자.


public class DroolsRuleEngine {

private KieContainer kContainer;


public DroolsRuleEngine() {

KieService ks = kieService.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();

}

}


}




응용 영역은 가격 계산을 위해 인프라스트럭처 영역의 DroolsRuleEngine을 사용한다.


public class CalulateDiscountService {

private DroolsRuleEngine ruleEngine;


public CalulateDiscountService() {

ruleEngine = new DroolsRuleEngine();

}


public Money calculateDiscount(List<OrderLine> orderLines, String customerId) {

Customer customer = findCustomer(customerId);


MutableMoney money = new MutableMoney(0);

List<?> facts = Arrays.asList(cusomer, money);

facts.addAll(orderLines);

ruleEngine.evaluate("discountCalculation", facts);

return money.toImmutableMoney();

}

}


CalulateDiscountService가 동작은 하겠지만 이 코드는 두가지 문제를 안고 있다.

1) CalulateDiscountService만 테스트하기 어렵다.

CalulateDiscountService를 테스트할려면 RuleEngine이 완벽하게 동작해야 한다.

RuleEngine 클래스와 관련 설정 파일을 모두 만든 이후에 비로소 CalulateDiscountService가 올바르게 동작하는지 확인할 수 있다.

2) 구현방식을 변경하기 어렵다


MutableMoney money = new MutableMoney(0);  //Drools에 특화된 코드: 연산결과를 받기 위해 추가한 타입

List<?> facts = Arrays.asList(cusomer, money); //Drools에 특화된 코드: 룰에 필요한 데이터

facts.addAll(orderLines);

ruleEngine.evaluate("discountCalculation", facts); //Drools에 특화된 코드: Drools의 세션 이름


코드만 보면 Drools가 제공하는 타입을 직접사용하지 않으므로 CalulateDiscountService가 Drools 자체에 의존하지 않는다고 생각할 수 있다.

하지만 'discountCalculation' 문자열은 Drools의 세션 이름을 의미한다.

따라서 Drools의 세션 이름을 변경하면 CalulateDiscountService의 코드도 함께 변경해야 한다.

MutableMoney는 룰 적용 결과 값을 보관하기 위해 추가한 타입인데 다른 방식을 사용했다면 필요 없는 타입이다.


이처럼 CalulateDiscountService가 겉으로는 인프라스트럭처의 기술에 직접적인 의존을 하지 않는 것처럼 보여도 실제로는 Drools라는 인프라스트럭처 영역의 기술에 완전하게 의존하고 있다.

이런 상황에 Drools가 아닌 다른 구현 기술을 사용하려면 코드의 많은 부분을 고쳐야 한다.


인프라스트럭처에 의존하면 '테스트 어려움'과 '기능 확장의 어려움' 두가지 문제가 발생하는것을 알았다.

그렇다면 어떻게 해야 이 두 문제를 해소할 수 있을까? 해답은 DIP를 적용하는 것이다.


DIP

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


가격할인계산(CalulateDiscountService)

고객 정보를 구한다.                        -> RDBMS에서 JPA로 구한다.

룰을 이용해서 할인 금액을 구한다.       -> Drools로 룰을 적용한다.


고수준                                           저수준


여기서 CalulateDiscountService는 고수준 모듈이다.

고수준 모듈은 의미 있는 단일 기능을 제공하는 모듈로 CalulateDiscountService는 '가격할인계산' 이라는 기능을 구현한다.

고수준 모듈의 기능을 구현하려면 여러 하위 기능이 필요하다.

가격할인계산을 구현하려면 고객 정보를 구해야 하고 룰을 실행해야 하는데, 이 두기능이 하위 기능이다.

저수준모듈은 하위 기능을 실제로 구현하는 것이다.

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


고수준 모듈이 제대로 동작하러면 저수준 모듈을 사용해야한다. 그런데 고수준 모듈이 저수준 모듈을 사용하려면 앞서 계층 구조 아키텍처에 언급했던 두가지 문제(구현변경과 테스트가 어려움)가 발생한다.

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

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

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


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

단지 고객정보와 구매정보에 룰를 적용해서 할인 금액을 구한다는 것이 중요할 뿐이다.


public interface RuleDiscounter {

public Money applyRules(Customer customer, List<OrderLine> orderLines);

}


public class CalulateDiscountService {

  private RuleDiscounter ruleDiscounter;


  public CalulateDiscountService(RuleDiscounter ruleDiscounter) {

    this.ruleDiscounter = ruleDiscounter;

  }


  public Money calculateDiscount(OrderLine orderLines, String customerId) {

    Customer customer = findCustomer(customerId);

    return ruleDiscounter.applyRules(customer, orderLines);

  }

}



CalulateDiscountService는 Drools에 의존하는 코드를 포함하고 있지 않다. 단지, RuleDiscounter가 룰을 적용한다는 것만 알 뿐이다.

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


룰 적용을 구현한 클래스는 RuleDiscounter 인터페이스를 상속받아 구현한다.


public class DroolsRuleDiscounter implements RuleDiscounter {

private KieContainer kContainer;


public DroolsRuleDiscounter() {

KieService ks = kieService.Factory.get();

kContainer = ks.getKieClasspathContainer();

}


public Money applyRules(Customer customer, List<OrderLine> orderLines) {

KieSession kSession = kContainer.newKieSession("discountSession");

try {

      ..코드생략

kSession.fireAllRules();

} finally {

kSession.dispose();

}

return money.toImmutableMoney();

}

}


CalulateDiscountService ---->  <<interface>> RuleDiscounter

                                        ^

                                DroolsRuleDiscounter


CalulateDiscountService는 더 이상 구현 기술인 Drools에 의존하지 않는다.

'룰을 이용한 할인 금액 계산'을 추상화한 RuleDiscounter 인터페이스에 의존할 뿐이다.

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

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


고수준 CalulateDiscountService ---->  <<interface>> RuleDiscounter

                                                   ^

저수준                                     DroolsRuleDiscounter


DIP를 적용하면 저수준 모듈이 고수준 모듈에 의존하게 된다. 고수준 모듈이 저수준 모듈을 사용하려면 고수준 모듈이 저수준 모듈에 의존해야 하는데,

반대로 저수준 모듈이 고수준 모듈에 의존한다고 해서 이를 DIP (Dependency Inversion Principle 의존역전원칙)라고 부른다.


DIP를 적용하면 앞서 다른 영역이 인프라스트럭처 영역에 의존 할 때 발생했던 두가지 문제인 구현 교체가 어렵다는 문제와 테스트가 어려운 문제를 해소 할 수 있다.

먼저 구현 기술 교체 문제를 보자

고수준 모듈은 더 이상 저수준 모듈에 의존하지 않고 구현을 추상화한 인터페이스에 의존한다.

실제 사용할 저수준 구현 객체는 다음 코드처럼 의존 주입을 이용해서 전달받을 수 있다.


//사용할 저수준 객체 생성

RuleDiscounter ruleDiscounter = new DroolsRuleDiscounter();


//생성자 방식으로 주입

CalulateDiscountService disService = new CalulateDiscountService(ruleDiscounter);


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

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


//사용할 저수준 구현 객체 변경

RuleDiscounter ruleDiscounter = new SimpleRuleDiscounter();


//사용할 저수준 모듈을 변경해도 고수준 모듈을 수정할 필요가 없다.

CalulateDiscountService disService = new CalulateDiscountService(ruleDiscounter);


의존 주입을 지원하는 스프링과 같은 프레임워크를 사용하면 설정 코드를 수정해서 쉽게 구현체를 변경 할 수 있다.


테스트에 대한 언급하기 전에 CalulateDiscountService가 제대로 동작하려면 Customer를 찾는 기능도 구현해야한다.

이를 위한 고수준 인터페이스를 CustomerRepository라고 하자. CalulateDiscountService는 다음과 같이 두 인터페이스인 CustomerRepository와 RuleDiscounter를 사용해서 기능을 구현하게 된다.


 public class CalulateDiscountService {

  private RuleDiscounter ruleDiscounter;

  private CustomerRepository customerRepository;


  public CalulateDiscountService(CustomerRepository customerRepository, RuleDiscounter ruleDiscounter) {

    this.customerRepository = customerRepository;

    this.ruleDiscounter = ruleDiscounter;

  }


  public Money calculateDiscount(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;

  }

}




CalulateDiscountService가 제대로 동작하는지 테스트하려면 CustomerRepository와 RuleDiscounter를 구현한 객체가 필요하다.

만약 CalulateDiscountService가 저수준 모듈에 직접 의존했다면 저수준 모듈이 만들어지지 전까지 테스트를 할 수가 없었겠지만 CustomerRepository와 RuleDiscounter는 인터페이스이므로 대용 객체를 사용해서 테스트를 진행할 수 있다.

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


public class CalculateDiscountServiceTest {


@Test(expect = NoCustomerException.class);

public void noCutomer_thenExceptionShouldBeThrown() {

//테스트 목적의 대용 객체

CustomerRepository stubRepo = mock(CustomerRepository.class);

when(stubRepo.findById("noCustId")).thenReturn(null);


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


//대용 객체를 주입받아 테스트 진행

CalulateDiscountService calDisSvc = new CalulateDiscountService(stubRepo, stubRule);

calDisSvc.calculateDiscount(someLines, "noCustId");

}

}


이 코드에서 stubRepo와 stubRule은 각각 CustomerRepository와 RuleDiscounter의 대용 객체이다.

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

두 대용 객체는 테스트를 수행하는데 필요한 기능만 수행한다.


stubRepo의 경우 findById("noCustId")를 실행하면 null를 리턴하는데, calDisSvc를 생성 할때 생성자로 stubRepo를 주입받는다. 따라서 calDisSvc.calculateDiscount(someLines, "noCustId") 코드를 실행하면 CalculateDiscountService의 findById() 메서드에서 실행하는 customerRepository.findById(customerId) 코드는 null로 리턴하고 결과적으로 NoCustomerException을 발생시킨다.


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

실제 구현 대신 스텁이나 Mock과 가은 테스트 목적의 대용 객체를 사용해서 거의 모든 상호아을 테스트 할 수 있다.


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


DIP주의 사항

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

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


도메인                               인프라

CalulateDiscountService ---->  <<interface>> RuleEngine

                                        ^

                                DroolsRuleEngine


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

즉 여전히 고수준 모듈이 저수준 모듈에 의존하고 있는 것이다.

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

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

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

단지 규칙에 따라 할인 금액 계산한다는 것이 중요할 뿐이다. 즉 '할인 금액 계산'을 추상화한 인터페이스는 저수준 모듈이 아닌 고수준 모듈에 위치한다.

반응형

'JAVA > DDD' 카테고리의 다른 글

트랜잭션 범위  (0) 2017.12.23
Aggregate 애그리거트  (0) 2017.12.19
엔티티와 밸류  (0) 2017.12.16
도메인 모델 도출  (0) 2017.12.15
DDD - 도메인 모델 패턴  (0) 2017.12.15