on
DDD와 MSA 기초(1)
DDD와 MSA에 대해 여러 시리즈로 정리합니다. 이번 편에서는요,
- 도메인
- 도메인 모델
- DIP
- 도메인 영역의 주요 구성요소
- ENTITY
- VALUE
- AGGREGATE
- REPOSITORY
- DOMAIN SERVICE
- 도메인 모델과 BOUNDED CONTEXT
에 대해 다룹니다.
도메인
개발자 입장에서 구현해야 할 소프트웨어 대상, 소프트웨어로 해결하고자 하는 문제 영역은 도메인(domain)에 해당합니다.
- 도메인은 여러 하위 도메인으로 구성될 수 있지만 모든 도메인마다 고정된 하위 도메인이 존재하는 것은 아닙니다. 각 하위 도메인이 다루는 영역은 서로 다르기 때문에 어러 하위 도메인을 하나의 다이어그램에 모델링하면 안됩니다. (ex. 카탈로그와 배송 도메인 모델을 구분하지 않고 하나의 다이어그램에 함께 표시하면 -> 카탈로그의 상품과 배송의 상품 의미를 함께 제공하게 되어 이해에 방해가 됨) 모델의 각 구성요소는 특정 도메인을 한정할 때 비로소 의미가 완전해지기 때문에, 각 하위 도메인마다 별도로 모델을 만들어야 합니다.
- 또 소프트웨어가 도메인의 모든 기능을 제공하지는 않습니다.(자체 시스템 / 외부) 하위 도메인을 어떻게 구성할지 여부는 상황에 따라 달라집니다.
도메인 모델
도메인 모델은 아키텍처상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴으로, 도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유할 수 있는데, 이런 도메인 모델은 객체 모델이나 상태 다이어그램 등을 사용해서 모델링할 수 있습니다.
도메인 자체를 이해하기 위한 개념 모델로, 구현 기술에 맞는 구현 모델이 따로 필요합니다.
계층 구조는 그 특성상 상위 계층에서 하위 계층으로의 의존만 존재하고 하위 계층은 상위 계층에 의존하지 않습니다. 또한 엄격하게 적용하면 상위 계층은 바로 아래의 계층에만 의존을 가져야 하지만 구현의 편리함을 위해 유연하게 적용하기도 합니다.
(ex. 응용 계층이 도메인 계층에도 의존하지만 외부 시스템의 연동을 위해 더 아래 계층인 인프라 계층에 의존하기도 함)
그런데 이렇게 응용 계층이 인프라 계층에 의존하게 되면 ‘테스트 어려움’과 ‘기능 확장의 어려움’ 이라는 두 가지 문제가 발생할 수 있는데 이는 DIP를 적용해서 해결할 수 있습니다.
DIP
ex.
가격 할인 계산
이라는고수준 모듈
은 의미있는 단일 기능을 제공하는 모듈로 이 기능을 구현하려면 여러 하위 기능(고객 정보 구하기, 룰을 이용해 할인 금액 구하기)이 필요합니다.저수준 모듈
은 하위 기능을 실제로 구현한 것입니다. 고수준 모듈이 제대로 동작하려면 저수준 모듈을 사용해야 하는데 이렇게 되면 앞서 언급한 두 문제가 발생합니다.
- DIP는 이를 해결하기 위해 저수준 모듈이 고수준 모듈에 의존하도록 변경합니다. 이를 위해서는
추상화한 인터페이스
를 활용합니다.CalculateDiscountService
는Drools
에 의존하는 코드를 포함하고 있지 않으며RuleDiscounter
가 룰을 적용한다는 것만 압니다. 실제RuleDiscounter
의 구현 객체는 생성자를 통해서 전달받습니다.- 룰 적용을 구현한 클래스는
RuleDiscounter
인터페이스를 상속받아 구현합니다. - 룰을 이용한 할인 금액 계산은 고수준 모듈의 개념이므로
RuleDiscounter
인터페이스는 고수준 모듈에 속합니다. DroolsRuleDiscounter
는 고수준의 하위 기능을 구현한 것이므로 저수준 모듈에 속합니다.
- 고수준 모듈이 저수준 모듈을 사용하려면 고수준 모듈이 저수준 모듈에 의존해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존한다고 해서 이를
DIP(Dependency Inversion Principle, 의존 역전 원칙)
이라고 부릅니다. - 구현을 추상화한 인터페이스에 의존하면서, 구현 기술을 변경하더라도 사용할 저수준 구현 객체를 생성하는 부분의 코드만 변경하변 됩니다.
- 또한 의존 주입을 지원하는 스프링과 같은 프레임워크를 사용하면 설정 코들르 수정해서 쉽게 구현체를 변경할 수 있습니다.
- 테스트 역시 고수준 모듈이 저수준 모듈에 직접 의존했다면, 저수준 모듈이 만들어지기 전까지 테스트를 할 수 없었겠지만 인터페이스를 사용함에 따라 대용 객체를 사용해서 테스트를 진행할 수 있습니다. 즉 실제 구현 클래스가 없어도 테스트를 작성할 수 있습니다.
- DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 저수준 모듈 관점이 아닌 고수준 모듈인 도메인 관점에서 도출해야 합니다.
DIP와 아키텍처
인프라 영역은 구현 기술을 다루는 저수준 모듈
이고 응용 영역과 도메인 영역은 고수준 모듈
입니다. 아키텍처 수준에서 DIP를 적용하면 인프라 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조가 됩니다.
즉, 인프라 영역에 위치한 클래스가 도메인이나 응용 영역에 정의한 인터페이스를 상속받아 구현하는 구조가 되므로 도메인과 응용 영역에 대한 영향을 최소화하면서 구현 기술을 변경할 수 있습니다.
도메인 영역의 주요 구성요소
엔티티(Entity)
- 도메인 모델의 데이터를 포함하여 해당 데이터와 관련된 기능을 함께 제공합니다.
- 식별자를 갖는다는 가장 큰 특징이 있으며, 식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖습니다.
- 식별자는 엔티티 생성 -> 속성 변경 -> 삭제할 때까지 유지된다. 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있습니다.
밸류 타입(Value)
- 고유의 식별자를 갖지 않는 객체로 주로 개념적인 하나의 도메인 객체의 속성을 표현할 때 사용됩니다. 또한 다른 밸류 타입의 속성으로도 사용될 수 있습니다.
public class ShippingInfo {
//받는 사람
private String receiverName;
private String receiverPhoneNumber;
//주소
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipCode;
}
- 받는 사람과 주소 필드들은 각각 개념적으로 완전한 하나를 표현하고 있다. 따라서 밸류타입 Receiver와 Address를 만들어서 개념적으로 완전한 하나를 보다 명확하게 표현할 수 있다.
public class Receiver {
private String name;
private String phoneNumber;
//생성자, getter, setter...
}
public class Address {
private String address1;
private String address2;
private String zipcode;
//생성자, getter, setter...
}
//Value 타입 이용
public class ShippingInfo {
private Receiver receiver;
private Address address;
//생성자, getter, setter...
}
- 밸류 타입은 의미를 명확하게 표현하기 위해 사용합니다.
- 또한 밸류 타입을 위한 기능을 추가할 수 있습니다.
- 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호합니다.
- 밸류 타입을 불변(immutable)으로 구현하는 가장 중요한 이유는 보다 안전한 코드를 작성할 수 있는데에 있습니다. 불변 객체는 참조 투명성과 스레드에 안전한 특징을 갖고 있습니다.
- 두 밸류 객체가 같은지 비교할 때는 모든 속성이 같은지 비교해야 합니다.
엔티티 식별자와 밸류 타입
엔티티 식별자의 실제 데이터는 문자열로 구성된 경우가 많은데 Money가 단순 숫자가 아닌 도메인의 ‘돈’을 의미하는 것처럼 식별자가 도메인에서 특별한 의미를 지니도록 하기 위해 식별자를 위한 밸류타입을 사용할 수도 있습니다.
public class Order {
//OrderNo 타입 자체로 id가 주문번호임을 알 수 있음
private OrderNo id;
// ...
}
- OrderNo 대신 String -> id라는 이름 만으로는 주문번호인지 알 수 없음 => 필드이름이 id가 아닌 OrderNo가 됨
- String 대신 OrderNo 타입을 식별자로 => 타입 자체로 알 수 있음
애그리거트(Aggregate)
- 관련된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것입니다. 도메인 모델에서 전체 구조를 이해하는 데 도움이 됩니다.
- 애그리거트를 사용하면 개별 객체가 아닌 관련 객체를 묶어서 객체 군집 단위로 모델을 바라볼 수 있게 되며 애그리거트 간의 관계로 도메인 모델을 이해하고 구현할 수 있게 됩니다.
- 루트 엔티티는 애그리거트에 속해 있는 엔티티와 밸류 객체를 이용해서 애그리거트가 구현해야 할 기능을 제공합니다.
- ex. 주문과 관련된 Order 엔티티, OrderLine 밸류, Orderer 밸류 객체를 주문 애그리거트로 묶을 수 있습니다.
애그리거트는 복잡한 모델을 일관성 있게 관리하는 기준이 됩니다. 복잡도가 낮아지는 만큼 도메인 기능을 확장하고 변경하는데 필요 노력도 줄어듭니다.
- 애그리거트가 관련된 모델을 하나로 모은 것인만큼 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖습니다. 도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높습니다.
- 애그리거트는 경계를 가지며 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않습니다.
- ex. 주문 애그리거트는 배송지 변경 O, 주문 상품 개수 변경 O -> BUT ⚠️ 주문 애그리거트에서 회원 비밀번호 변경 X, 상품 가격 변경 X
- 애그리거트는 여러 객체로 구성되기 떄문에 도메인 규칙을 지키기 위해서는 애그리거트에 속한 모든 객체가 정상 상태를 가져야 합니다. 이때 일관된 상태를 유지하기 위해 이를 책임지는 것이 애그리거트의 루트 엔티티입니다. 일관성을 유지하기 위해서는 애그리거트 루트가 아닌 다른 객체가 애그리거트에 속한 객체를 직접 변경하면 안됩니다.
불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면,
- 단순히 필드를 변경하는 setter 메서드를 public으로 만들지 않습니다.
- 밸류 타입은 불변으로 구현합니다.
- 불필요한 setter 사용을 줄여서 도메인 로직이 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산되지 않도록 하며 구현하는 메서드의 의미를 지닐 수 있도록 합니다.
- 밸류 객체를 불변으로 만들어서 애그리거트 외부에서 밸류 객체의 상태를 변경하는 대신 새로운 밸류 객체를 할당하도록 합니다.
트랜잭션
한 트랜잭션에서는 한 개의 애그리거트만 수정하도록 합니다. 즉 애그리거트에서 다른 애그리거트를 변경하지 않도록 해서 애그리거트 간 결합도를 낮추도록 합니다. 부득이하게 한 트랜잭션에서 두 개의 애그리거트를 수정해야 할 때는 한 애그리거트에서 직접 수정하지 말고 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 합니다.
참고: 도메인 이벤트를 사용하면 한 트랜젝션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있습니다.
리포지터리(Repository)
- 도메인 모델의 영속성을 처리합니다. 애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 리포지터리는 애그리거트 단위로 존재합니다.
- 도메인 모델 관점에서 Repository Interface는 도메인 객체를 영속화하는데 필요한 기능을 추상화한 것으로 고수준 모델에 속합니다. 이를 이용해서 구현한 클래스는 저수준 모델로 인프라 영역에 속하게 됩니다.
응용 서비스와 리포지터리
- 응용 서비스는 필요한 도메인 객체를 구하거나 저장할 때 리포지터리를 사용합니다.
- 응용 서비스는 트랜잭션을 관리하는데, 트랜잭션 처리는 리포지터리 구현 기술에 영향을 받습니다.
- 리포지터리는 응용 서비스가 필요로 하는 메서드를 제공하는데 가장 기본이 되는 것이 애그리거트를 저장하는 메서드와 애그리거트 루트 식별자로 애그리거트를 조회하는 메서드입니다.
public interface SomeRepository {
void save(Some some);
Some findById(SomeId id);
}
도메인 서비스(Domain Service)
특정 엔티티에 속하지 않은 도메인 로직을 제공합니다. 도메인 서비스는 애그리거트나 밸류와 다르게 상태없이 로직만 구현합니다. 도메인 서비스를 구현하는데 필요한 상태는 다른 방법으로 전달받습니다.
도메인 서비스를 사용하는 주체는 애그리거트가 될 수도 있고 응용 서비스가 될 수도 있습니다. 또한 애그리거트 객체에 도메인 서비스를 전달하는 것은 응용 서비스의 책임이 됩니다.
도메인 서비스는 도메인 로직을 실행하므로 도메인 서비스의 위치는 다른 도메인 구성 요소와 동일한 패키지에 위치합니다.
예시 - 결제 금액 계산 로직
- 상품 애그리거트: 구매하는 상품의 가격, 배송비
- 주문 애그리거트: 구매 개수
- 할인 쿠폰 애그리거트: 쿠폰 별 할인 금액이나 비율, 중복 할인 계산
- 회원 애그리거트: 회원 등급에 따른 추가 할인
- Q. 실제 결제 금액을 계산하는 주체는?
- if) 주문 애그리거트가 필요한 애그리거트나 필요 데이터를 모두 가지게 한다면? -> 주문 애그리거트의 책임이 맞는지?
- if) 전품목 추가할인 한다면 -> 결제 금액 계산 책임이 주문 애그리거트에 있다는 이유로 주문 애그리거트를 수정하는가? ⚠️ 자신의 책임 범위를 넘어서는 기능을 구현하면 안된다! 애그리거트에 억지로 넣기 보다는 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내기
public class DiscountCalculationService {
public Money calculateDiscountAmounts(List<OrderLine> orderLines, List<Coupon> coupons, MemberGrade grade) {
Money couponDiscount = coupons.stream()
.map(coupon -> calculateDiscount(coupon))
.reduce(Money(0), (v1, v2) -> v1.add(v2));
Money membershipDiscount = calculateDiscount(orderer.getMember().getGrade());
return couponDiscount.add(membershipDiscount);
}
private Money calculateDiscount(Coupon coupon) {
...
}
private Money calculateDiscount(MemberGrade grade) {
...
}
}
public class Order {
public void calculateAmounts(DiscountCalculationService discountCalculationService, MemberGrade grade) {
Money totalAmounts = getTotalAmount();
Money discountAmounts = discountCalculationService.calculateDiscountAmounts(this.orderLines, this.coupons, grade);
this.paymentAmounts = totalAmounts.minus(discountAmounts);
}
...
}
DiscountCalculationService
를 다음과 같이 애그리거트의 결제 금액 계산 기능에 전달하면 사용 주체는 애그리거트가 됩니다.
public class OrderService {
private DiscountCalculationService discountCalculationService;
@Transactional
public OrderNo placeOrder(OrderRequest orderRequest) {
OrderNo orderNo = orderRepository.nextId();
Order order = createOrder(orderNo, orderRequest);
orderRepository.save(order);
// 응용 서비스 실행 후 표현 영역에서 필요한 값 리턴
return orderNo;
}
private Order createOrder(OrderNo orderNo, OrderRequest orderRequest) {
Member member = findMember(orderRequest.getOrdererId());
Order order = new Order(orderNo, orderRequest.getOrderLines(), orderRequest.getCoupons, createMember(member), orderRequest.getShippingInfo());
order.calculateAmounts(this.discountCalculationService, member.getGrade());
return order;
}
}
- 응용 서비스인
OrderService
가 애그리거트 객체인Order
에 도메인 서비스를 전달합니다.
public class TransferService {
public void transfer(Account fromAccount, Account toAccount, Money amounts) {
fromAccount.withdraw(amounts);
toAccount.credit(amounts);
}
}
- 애그리거트 메서드를 실행할 때 도메인 서비스를 인자로 전달하지 않고 반대로 도메인 서비스의 기능을 실행할 때 애그리거트를 전달하기도 합니다. (ex. 계좌 이체)
도메인 서비스 vs 응용 서비스
도메인 서비스는 응용 로직을 수행하지는 않습니다. 트랜잭션 처리와 같은 로직은 응용 로직이므로 도메인 서비스가 아닌 응용 서비스에서 처리해야 합니다.
Q. 특정 기능이 응용 서비스인지 도메인 서비스인지 확인하려면?
- 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사해보기
- ex. 계좌 이체 로직: 계좌 애그리거트의 상태를 변경
- ex. 결제 금액 로직: 주문 애그리거트의 주문 금액 계산
도메인 모델과 BOUNDED CONTEXT
하위 도메인마다 사용하는 용어가 다르기 때문에 올바른 도메인 모델을 개발하려면 하위 도메인마다 모델을 만들어야 하고 각 모델은 명시적으로 구분되는 경계를 가져서 섞이지 않아야 합니다.
모델은 특정한 컨텍스트(문맥) 하에서 완전한 의미를 갖습니다. 이렇게 구분되는 경계를 갖는 컨텍스트를 DDD에서는 BOUNDED CONTEXT라고 부릅니다.
BOUNDED CONTEXT는 용어를 기준으로 분리하며 실제로 사용자에게 기능을 제공하는 물리적 시스템이기 때문에 도메인 모델은 BOUNDED CONTEXT 안에서 도메인을 구현합니다.
이상적으로는 하위 도메인과 BOUNDED CONTEXT가 일대일 관계를 가지면 좋겠지만 현실적인 상황에 따라 여러 하위 도메인을 한 BOUNDED CONTEXT에서 구현하기도 합니다. 이때 하위 도메인의 모델이 뒤섞이지 않도록 하는 것이 중요합니다. 하위 도메인마다 구분되는 패키지를 갖도록 구현해야 하위 도메인을 위한 모델이 뒤섞이지 않습니다.
- ex. 같은 사용자 -> 주문 BOUNDED CONTEXT와 회원 BOUNDED CONTEXT가 갖는 모델이 달라짐
- ex. 같은 상품 -> 카탈로그 BOUNDED CONTEXT의 Product와 재고 BOUNDED CONTEXT의 Product는 각 컨텍스트에 맞는 모델을 가짐
BOUNDED CONTEXT는 도메인 모델만 포함하는 것이 아니라 도메인 기능을 사용자에게 제공하는데 필요한 표현, 응용, 인프라 영역 등을 모두 포함합니다. 도메인 모델의 데이터 구조가 바뀌면 DB 테이블 스키마도 함께 변경해야 하므로 해당 테이블도 포함됩니다.
또한 모든 BOUNDED CONTEXT가 반드시 DDD로 개발될 필요는 없으며 CRUD 방식으로 구현해도 됩니다.
두 방식을 혼합해서 사용할 수도 있는데 이를 CQRS(Command Query Responsibility Segregation) 패턴이라고 합니다.
상태를 변경하는 명령 기능과 내용을 조회하는 쿼리 기능을 위한 모델을 구분하는 패턴으로 이를 BOUNDED CONTEXT에 적용하면,
- 상태 변경과 관련된 기능은 -> 도메인 모델 기반으로 구현하고,
- 조회 기능은 -> 서비스-DAO를 이용해서 구현할 수 있습니다.
BOUNDED CONTEXT 간 통합
ex. 카탈로그 하위 도메인에 개인화 추천 기능을 도입하게 되었다면? => 사용자가 제품 상세 페이지를 볼 때, 보고 있는 상품과 유사한 상품 목록을 하단에 보여주기
- 이때 카탈로그 BOUNDED CONTEXT & 추천 BOUNDED CONTEXT 간 통합이 발생하는데 각 컨텍스트의 도메인 모델은 서로 다릅니다. 카탈로그 도메인 모델을 사용해서 추천 상품을 표현해야 하기 때문에 도메인 관점에서 인터페이스를 정의하고 도메인 모델과 외부 시스템 간의 모델 변환을 처리하는 클래스를 인프라 영역에 위치시킵니다.
- 이렇게 REST API를 호출해서 두 BOUNDED CONTEXT 직접 통합할 수 있습니다.
- 또한 메시지 큐를 사용해서 간접적으로 통합할 수도 있습니다. 사용자의 조회 상품, 구매 이력을 활용해서 추천 시스템을 구현하는데 이때 필요한 이력들을 메시지 큐에 추가합니다.
- 메시지는 비동기로 처리되기 때문에 카탈로그 BOUNDED CONTEXT는 큐에 추가한 후 추천 BOUNDED CONTEXT가 처리할 때까지 자신의 처리를 계속할 수 있습니다.
- 어떤 BOUNDED CONTEXT 관점에서 접근하느냐에 따라 메시지 데이터의 형식이 달라질 수 있습니다.
마이크로 서비스 아키텍처(MSA)는 애플리케이션을 작은 서비스로 나누어 개발하는 아키텍처 스타일로 개별 서비스를 독립된 프로세스로 실행하고 각 서비스가 REST API나 메시징을 이용해서 통신하는 구조를 갖습니다.
각 BOUNDED CONTEXT를 MSA로 구현하면 자연스럽게 컨텍스트 별로 모델이 분리되면서 별도의 프로세스로 마이크로 서비스마다 프로젝트가 생성되며 이를 독립적으로 배포하고 모니터링하고 확장할 수 있게 됩니다.