-
DDD - 2장 - 아키텍처 개요소프트웨어 개발 방법론/DDD 2023. 2. 11. 18:26
아키텍처는 전형적으로 네 가지 영역으로 구분된다.
명칭 역할 예시 표현(Presentation) 요청 및 응답 객체의 변환 (Rest)Controller 응용(Application) 도메인 로직(들)에게 수행을 위임 Service 도메인(Domain) 도메인의 핵심 로직 구현 Entity 인프라스트럭처(Infrastructure) 데이터 조회 등 수행 MaraiaDB, SMTP Server, Kafka 위 영역을 구성할 때 일반적으로 많이 사용하는 구조는 아래와 같은 계층형 구조이다.
(좌)엄격한 계층 구조와 (우)구현의 편리함을 고려한 계층 구조 계층 구조는 그 특성상 상위 계층(표현)에서 하위(인프라) 계층으로의 의존만 존재하고 하위 계층은 상위 계층에 의존하지 않는다.
이와 같은 구조는 직관적으로 이해하기 쉬운 장점이 있지만 응용 영역이 인프라스트럭처 기술에 직접적으로 종속될 수 있다는 문제점이 있다.
// 할인 금액을 계산하는 응용계층 클래스 @Service public class CalculateDiscountService { // 실제로 할인 금액을 계산해주는 인프라스트럭처 계층 클래스 @Autowired private DcRuleEngine ruleEngine; public Money calculateDiscount(OrderLine orderLines, String customerId) { Customer customer = findCustomer(customerId); // (1)DcRuleEngine 특화 코드: 연산결과를 받기 위해 추가된 타입 MutableMoney money = new MutableMoney(0); // (2)DcRuleEngine 특화 코드: 룰에서 계산에 필요한 지식 List<?> facts = Arrays.asList(customer, money); facts.addAll(orderLines); // (3)DcRuleEngine 특화 코드: 내부에서 사용하는 세션 이름 ruleEngine.evaluate("discountCalculation", facts); return money.toImmutableMoney(); } }
위 코드를 보면 겉으로 보기에는 직접적인 종속이 없는 것 처럼 보여도 아래와 같은 문제점을 금방 확인할 수 있다.
- DcRuleEngine클래스의 연산 결과를 처리하기 위한 MutableMoney클래스에 대한 이해가 강제된다.
- DcRuleEngine.evaluate(...) 메서드를 호출하기 위해 위와 같이 값을 세팅해야 한다는 사전 지식이 필요하다.
- DcRuleEngine.evaluate(...) 메서드 내에서 사용하는 세션명(discountCalculation)을 응용 계층에서 사용하면서 DcRuleEngine내의 세션명이 변경되는 경우 CalculateDiscountService또한 변경이 필요해진다.
이렇게 계층 구조에서 인프라스트럭처에 종속되게 되면서 "테스트의 어려움"과 "기능 확장의 어려움" 이라는 두 가지 문제가 발생하게 되는데, 이 문제를 해소할 수 있는 해답은 그 유명한 DIP(Dependency Inversion Principle: 의존 역전 원칙)에 있다.
DIP를 적용하여 위 코드를 아래와 같이 변경한다면 어떨까?
// RuleDiscounter.java public interface RuleDiscounter { Money applyRules(Customer customer, List<OrderLine> orderLines); }
// DcRuleEngine.java public class DcRuleEngine implements RuleDiscounter { private KieContainer kieContainer; public DcRuleEngine() { KieServices kieServices = KieServices.Factory.get(); this.kieContainer = kieServices.getKieClasspathContainer(); } @Override public Money applyRules(Customer customer, List<OrderLine> orderLines) { KieSession kieSession = this.kieContainer.newKieSession("discountSession"); try { kieSession.fireAllRules(); // ... } finally { kieSession.dispose(); } return money.toImmutableMoney(); } }
// CalculateDiscountService.java @Service public class CalculateDiscountService { @Autowired private RuleDiscounter ruleDiscounter; public Money calculateDiscount(List<OrderLine> orderLines, String customerId) { Customer customer = findCustomer(customerId); return ruleDiscounter.applyRules(customer, orderLines); } }
위 코드를 보면 CalculateDiscountService가 더 이상 구현 기술인 DcRuleEngine에 의존하지 않고 "룰을 이용한 할인 금액 계산"을 추상화한 RuleDiscounter 인터페이스에 의존하게 되며 DcRuleEngine 또한 고수준 모듈인 RuleDiscounter 인터페이스에 의존하게 된다. 일반적인 경우와 반대로 "저수준 모듈이 고수준 모듈에 의존"한다고 해서 DIP(의존관계 역전 원칙) 이라는 이름이 붙게 되었다.DIP를 적용해서 고수준의 모듈이 저수준 모듈에 의존하지 않도록 하면 아래와 같이 실제 구현체가 없이도 테스트를 하기가 용이해진다.
public class CalculateDiscountServiceTest { @Test public void 없는_고객이면$예외가_던져져야_한다() { // Given : 테스트 목적의 stub(대역) 객체 설정 CustomerRepository stubRepo = mock(CustomerRepository.class); when(stubRepo.findById("noCustId")).thenReturn(null); RuleDiscounter stubRule = (cust, lines) -> null; // When : stub객체를 주입 받아 테스트 객체 생성 CalculateDiscountService calDcService = new CalculateDiscountService(stubRepo, stubRule); // Then assertThrows(NoCustomerException.class, () -> calDcService.calculateDiscount(someLines, "noCustId"); } }
DIP를 잘못 생각하면 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들일 수 있는데, DIP의 핵심은 "고수준 모듈이 저수준 모듈에 의존하지 않도록 하기 위함" 이라는 것을 명심해야 한다. 즉, 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출되어야 한다.
(좌)DIP를 잘못 적용한 예 (우)하위 기능을 추상화한 인터페이스를 고수준 모듈에 위치시킨 올바른 예 대부분의 경우는 DIP를 적용하는것이 좋지만 추상화 대상이 잘 떠오르지 않거나 구현 기술에 의존적인 코드를 도메인에 일부 포함하는게 효과적인 경우에는 완벽한 DIP를 적용하기보다는 적용 범위를 검토해보는것이 좋다는 것 또한 염두해 두어야 한다.
또한, 필자는 구현의 편리함도 DIP가 주는 다른 장점(변경 유연성, 테스트 용이성)만큼 중요하다 하는데 DIP의 장점을 해치지 않는 범위내에서 응용 영역과 도메인 영역에서 구현 기술에 대한 의존을 가져가는 것이 나쁘지 않다고 한다.
예시로 스프링의 @Transactional 어노테이션을 제시했는데 해당 어노테이션을 사용하면 한 줄로 트랜잭션 관리를 할 수 있지만 스프링에 대한 의존이 생기게 된다. 의존을 없애려면 복잡한 스프링 설정(xml)을 사용할 수 있겠지만 테스트가 쉬워지거나 유연함이 증가하진 않을 것이다.
앞에서 도메인 영역의 구조를 살펴봤으니 이제 주요 구성요소에 대해 알아보자.
요소 설명 예시 엔티티 (Entity) 고유 식별자(ID)를 갖는 객체로 자신의 라이프사이클을 갖고 도메인의 고유 개념을 포함. Order(주문), Member(회원), Product(상품) 밸류 (Value) 고유 식별자를 갖지 않는 객체로 주로 개념적으로 하나인 값을 표현할 때 사용. 엔티티나 다른 밸류 타입의 속성으로도 사용될 수 있다. Address(주소), 금액(Money) 애그리거트 (Aggregate) 연관된 엔티티와 밸류 객체를 하나로 묶은 것. '주문' 애그리거트:
Order, OrderLine, Orderer리포지터리 (Repository) 도메인 모델의 영속성을 처리. DBMS에서 save, select 도메인 서비스 (Domain Service) 특정 엔티티에 속하지 않은 도메인 로직을 제공. 도메인 로직이 여러 엔티티와 밸류를 필요로 하는 경우 도메인 서비스에서 로직을 구현한다. 할인 금액 계산:
상품, 쿠폰, 회원등급, 구매금액 등 다양한 도메인이 포함되어야 함(에릭 에반스의 ⎡도메인 주도 설계⎦에 기반한 도메인의 구성 요소)
1. 엔티티와 밸류
특정 도메인 모델의 데이터와 기능을 정의하는 클래스들로 (1)데이터와 함께 기능을 제공한다는 점, (2)두 개 이상의 데이터가 개념적으로 하나인 경우 밸류타입을 이용해서 표현할 수 있다는 점에서 ERD테이블과 1:1로 매핑되는 개념은 아니라는것을 알 수 있다.(좌)도메인 모델의 엔티티와 밸류타입 (우) RDBMS로 표현한 테이블 우측의 ERD로 표기한 테이블의 경우 밸류 타입의 의미보다는 하나의 엔티티에 가깝게 표현되어 좌측 도메인 모델의 밸류타입 Orderer클래스보다는 도메인을 이해하기 어렵다.
2. 애그리거트도메인 모델이 복잡해지면 개발자가 전체 구조가 아닌 한 개 엔티티와 밸류에만 집중하는 상황이 발생한다. 이 때 상위 수준에서 모델을 관리하지 않고 개별 요소에만 초점을 맞추다 보면 큰 수준의 모델을 이해하지 못해 큰 틀에서 모델을 관리할 수 없는 상황에 빠질 수 있는데 이를 방지하기 위해 도메인 모델에서 전체 구조를 이해하는 데 도움이 되도록 관련 객체를 하나의 군집으로 묶는 것을 애그리거트라 한다.
애그리거트는 군집에 속한 객체를 관리하는 루트 엔티티를 갖고 루트 엔티티는 애그리거트에 속해 있는 엔티티와 밸류 객체를 이용해서 구현해야 할 기능을 제공한다.
관련된 객체를 애그리거트로 묶는 예시 // 주문 "애그리거트 루트" Order public class Order { // ... public void changeShippingInfo(ShippingInfo newInfo) { checkShippingInfoChangeable(); // 배송지 변경 가능 여부 확인 this.shippingInfo = newInfo; } private void checkShippingInfoChangeable() { // 배송지 정보를 변경할 수 있는지 여부를 확인하는 도메인 규칙 구현 } }
애그리거트 루트의 사용으로 내부 구현을 숨겨서 애그리거트 단위로 캡슐화할 수 있도록 돕도록 할 수 있다.
애그리거트를 구현할 때는 고려할 것이 많은데, 어떻게 구성했냐에 따라 구현이 복잡해지기도 하고 트랜잭션 범위도 달라지기도 한다.
3. 리포지터리
리포지터리는 도메인 객체를 물리적인 저장소(RDBMS, NoSQL등)에 도메인 객체를 보관하는 역할을 한다.
이 때 저장하는 단위는 애그리거트 루트 단위(Order, Member등)이며 기본적으로 save와 findById(id)메서드를 지원하고 필요에 따라 delete(id)나 counts() 등의 메서드를 제공하기도 한다.
마지막으로 모듈 구성에 대해 정리해보았다.
영역별로 별도 패키지로 구성한 모듈 구조 도메인이 큰 경우는 아래처럼 하위 도메인으로 나눌 수 있다.
하위 도메인별로 나뉜 모듈 구조 마지막으로 도메인 모듈은 도메인에 속한 애그리거트를 기준으로 다시 패키지를 구성할 수 있다.
하위 도메인을 하위 패키지로 구성한 모듈 구조 위의 큰 구조를 가져가면서 애그리거트, 모델, 리포지토리는 같은 패키지에 위치시키는것을 권장한다.
- com.myshop.order.domain.order : 애그리거트 위치
- com.myshop.order.domain.order.Order
- com.myshop.order.domain.order.OrderLine- com.myshop.order.domain.order.Orderer
- com.myshop.order.domain.order.OrderRepository- com.myshop.order.domain.service : 서비스 위치
- com.myshop.order.domain.service.OrderCancelService
- com.myshop.order.domain.service.PeriodOrderService모듈 구조를 얼마나 세분화해야 하는지에 대해 정해진 규칙은 없고 한 패키지에 더무 많은 타입이 몰려서 코드를 찾을 때 불편한 정도만 아니면 된다. 저자는 10~15개 미만으로 타입 개수를 유지하려고 하고 이 개수가 넘어가면 패키지를 분리하는 시도를 권장한다.
'소프트웨어 개발 방법론 > DDD' 카테고리의 다른 글
DDD - 1장 - 도메인 모델 시작하기 (0) 2023.02.11