DDD의 개념과 등장 배경
💡복잡한 소프트웨어 시스템을 설계할 때, 도메인의 복잡성을 관리하고, 비즈니스 요구사항을 효과적으로 반영하기 위한 소프트웨어 설계 접근 방식
소프트웨어를 설계할 때 요구사항을 정확히 이해하는 것이 우선시 되어야 한다. 요구사항을 제대로 이해하지 못하면 잘못된 설계를 하게 되고, 이를 개발까지 끌고 갔다면 수정하는 데 더 많은 시간과 비용이 들게 된다. 뿐만 아니라, 쓸모 없고 유용하지 않은 시스템이 될 가능성이 크다.
과거에는 주로 기술 중심의 개발 방법론(메모리를 관리하기 위한, 개발적 리소스를 관리하기 위한)이 사용되었기 때문에 기술적 요구사항을 중점적으로 다루고, 비즈니스 측면의 요구사항을 효과적으로 반영하는데 한계가 있었다.
특히, 비즈니스 전문가와 개발자 간의 소통이 원활하지 않으면, 최종 소프트웨어가 비즈니스의 실제 요구를 충족시키지 못할 수 있었다고 하는데, 이러한 문제점들을 해결하기 위해 나온 설계가 도메인 주도 설계(=DDD,Domain Driven Design)
이다.
Domain(도메인)
이란 소프트웨어로 해결하려는 문제의 영역을 의미한다.
예를 들어 회원, 상품, 주문 등이 도메인이 될 수 있다.
또한, 하나의 도메인 내부에는 하위 도메인이 존재할 수 있다. (예: 회원 프로필, 상품 상세, 주문 상품 등)
DDD의 핵심 가치
DDD는 소프트웨어 개발에서 도메인 지식이 가장 중요한 요소임을 인식하고, 이를 중심으로 소프트웨어를 설계한다.
예를 들어 OrderState를 다음과 같이 구현했다고 가정
public enum OrderState {
STEP1, STEP2, STEP3, STEP4
}
어떤 개발자가 주문 상태에 결제 대기중
, 상품 준비중
, 배송중
, 배송 완료
상태가 있다고 가정하고 STEP1, STEP2, STEP3, STEP4로 구현할 경우 비즈니스 로직을 아래와 같이 작성할 가능성이 높다.
public boolean verifyStep1OrStep2(OrderState state) {
return state == OrderState.STEP1 || state == OrderState.STEP2;
}
DDD는 유비쿼터스 언어(Ubiquitous Language)의 사용을 통해 도메인 전문가와 개발자 간의 의사소통을 향상시킨다.
이는 도메인 전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들어, 프로젝트 전반에 걸쳐 일관된 용어와 개념을 사용함으로써 도메인의 복잡성을 명확하게 전달하고 이해할 수 있다.
이렇게 함으로써 용어의 모호함을 줄이고, 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다. 시간이 지남에 따라 도메인에 대한 이해가 깊어지면, 새로 이해한 내용을 잘 표현할 수 있는 용어를 찾아 이를 공통의 언어로 만들어 함께 사용한다.
public enum OrderState {
WAITING, PREPARING, DELIVERING, DONE
}
위와 같은 코드가 더 올바른 방식이겠다.
DDD 구성 요소와 구조
DDD 구성 요소
도메인 모델(Domain Model)
도메인 모델은 도메인 주도 설계(DDD)에서 핵심 “개념”을 표현하는 방법이다.
이는 특정 문제 영역(도메인)에 대한 지식, 규칙, 그리고 로직을 추상화하여 개념적으로 표현한 것이다.
예를 들어, 전자상거래(E-Commerce) 시스템에서 도메인 모델은 주문, 결제, 배송 같은 개념과 그들 간의 관계를 표현할 수 있다.
도메인 모델을 만들기 위해서는 핵심 구성 요소, 규칙, 기능을 파악해야 한다.
서비스의 요구사항을 분석하고 관련 기능들을 묶어서 구조화를 해야 한다.
도메인 모델은 크게 엔티티(Entity)와 값 객체(Value Object)로 나눌 수 있다.
엔티티(Entity)는 고유의 식별자를 갖는 객체로 자신의 라이프 사이클을 갖는다.
주문(Order), 회원(Member), 상품(Product)과 같이 도메인의 고유한 개념을 표현한다.
엔티티는 단순히 데이터를 담고 있는 데이터 구조라기보다는 데이터와 함께 기능을 제공하는 객체다.
도메인 관점에서 기능을 구현하고 기능 구현을 캡슐화해서 데이터가 임의로 변경되는 것을 막을 수 있다.
값 객체(Object Value)는 고유의 식별자를 갖지 않는 객체로 주로 개념적으로 하나인 값을 표현할 때 사용된다.
배송지 주소를 표현하기 위한 주소(Address)나 구매 금액을 위한 금액(Price)와 같은 것들이 Value이다.
엔티티의 속성으로 사용할 뿐만 아니라 다른 Value 타입의 속성으로도 사용할 수 있다.
도메인 서비스(Domain Service)
특정 엔티티에 속하지 않는 도메인 로직을 담당한다.
예를 들어, "할인 금액 계산"은 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 고려하여 이루어지는데,
이 로직의 주체가 명확하지 않을 때가 있다.
이처럼 여러 엔티티와 값이 필요한 도메인 로직은 도메인 서비스에서 구현할 수 있다.
집합(Aggregate)
만약 위와 같은 그림의 도메인 모델을 구성했을 때 어떤 도메인 모델이 어떤 역할을 하는지 쉽게 판단이 되지 않는다. 이처럼 개별 객체 단위에서 상위 객체의 개념을 파악하려면 오랜 시간이 걸린다. 그리고 주요 도메인 개념 간의 관계를 파악하기 어렵다는 것은 곧 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다.
이것이 집합(Aggregate)이 필요한 이유이다. 집합이란 관련된 객체들을 모아 하나의 단위로 취급하는 개념이며, 연관 도메인을 집합으로 묶어 하나의 군집으로 이해한다면 좀 더 상위 수준에서 도메인 모델 간의 관계를 파악할 수 있다.
집합에 속한 모든 객체가 일관된 상태를 유지하려면 집합 전체를 관리할 주체가 필요하다.
그래서 집합은 특정 도메인 군집에 속한 객체들을 관리하는 루트 엔티티
를 가진다.
하나의 집합에는 반드시 하나의 루트 엔티티가 있으며, 여러 개의 엔티티와 값 객체들이 포함될 수 있다.
루트 엔티티는 집합 내의 엔티티와 값 객체를 활용해, 집합이 수행해야 할 기능들을 제공한다.
집합을 사용하는 코드는 집합이 제공하는 기능을 실행하며, 루트 엔티티를 통해 간접적으로 집합 내의 다른 엔티티나 값 객체에 접근할 수 있다. 이 방식은 집합의 내부 구현을 숨겨서, 집합 단위로 캡슐화할 수 있도록 한다.
- 예시
- 루트 엔티티의 메서드를 통해 간접적으로 OrderProduct(하위 엔티티)에 접근한다. 이를 통해 하위 엔티티들은 루트 엔티티의 라이프사이클에 따르게 된다.
public class Order {
...
// 주문에 상품 추가 메서드
public void addProduct(OrderProduct product) {
productList.add(product);
}
// 주문에 상품 삭제 메서드
public void removeProduct(OrderProduct product) {
productList.remove(product);
}
}
DDD 구조
DDD도 Layered Architecture와 같이 Presentation, Application, Domain, Infrastructure 4개의 계층으로 이루어져 있으며
고수준 모듈이 저수준 모듈에 의존하지 않는 구조로 개발이 필요하다. 만약 불가피하게 Application 계층에서 Infrastructure의 코드를 사용할 경우 의존역전원칙(DIP)을 이용하여 하위 계층에 의존하지 않는 형태로 개발해야 한다.
DDD(Domain Driven Design) 프로젝트 구조도 예시
com.example.myapp
├── application
│ ├── service
│ │ └── OrderService.java
│ ├── dto
│ │ └── OrderDTO.java
├── domain
│ ├── model
│ │ ├── Order.java
│ │ ├── Product.java
│ │ └── ValueObject.java
│ ├── repository
│ │ └── OrderRepository.java
│ └── service
│ └── OrderDomainService.java
├── infrastructure
│ ├── repository
│ │ ├── JpaOrderRepository.java
│ │ └── OrderRepositoryImpl.java
│ ├── configuration
│ │ └── DatabaseConfig.java
│ └── messaging
│ └── KafkaMessageProducer.java
└── presentation
├── controller
│ └── OrderController.java
└── request
└── OrderRequest.java
JPA 에서의 DDD
DDD 에서는 도메인 모델을 관리할 때 비즈니스 로직과 도메인 모델 간의 결합도를 낮추기 위해 Repository 패턴을 권장한다. Repository 패턴은 특정 도메인 모델을 관리하는 메서드를 Repository라는 이름의 클래스로 구성하여 "이 도메인 모델의 변경은 이 리포지토리만을 통해서만 가능하다"라고 제한하여 유연한 구조를 가져갈 수 있는 설계 패턴이다. 이는 Spring JPA에서의 JpaRepository와 매우 유사하기 때문에 JPA를 사용할 경우 별다른 구성 없이 Repository 패턴을 사용할 수 있다.
또한, DDD에서는 도메인 모델이 애플리케이션의 핵심이다. 그리고 JPA는 @Entity 어노테이션을 통해 객체 지향적인 방식으로 데이터베이스와 상호작용할 수 있게 해주며, 도메인 객체를 Jpa 엔티티에 그대로 사용할 수 있다. JPA Entity에 DDD를 적용할 경우 데이터베이스 테이블에 대한 매핑을 하면서도 도메인 모델의 순수성을 유지할 수 있다. 그리고 JPA는 Persistence Context, Lazy Loading, Cascade, 연관 관계 매핑 등의 기능을 제공하여 DDD의 도메인 모델을 Spring에 쉽게 구현할 수 있게 한다.
결국 JPA는 객체 지향 설계를 지원하고, 도메인 모델을 중심으로 설계된 애플리케이션에서 효과적으로 사용할 수 있는 다양한 기능을 제공하기 때문에 DDD와 자연스럽게 어울릴 수 있다.
하나의 애그리거트에는 반드시 하나의 루트 엔티티가 있다. 그리고 이 루트 엔티티는 하위 엔티티들을 관리한다.
그럼 실제 JPA에서는 이 루트 엔티티와 하위 엔티티를 어떻게 구현할 수 있을까?
정답은 JpaRepository를 루트 엔티티에만 구현하는 것이다.
이렇게 하면, 루트 엔티티가 하위 엔티티들을 관리하고, 영속성 관련 로직도 루트 엔티티에 집중할 수 있다.
Order(루트 엔티티) → OrderProduct (하위 엔티티) → Receipt (하위 엔티티)의 가격 정보를 수정하는 예시
@Transactional
public void updateOrderProductReceiptPrice(Long id, Long orderProductId, Long receiptId, Long price) {
orderRepository.findById(id)
.flatMap(order -> order.getProductId(orderProductId)
.flatMap(orderProduct -> orderProduct.findReceipt(receiptId)))
.ifPresent(receipt -> receipt.update(price));
}
JpaRepository는 Order에만 구현되어 있다.
MSA 에서의 DDD
MSA가 보편화되면서 DDD가 주목받게 되었다고 한다.
왜 MSA에서 DDD가 부각되었을까?
이는 모놀리식 아키텍처를 MSA로 전환하는 과정에서 서비스를 식별하고 분석 및 설계하는 데 DDD가 큰 도움을 주기 때문이다.
복잡한 비즈니스 로직이나 모놀리식 애플리케이션을 MSA로 전환할 때, 가장 중요한 것은 서비스의 경계를 명확히 나누는 것이다.
예를 들어, 모놀리식 E-Commerce 플랫폼을 MSA로 전환할 때는 주문, 상품, 리뷰와 같은 도메인 개념을 기준으로 마이크로서비스를 구축하게 된다.
DDD는 도메인 모델을 중심으로 비즈니스 로직을 정의하고, 이를 독립적인 도메인 경계로 나눈다.
따라서 MSA와 DDD를 함께 사용하면, 도메인 경계를 기반으로 각 도메인에 해당하는 마이크로서비스를 정의할 수 있다.
이렇게 하면 각 마이크로서비스는 단일 책임 원칙을 따르고, 특정 비즈니스 기능에 집중할 수 있게 된다.
Ref
https://f-lab.kr/insight/domain-driven-design