도메인 주도 개발 시작하기 - 2. 아키텍처 개요
2.1 아키텍처
2.2 계층 구조 아키텍처
2.3 DIP
2.4 도메인 영역의 주요 구성요소
2.5 요청 처리 흐름
2.6 인프라스트럭처
2.7 모듈 구성
2.1 아키텍처
-
아키텍처를 설계할 때 전형적인 네 가지 영역
① 표현 영역
HTTP 요청
→요청 변환
→응용 영역에 전달
→응답 변환
→응답 전송
- 스프링 MVC 프레임워크가 표현 영역을 위한 기술에 해당한다.
② 응용 영역
- 시스템에서 사용자에게 제공해야 할 기능을 구현한다.
- 기능을 구현하기 위해 도메인 영역의 도메인 모델을 사용한다.
- 응용 서비스는 로직을 직접 수행하기보다 도메인 모델에 로직 수행을 위임한다.
③ 도메인 영역
- 도메인 영역은 도메인 모델을 구현한다.
- 도메인 모델은 도메인의 핵심 로직을 구현한다.
- 주문 도메인은 배송지 변경, 결제 완료, 주문 총액 계산과 같은 핵심 로직을 도메인 모델에서 구현한다.
④ 인프라스트럭처 영역
- 인프라스트럭처 영역은 구현 기술에 대한 것을 다룬다.
- RDBMS 연동, 메세징 큐, 몽고DB, 레디스 연동, SMTP 메일 발송, HTTP 클라이언트
- 표현 영역, 응용 영역, 도메인 영역은 구현 기술을 사용한 코드를 직접 만들지 않고, 인프라스트럭처 영역에서 제공하는 기능을 사용해서 필요한 기술을 개발한다.
- DB 모듈을 사용하여 데이터를 조회, SMTP 모듈을 이용해서 메일을 발송
2.2 계층 구조 아키텍처
- 계층 구조는 그 특성상 상위 계층에서 하위 계층으로만 의존한다.
- 인프라스트럭처 계층이 도메인을 의존하거나 도메인이 응용 계층에 의존하지 않는다.
- 계층 구조를 엄격하게 적용한다면 상위 계층은 바로 아래의 계층에만 의존을 가져야 하지만 구현의 편리함을 위해 계층 구조를 유연하게 적용하기도 한다.
- 응용 계층은 도메인 계층에 의존하면서 외부 시스템과의 연동을 위해 더 아래 계층인 인프라스트럭처 계층에 의존하기도 한다.
- 계층 구조를 사용하면 아키텍처를 직관적으로 이해하기 쉽다는 장점이 있다.
- 계층 구조의 중요한 특징 중 하나는 표현, 응용, 도메인 계층은 인프라스트럭처 계층에 종속된다.
- 도메인의 가격 계산 규칙의 예
- 룰 엔진을사용해서 계산 로직을 수행하는 인프라스트럭처 영역의 코드
Drools
: 할인 금액을 계산하기 위한 룰 엔진-
DroolsRuleEngine
: 룰 엔진의 가격 계산을 수행 (* Drools는 전방 및 후방 추론 기반 룰 엔진을 갖춘 BRMS이다.)public class DroolsRuleEngine { private KieContainer kContainer; public DroolsRuleEngine() { KieServices ks = KieServices.get(); kContainer = ks.getKieClasspathContainer(); } public void evalute(String sessionName, List<?> facts) { KieSession kSession = kContainer.newKieSession(sessionName); try { facts.forEach(x -> kSession.insert(x)); kSession.fireAllRules(); } finally { kSession.dispose(); } } }
public class CalculateDiscountService { private DroolsRuleEngine ruleEngine; public CalculateDiscountService(DroolsRuleEngine ruleEngine) { ruleEngine = ruleEngine; } 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(); } ... }
Drools
의 세션 이름을 변경하면CalculateDiscountService
의 코드도 함께 변경해야 한다.MutableMoney
는 연산 결과값을 보관하기 위한 데이터 타입으로, 다른 방식을 사용하면 필요 없다.
➡️
CalculateDiscountService
가 직접적으로 인프라스트럭처 기술에 의존하는것 같지 않아보여도, 실제로는 의존하고 있는 코드로 인해 변경사항에 유연하지 못하게 된다.
상위 계층이 인프라스트럭처 계층에 종속되면 두 가지 문제점이 있다.
1. 테스트하기 어렵다.
2. 구현 방식을 변경하기 어렵다.
2.3 DIP
- 앞서 살펴본 계층 구조 아키텍처에서의 문제점을 DIP를 통해 해결할 수 있다.
-
DIP 개념을 이해하기 위해 고수준 모델과 저수준 모델의 차이를 이해할 필요가 있다.
- 고수준 모듈
- 의미 있는 단일 기능을 제공하는 모듈이다.
CalculateDiscountService
의 가격 할인 계산 기능- 고수준 모듈의 기능을 구현하려면 여러 하위 기능이 필요하다.
- 가격 할인 계산 기능을 구현하기 위해서는 고객 정보와 할인 룰 엔진이 필요
- 저수준 모듈
- 하위 기능을 실제로 구현한 것이다.
- JPA를 이용해 고객 정보 조회, Drools를 이용해 룰을 실행
➡️ 고수준 모듈이 제대로 동작하려면 저수준 모듈을 사용해야 한다. 고수준 모듈이 저수준 모듈을 사용하면 앞서 계층 구조 아키텍처의 문제점이 발생하게 된다.
- 고수준 모듈
-
DIP는 저수준 모듈이 고수준 모듈에 의존하도록 추상화한 인터페이스를 사용한다.
- 계층형 아키텍처에 DIP를 적용한 예
-
룰을 적용하기 위해 추상화된 인터페이스를 사용하도록 한다.
public interface RuleDiscounter { Money applyRules(Customer customer, List<OrderLine> orderLines); }
-
룰을 적용하는
RuleDiscounter
의 구현 객체는 생성자를 통해 전달받는다.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); } ... }
-
할인 룰 적용을 구현한 클래스는
RuleDiscounter
인터페이스를 상속받아 구현한다.public class DroolsRuleDiscounter implements RuleDiscounter { private KieContainer kContainer; public DroolsRuleDiscounter() { KieServices ks = KieServices.get(); kContainer = ks.getKieClasspathContainer(); } @Override public Money applyRules(Customer customer, List<OrderLine> orderLines) { KieSession kSession = kContainer.newKieSession("discountCalculation"); try { ... 코드 생략 kSession.fireAllRules(); } finally { kSession.dispose(); } return money.toImmutableMoney(); } }
-
-
DIP를 적용하면 저수준 모듈이 고수준 모듈에 의존하게 된다. 고수준 모듈이 저수준 모듈을 사용하는데 반대로 저수준 모듈이 고수준 모듈을 의존한다고 해서 이를 DIP(Dependency Injection, 의존 역전 원칙)이라고 부른다.
- DIP를 적용하면 고수준 모듈이 저수준 모듈에 의존할 때 발생했던 두 가지 문제를 해소할 수 있다.
-
구현 기술을 변경하더라도
CalculateDiscountService
를 수정할 필요가 없다.// 사용할 저수준 구현 객체 변경 RuleDiscounter ruleDiscounter = new SimpleRuleDiscounter(); // 사용할 저수준 모듈을 변경해도 고수준 모듈을 수정할 필요 없음 CalulateDiscountService discountService = new CalculateDiscountService(ruleDiscounter);
-
CalculateDiscountService
가 구현 객체가 아닌 인터페이스를 사용하므로, 대역 객체를 사용해서 테스트 할 수 있다.@Test public void noCustomer_thenExceptionsShouldBeThrown() { // 테스트 목적의 대역 객체 CustomerRepository stubRepo = mock(CustomerRepository.class); when(stubRepo.findById("noCustId")).thenReturn(null); RuleDiscounter stubRule = (cust, lines) -> null; // 대용 객체를 주입 받아 테스트 진행 CalculateDiscountService calculateDiscountService = new CalculateDiscountService(stubRepo, stubRule); assertThrows(NoCustomerException.class, () -> calculateDiscountService.calculateDiscount(someLines, "noCustId")); }
-
2.3.1) DIP 주의 사항
-
DIP를 잘못 생각하면 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들일 수 있다.
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); MutableMoney money = new MutableMoney(0); List<?> facts = Arrays.asList(customer, money); facts.addAll(orderLines); ruleDiscounter.evaluate("discountCalculation", facts); return money.toImmutableMoney(); } ... }
-
DIP를 적용할 때, 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출한다.
2.3.2) DIP와 아키텍처
- 인프라스트럭처 계층이 가장 하단에 위치하는 계층형 구조와 달리 DIP를 적용하면 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조가 된다.
- 그래서 도메인과 응용 영역에 대한 영향을 최소화하면서 구현 기술을 변경하는 것이 가능하다.
- DIP를 항상 적용할 필요는 없다. 구현 범위와 추상화 정도에 따라 DIP의 이점을 얻는 수준에서 적용 범위를 검토해보자.
2.4 도메인 영역의 주요 구성요소
요소 | 설명 | 사용 |
---|---|---|
엔티티 ENTITY |
- 고유의 식별자를 갖는다. - 라이프 사이클을 갖는다. - 도메인의 데이터를 포함하며, 해당 데이터와 관련된 기능을 함께 제공한다. |
- 주문(Order) - 회원(Member) - 상품(Product) |
밸류 VALUE |
- 고유식 식별자를 갖지 않는다. - 개념적으로 하나인 값을 표현할 때 사용된다. - 엔티티의 속성으로 사용할 뿐 아니라 다른 밸류 타입의 속성으로도 사용할 수 있다. |
- 주소(Address) - 금액(Money) |
애그리거트 AGGREGATE |
- 연관된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다. | [주문 애그리거트] - Order 엔티티 - OrderLine 밸류 - Orderer 밸류 |
리포지터리 REPOSITORY |
- 도메인 모델의 영속성을 처리한다. | - 엔티티 객체를 조회 - 엔티티 객체를 저장 |
도메인 서비스 DOMAIN SERVICE |
- 특정 엔티티에 속하지 않은 도메인 로직을 제공한다. -도메인 로직이 여러 엔티티와 밸류를 필요로 하면 도메인 서비스에서 로직을 구현한다. |
[할인 금액 계산] - 상품 - 쿠폰 - 회원 등급 |
2.4.1) 앤티티와 밸류
DB 테이블의 엔티티
VS도메인 모델의 엔티티
-
도메인 모델의 엔티티는 단순히 데이터를 담고 있는 데이터 구조가 아니라 데이터와 함께 기능을 제공하는 객체이다.
public class Order { // 주문 도메인 모델의 데이터 private OrderNo number; private Orderer orderdr; private ShippingInfo shippingInfo; ... // 도메인 모델 엔티티는 도메인 기능도 함께 제공 public void changeShippingInfo(ShippingInfo newShippingInfo) { ... } }
-
도메인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표현할 수 있다.
public class Orderer { private String name; private String email; ... }
-
RDBMS 같은 관계형 데이터베이스는 밸류 타입을 제대로 표현하기 힘들다.
- 한 테이블에 주문자 정보를 함께 넣으면 ‘주문자’ 개념이 드러나지 않는다.
- 다른 테이블에 주문자 정보를 저장하면 엔티티처럼 보여, 밸류 타입의 의미가 들어나지 않는다.
-
-
밸류는 불변으로 구현할 것을 권장하며, 이는 엔티티의 밸류 타입 데이터를 변경할 때는 객체 자체를 완전히 교체한다는 것을 의미한다.
public class Order { private ShippingInfo shippingInfo; // 도메인 모델 엔티티는 도메인 기등도 함께 제공 public changeShippingInfo(ShippingInfo newShippingInfo) { checkShippingInfoChangeable(); setShippingInfo(newShippingInfo); } public void setShippingInfo(ShippingInfo newShippingInfo) { if (newShippingInfo == null) throw new IllegalArgumentException(); // 밸류 타입의 데이터를 변경할 때는 새로운 객체로 교체한다. this.shippingInfo = newShippingInfo; } ... }
2.4.2) 애그리거트
- 도메인이 커질수록 엔티티와 밸류 개수가 많아져 도메인 모델이 복잡해진다.
- 도메인 모델이 복잡해지면 개발자가 전체 구조가 아닌 엔티티와 밸류에만 집중하는 상황이 발생한다.
- 상위 수준에서 모델을 관리하지 않고 개별 요소에만 초점을 맞추다 보면, 모델을 제대로 이해하고 관리할 수 없게 된다.
- 지도에서도 대축척 지도와 소축척 지도를 함께 봐야 현재 위치를 보다 정확하게 이해할 수 있다.
- 도메인 모델에서 전체 구조를 이해하는데 도움이 되는 것이 애그리거트이다.
- 에그리거트는 관련 객체를 하나로 묶은 군집이다.
- 애그리거트를 사용하면 개별 객체가 아닌 관련 객체를 묶어서 객체 군집 단위로 모델을 바라볼 수 있다.
- 애그리거트는 군집에 속한 객체를 관리하는 루트 엔티티를 갖는다.
-
애그리거트 루트를 통해서 간접적으로 애그리거트 내의 다른 엔티티나 밸류 객체에 접근한다.
➡️ 애그리거트 단위로 구현을 캡슐화public class Order { ... private ShippingInfo shippingInfo; // 도메인 모델 엔티티는 도메인 기등도 함께 제공 public changeShippingInfo(ShippingInfo newShippingInfo) { checkShippingInfoChangeable(); // 배송지 변경 가능 여부 확인 setShippingInfo(newShippingInfo); } private void checkShippingInfoChangeable() { // 배송지 정보를 변경할 수 있는지 여부를 확인하는 도메인 규칙 구현 } ... }
- 주문 애그리거트는 Order를 통하지 않고는 ShippingInfo를 변경할 수 있는 방법을 제공하지 않는다. 즉, 배송지를 변경하려면 루트 엔티티인 Order를 사용해야 하므로, 배송지 정보를 변경할 때는 Order가 구현한 도메인 로직을 항상 따르게 된다.
- 애그리거트를 어떻게 구성했느냐에 따라 구현 복잡도와 트랜잭션 범위가 달라진다.
2.4.3) 리포지터리
- 도메인 객체를 지속적으로 사용하려면 물리적인 저장소에 도메인 객체를 보관해야 하는데, 이를 위한 도메인 모델이 리포지터리이다.
-
리포지터리는 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.
public interface OrderRepository { Order findByNumber(OrderNumber orderNumber); void save(Order order); void delete(Order order); }
- 객체를 조회하고 저장하는 단위가 애그리거트 루트인
Order
이다.
- 객체를 조회하고 저장하는 단위가 애그리거트 루트인
- 도메인 모델 관점에서
OrderRepository
는 도메인 객체를 영속화하는데 필요한 기능을 추상화한 것으로 고수준 모듈에 속한다. - 기반 기술을 이용해서
OrderRepositoy
를 구현한 클래스는 저수준 모듈로 인프라스트럭처 영역에 속한다. - 응용 서비스와 리포지터리는 밀접한 연관이 있다.
- 응용 서비스는 필요한 도메인 객체를 조회하거나 저장할 때 리포지터리를 사용한다.
- 응용 서비스는 트랜잭션을 관리하는데, 트랜잭션 처리는 리포지터리 구현 기술의 영향을 받는다.
2.5 요청 처리 흐름
- 도메인의 상태를 변경해야 하는 경우, 변경된 상태가 올바르게 반영되도록 트랜잭션 관리가 필요하다.
- 스프링프레임워크를 사용하면
@Transactional
애너테이션으로 트랜잭션을 처리할 수 있다.
- 스프링프레임워크를 사용하면
2.6 인프라스트럭처
- 인프라스트럭처는 표현 영역, 응용 영역, 도메인 영역을 지원한다.
- 도메인 영역과 응용 영역에서 인프라스트럭처 기능을 직접 사용하는 것보다 DIP를 적용하여 시스템을 더 유연하고 테스트하기 쉽게 만들 수 있다.
- 하지만 무조건 인프라스트럭처에 대한 의존을 없앨 필요는 없다.
- 트랜잭션 처리를 위한
@Transactional
애너테이션 - JPA 전용 애너테이션 -
@Entity
,@Table
등
- 트랜잭션 처리를 위한
- 구현의 편리함은 DIP가 주는 다른 장점 만큼 중요하기 때문에, DIP의 장점을 헤치치 않는 범위에서 사용하는 것이 좋다.
2.7 모듈 구성
- 아키텍처의 각 영역은 별도 패키지에 위치한다.
- 패키지 구성 규칙에 정답이 존재하는 것은 아니지만, 같은 영역별로 모듈이 위치할 패키지를 구성할 수 있다.
- 도메인이 크면 하위 도메인으로 나누고, 각 하위 도메인마다 별도 패키지를 구성한다.
- 도메인 모듈은 도메인이 속한 애그리거트를 기준으로 다시 패키지를 구성한다.
- 애그리거트, 모델, 리포지터리는 같은 패키지에 위치시킨다.
shop.order.domain.order
: 애그리거트 위치-
shop.order.domain.service
: 도메인 서비스 위치 -
도메인이 복잡하면 도메인 모델과 도메인 서비스를 별도 패키지에 위치시킬 수도 있다.
shop.catalog.application.product
shop.catalog.application.catetory
➡️ 모듈 구조를 얼마나 세분화해야 하는지에 대해 정해진 규칙은 없다. 보통 한 패키지에 10~15개 미만으로 유지하는 편이다.