DDD START
본 블로그에서는 Entity, Value, Aggregate, Domain Service, CQRS 등 DDD의 핵심 개념과 구현 방식에 대해 자세히 설명합니다.
Contents
- Entity 와 Value
- Entity
- Value
- 표현, 응용, 도메인, 인프라스트럭처
- 도메인 영역의 주요 구성요소
- DIP (Dependency Inversion Principle)
- Aggregate
- Aggregate Root
- Application Service
- 도메인 로직 넣지 않기
- 응용 서비스의 크기
- 도메인 이벤트 처리
- Domain Service
- CQRS

Entity 와 Value
Entity
엔티티는 식별자(ID) 를 지닌다. 식별자는 어떠한 경우에도 변하지 않는다. 즉, 두 엔티티의 식별자가 같다면, 같은 엔티티라고 볼 수 있다.
Value
밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다. 예를 들어, 받는 사람을 위한 밸류 타입인 Receiver를 다음과 같이 작성할 수 있다.
public class Receiver {
private String name;
private String phoneNumber;
...
}
Receiver 는 "받는 사람" 이라는 도메인 개념을 포함한다. name과 phoneNumber 을 개별로 데이터로 받을 수도 있지만, 위에서 표현한 Receiver 는 그 자체로 받는 사람을 뜻한다. 밸류 타입을 사용함으로써 완전한 하나를 잘 표현할 수 있는 것이다.
표현, 응용, 도메인, 인프라스트럭처
| 항목 | 설명 | | --- | --- | | 표현 | (Controller) 사용자의 요청을 해석해서 응용 Service에 전달하고, 응용 Service의 실행 결과를 사용자가 이해할 수 있는 형식으로 변환해서 응답한다. | | 응용 | (Service) 응용 영역은 도메인 모델을 이용해서 사용자에게 제공할 기능을 구현한다. 실제 도메인 로직 구현은 도메인 모델에 위임한다. | | 도메인 | (Entity) 도메인 모델에서 필요로 하는 기능을 구현한다. | | 인프라스트럭처 | (Repository) 인프라스트럭처 영역은 구현 기술을 다룬다. ex. Database, SMTP |
도메인 영역의 주요 구성요소
| 항목 | 설명 |
| --- | --- |
| Entity | 고유의 식별자를 갖는 객체로 자신의 라이프사이클을 갖는다. 주문(Order), 회원(Member), 상품(Product)과 같이 도메인의 고유한 개념을 표현한다. 도메인 모델의 데이터를 포함하며 해당 데이터와 관련된 기능을 함께 제공한다. |
| Value | 고유의 식별자를 갖지 않는 객체로 주로 개념적으로 하나인 도메인 객체의 속성을 표현할 때 사용된다. 배송지 주소를 표현하기 위한 주소(Address)나 구매 금액을 위한 금액(Money)과 같은 타입이다. 앤티티의 속성으로 사용될 뿐만 아니라 다른 밸류 타입의 속성으로도 사용될 수 있다. |
| Aggregate | 애그리거트는 관련된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다. 예를 들어, 주문과 관련된 Order 엔티티, OrderLine 밸류, Orderer 밸류 객체를 주문 애그리거트로 묶을 수 있다. |
| Repository | 도메인 모델의 영속성을 처리한다. 예를 들어, DBMS 테이블에서 엔티티 객체를 로딩하거나 저장하는 기능을 제공한다. |
| Domain Service | 특정 엔티티에 속하지 않는 도메인 로직을 제공한다. "할인 금액 계산"은 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현하게 되는데, 이렇게 도메인 로직이 여러 엔티티와 밸류를 필요로 할 경우 도메인 서비스에서 로직을 구현한다. |
DIP (Dependency Inversion Principle)
DDD 의 계층 레이어 간, 고수준의 영역이 저수준 영역에 의존 관계를 지니는 경우가 있다. 주로 Infrastructure(저수준) 를 담당하는 Repository 와, Application(고수준) 을 담당하는 Service 사이에서 의존 관계가 생기는 경우가 많다. Application(Service) 가 Infrastructure(Repository) 에 의존 관계를 지니게 되면, 추후에 Infrastructure의 종류를 수정할 때, Application 의 코드까지 수정해야 하는 경우가 종종 발생한다. 이를 막기 위해서는, 저수준 Infrasturcutre 의 명세를 인터페이스로 정의하고, Application 에서는 DIP 를 이용하는 것이 좋다.
public interface RuleDiscounter {
public Money applyRules(Customer customer, List<OrderLine> orderLines);
}
public class CalculateDiscountService {
private RuleDiscounter ruleDiscounter;
public CalculateDiscountService(RuleDiscounter ruleDiscounter) {
this.ruleDiscounter = ruleDiscounter;
}
public Money calculateDiscount(OrderLine orderLines, String customerId) {
Customer customer = findCustomer(customerId);
return ruleDiscounter.applyRules(customer, orderLines);
}
}
DIP 를 적용했을 때 나타나는 가장 큰 특징은, 테스트 코드 작성에 유리하다는 점이다. 단위 테스트를 작성할 때, 가급적이면 Infrastructure 에 의존적이지 않은 테스트 코드를 작성해야 할 때가 많은데, 이 경우에 의존성 주입을 통해 Mock Infrastructure 를 생성할 수 있다.
Aggregate
백 개 이상의 테이블을 한 장의 ERD 에 모두 표시하면 개별 테이블 간의 관계를 파악하느라 큰 틀에서 데이터 구조를 이해하는 데 어려움을 겪게 되는 것처럼, 도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워진다.
복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만드려면, 상위 수준에서 모델을 바라볼 수 있는 방법이 필요한데, 그 방법이 바로 Aggregate이다. 예를 들어, Order, OrderLine, Orderer, Receiver, ShippingInfo, DeliveryTracking, Address 가 묶여서 하나의 Aggregate 가 된다.
Aggregate Root
주문 Aggregate 는 다음을 포함한다.
- 총 금액인 totalAmounts 를 가지고 있는 Order dpsxlxl
- 개별 구매 상품의 개수인 quantity 와 금액인 price 를 가지고 있는 OrderLine 밸류
구매할 상품의 개수를 변경하면 한 OrderLine의 quantity 를 변경하고, 더불어 Order 의 totalAmounts 도 변경해야 한다.
이와 같이, Aggregate 에 속한 모든 객체가 일관된 상태를 유지하려면 Aggregate 전체를 관리할 주체가 필요한데, 이 책임을 지는 것이 바로 Aggregate 의 Root Entity 이다. Aggregate Root Entity 는 Aggregate 의 대표 엔티티로 Aggregate에 속한 객체는 Root Entity 에 직접 또는 간접적으로 속한다. 위의 예시에서는 Order 가 Root Entity 가 된다.
Application Service
응용 서비스는 클라이언트가 요청한 기능을 실행한다. 응용 서비스는 사용자의 요청을 처리하기 위해 리포지터리로부터 도메인 객체를 구하고, 도메인 객체를 사용한다.
응용 서비스의 주요 역할은 도메인 객체를 사용해서 사용자의 요청을 처리하는 것이므로, 클라이언트 영역 입장에서 보았을 때 응용 서비스는 도메인 영역과 표현 영역을 연결해 주는 창구인 파사드(facade) 역할을 한다.
public Result doSomeCreation(CreateSomeReq req) {
// 1. 데이터 중복 등 데이터가 유효한지 검사한다.
checkValid(req);
// 2. 애그리거트를 생성한다.
SomeAgg newAgg = createSome(req);
// 3. 리포지터리에 애그리거트를 저장한다.
someAggRepository.save(newAgg);
// 4. 결과를 리턴한다.
return createSuccessResult(newAgg);
}
도메인 로직 넣지 않기
도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않는다. 예를 들어, 유저의 비밀번호 변경 기능을 구현한다고 가정하자. 비밀번호 암호화/해시 함수는 Service 단에 있어서는 안되고, Domain(Entity) 클래스 안에 있어야 DDD 의 원칙에 따를 수 있다.
public class ChangePasswordService {
public void changePassword(String memberId, String oldPw, String newPw) {
Member member = memberRepository.findById(memberId);
checkMember();
if(!member.checkPassword(newPw)) {
throw new BadPasswordException();
}
member.setPassword(newPw);
}
}
응용 서비스의 크기
응용 서비스의 크기는 사소하지만, 때로는 생각할 거리가 많아진다. 응용 서비스는 회원 가입하기, 회원 탈퇴하기, 회원 암호 변경하기, 비밀번호 초기화하기와 같은 긴으을 구현하기 위해 도메인 모델을 사용하게 된다. 이 경우, 응용 서비스는 보통 다음의 두 가지 방법 중 하나의 방식으로 구현한다.
- 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
- 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
전자로 구현할 경우 한 응용 서비스 파일의 크기가 지나치게 커질 수 있고, 후자로 구현할 경우 공통 함수들에 대한 재사용성이 사라질 수 있다. 한 응용 서비스 파일의 크기를 줄이고 싶은 경우, 서비스 클래스를 따로 구현한 후, Helper 를 이용해서 공통 함수를 정리할 수 있다.
// 각 응용 서비스에서 공통되는 로직을 별도 클래스로 구현
public final class MemberServiceHelper {
public static Member findExistingMember(MemberRepository repo, String memberId) {
Member member = memberRepository.findById(memberId);
if(member == null) {
throw new NoMemberException(memberId);
}
return member;
}
}
도메인 이벤트 처리
응용 서비스의 역할 중 하나는 도메인 영역에서 발생시킨 이벤트를 처리하는 것이다. 여기서 이벤트는 도메인에서 발생한 상태 변경을 의미하며 "암호 변경됨", "주문 취소함" 과 같은 것이 이벤트가 될 수 있다.
public class Member {
private Password password;
public void initializePassword() {
String newPAssword = generateRandomPassword();
this.password = new Password(newPassword);
Events.raise(new PasswordChangedEvent(this.id, password));
}
}
...
public class InitPasswordService {
@Transactional
public void initializePassword(String memberId) {
Events.handle((PasswordChangedEvent evt) -> {
// evt.getId()에 해당하는 회원에게 이메일 발송하는 기능 구현
});
Member member = memberRepository.findById(memberId);
checkMemberExists(member);
member.initializePassword();
}
}
이벤트를 사용하면 코드가 다소 복잡해지는 대신 도메인 간의 의존성이나 외부 시스템에 대한 의존을 낮춰주는 장점을 얻을 ㅅ ㅜ있다. 또한 시스템을 확장하는 데에 이벤트가 핵심 역할을 수행하게 된다.
Domain Service
할인 금액 규칙 계산처럼 한 애그리거트에 넣기 애매한 도메인 개념을 구현하려면 애그리거트에 억지로 넣기보다는 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면 된다. 응용 영역의 서비스가 응용 로직을 다룬다면 도메인 서비스는 도메인 로직을 다룬다.
도메인 서비스가 도메인 영역의 애그리거트나 밸류와 같은 다른 구셩요소와 비교할 때 다른 점이 있다면 상태 없이 로직만 구현한다는 점이다. 도메인 서비스를 구현하는데 필요한 상태는 애그리거트나 다른 방법으로 전달받는다.
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) {
...
}
}
CQRS
시스템이 제공하는 기능은 크게 두 가지로 나누어 생각해 볼 수 있다. 하나는 상태를 변경하는 기능이다. 새로운 주문을 생성하거나, 배송지 정보를 변경하거나, 회원의 암호를 변경하는 기능이 이에 해당한다. 또 다른 하나는 사용자 입장에서 상태 정보를 조회하는 기능이다. 주문 상세 내역 보기, 게시물 목록 보기, 회원 정보 보기, 판매 통계 보기가 이에 해당한다.
CQRS(Command Query Responsibility Segregation) 은 상태를 변경하는 명령(Command) 을 위한 모델과 상태를 제공하는 조회(Query) 를 위한 모델을 분리하는 패턴이다. CQRS 는 복잡한 도메인에 적합하다. 도메인이 복잡할수록 명령 기능과 조회 기능이 다루는 데이터 범위에 차이가 발생하는데, 이 두 기능을 단일 모델로 처리하게 되면 조회 기능의 로딩 속도를 위해 모델 구현이 필요 이상으로 복잡해지는 문제가 발생한다. 예를 들어, 온라인 쇼핑에서 다양한 차원에서 주문/판매 통계를 조회해야 한다. JPA 기반의 단일 도메인 모델을 사용하면 통계 값을 빠르게 조회하기 위해 JPA 와 관련된 다양한 성능 관련 기능을 모델에 적용해야 한다. 이런 도메인에 CQRS 를 적용하면 통계를 위한 조회 모델을 별도로 만들기 때문에 조회 때문에 메인 모델이 복잡해지는 것을 막을 수 있다.
CQRS 를 사용하면 각 모델에 맞는 구현 기술을 선택할 수 있다. 예를 들어, 명령 모델은 객체 지향에 기반해서 도메인 모델을 구현하기에 적합한 JPA 를 사용하여 구현하고, 조회 모델은 DB 테이블에서 SQL 로 데이터를 조회할 때 좋은 MyBatis 를 사용해서 구현할 수 있다. 더 나아가, 명령 모델과 조회 모델이 서로 다른 DB 를 사용하는 것도 가능하다. 도 DB 가 이벤트를 기반으로 동기화 되어 있고 명령(Command)을 위해서는 RDBMS 로, 조회(Query) 을 위해서는 NoSQL 로 Infrastructure 를 구성할 수 있다.
이것도 읽어보세요