콘텐츠로 이동

DDD (Domain-Driven Design) Basics

분류: Layer 9 - 아키텍처 & 설계 패턴

DDD(Domain-Driven Design)는 비즈니스 도메인을 코드 구조의 1차 시민으로 끌어올리기 위해, 도메인을 Bounded Context 로 분리하고 그 안에서 Ubiquitous Language, Aggregate, Value Object, Domain Event 같은 전술 패턴으로 구조화하는 설계 방법론이다. Eric Evans가 2003년 정립했다.

도메인 복잡도가 일정 수준을 넘으면, 코드가 비즈니스 언어와 따로 놀기 시작한다. 기획자가 “주문을 확정한다”라고 말하는데 코드는 order.setStatus("CONFIRMED") 같은 식이다 — 이 거리감이 쌓이면 모든 변경이 번역 비용을 동반한다. DDD는 도메인 언어코드 모델 을 정렬시켜 그 거리를 0으로 줄이고, 동시에 Bounded Context 분리로 “한 팀의 변경이 다른 팀을 깨뜨리는” 분산 모놀리스 함정을 회피한다. 모듈러 모놀리스 → MSA 진화 경로의 설계 근거.

선수지식: clean-architecture.md(레이어 분리 기반). 후속: cqrs-event-sourcing.md, saga-pattern.md, msa-patterns.md.

같은 회사에서도 팀마다 같은 단어가 다른 의미를 가진다.

  • 회계팀의 “고객”: 세금계산서를 발행해야 하는 법인/개인 → 사업자번호, 세금 정보가 중요
  • 마케팅팀의 “고객”: 캠페인 타겟 → 구매 이력, 선호도가 중요
  • 배송팀의 “고객”: 배송지가 있는 수령인 → 주소, 연락처가 중요

이 세 팀이 하나의 “고객” 테이블을 공유하면 서로의 변경이 충돌한다. DDD는 이 문제를 Bounded Context로 해결한다.

원리: 전략적 설계 (Strategic Design)

섹션 제목: “원리: 전략적 설계 (Strategic Design)”

큰 그림을 어떻게 나눌지 결정한다.

① Bounded Context (경계 컨텍스트)

각 팀/도메인이 자신만의 모델을 가지는 경계. 경계 안에서는 모델이 일관성을 유지한다.

[ 주문 Context ] [ 배송 Context ] [ 정산 Context ]
Order Shipment Invoice
OrderItem DeliveryAddress Payment
Customer (구매자) Customer (수령인) Customer (세금정보)

같은 “Order”라도 각 Context에서 다른 속성을 가진다.

② Ubiquitous Language (유비쿼터스 언어)

개발자와 도메인 전문가(기획자, 비즈니스 담당자)가 동일한 용어를 사용해야 한다. “주문을 확정한다”가 비즈니스 용어라면 코드에도 order.confirm()이어야 한다. order.setStatus("CONFIRMED")가 아니라.

③ Context Map

여러 Bounded Context 사이의 관계를 정의한다.

주문 Context → (Anti-Corruption Layer) → 외부 결제 시스템 PG사
주문 Context ← (Publisher/Subscriber) → 배송 Context

”왜 Bounded Context 분리가 필수인가” — 공유 모델의 함정

섹션 제목: “”왜 Bounded Context 분리가 필수인가” — 공유 모델의 함정”

Bounded Context 없이 하나의 “Customer” 모델을 공유하면, 각 팀이 필요한 필드를 계속 추가하면서 “신(God) 객체”가 탄생한다. 필드가 50개 넘는 Customer 테이블이 만들어지고, 한 팀의 스키마 변경이 다른 팀 전체를 깨뜨린다.

공유 모델의 시간 경과:
[Month 1] Customer { id, name, email } → 깔끔
[Month 3] Customer { id, name, email, address, phone,
taxId, preferredBrand, lastCampaign } → 비대해짐
[Month 6] Customer { ... 30개 필드 ...
+ 팀 A가 추가한 필드가 팀 B의 마이그레이션을 깨뜨림 }
→ Bounded Context 분리 후:
주문 Context: Customer { id, name, email }
배송 Context: Recipient { name, address, phone }
정산 Context: Payer { name, taxId, bankAccount }
각 팀이 독립적으로 모델을 변경 가능

📖 더 보기: Martin Fowler: Bounded Context — Bounded Context의 정의와 Context Map 관계 유형 (중급)

원리: 전술적 설계 (Tactical Design)

섹션 제목: “원리: 전술적 설계 (Tactical Design)”

경계 안의 코드를 어떻게 구조화할지 결정한다.

개념설명예시
Entity고유한 ID로 식별되는 객체. 속성이 바뀌어도 동일 객체Order(id="123") - 상태가 바뀌어도 같은 주문
Value ObjectID 없이 속성 값으로 동일성 판단. 불변Money(amount=1000, currency="KRW")
Aggregate연관 객체의 묶음. 외부에서는 루트(Root)를 통해서만 접근Order(루트) + OrderItem
RepositoryAggregate의 영속성 관리 인터페이스OrderRepository.findById()
Domain Event도메인에서 발생한 중요 사건OrderConfirmed, PaymentFailed
Domain Service특정 엔티티에 속하기 어려운 비즈니스 로직PricingService.calculateDiscount()

”왜 Aggregate Root가 비즈니스 규칙을 지켜야 하는가” — 불변식(Invariant) 보호

섹션 제목: “”왜 Aggregate Root가 비즈니스 규칙을 지켜야 하는가” — 불변식(Invariant) 보호”

Aggregate Root의 핵심 역할은 **불변식(Invariant)**을 보호하는 것이다. 불변식이란 “어떤 상황에서도 반드시 참이어야 하는 비즈니스 규칙”이다.

예를 들어 “주문 총 금액은 절대 음수일 수 없다”, “확정된 주문에는 상품을 추가할 수 없다”가 불변식이다. 외부에서 OrderItem을 직접 수정하면 이 규칙이 깨질 수 있으므로, 반드시 Aggregate Root(Order)를 통해서만 상태를 변경해야 한다.

Aggregate Root 없이 직접 접근하는 경우:
// 외부 코드가 OrderItem을 직접 수정
order.items[0].quantity = -5; // 불변식 깨짐! (음수 수량)
order.items.push(new OrderItem(...)); // 확정 상태인데 추가됨!
→ 데이터 무결성 붕괴, 결제 오류, 정산 오류로 이어짐
Aggregate Root를 통한 접근:
order.addItem('prod-1', price, 2); // Root가 상태 검증 후 추가
// → PENDING 상태가 아니면 DomainException 발생
// → 수량이 0 이하면 DomainException 발생
// → 총 금액 자동 재계산

이 패턴의 핵심 장점은 트랜잭션 일관성이다. Aggregate 내부의 모든 변경은 하나의 트랜잭션으로 저장된다. 부분 성공/부분 실패가 불가능하다.

📖 더 보기: DDD Aggregates in Practice (Medium) — Aggregate 설계 시 경계 설정과 불변식 보호 실전 예시 (중급)

// Value Object 예시 - 불변, ID 없음
export class Money {
constructor(
private readonly amount: number,
private readonly currency: string,
) {
if (amount < 0) throw new Error("금액은 0 이상이어야 합니다");
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("다른 통화끼리는 더할 수 없습니다");
}
return new Money(this.amount + other.amount, this.currency); // 새 객체 반환 (불변)
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
getAmount(): number {
return this.amount;
}
getCurrency(): string {
return this.currency;
}
}
// 사용 예
const price = new Money(1000, "KRW");
const discount = new Money(100, "KRW");
const finalPrice = price.add(discount); // 새 Money 객체
// price는 여전히 1000원 (불변성 보장)
// Aggregate Root 예시
export class Order {
// Aggregate Root
private readonly id: string;
private status: OrderStatus;
private items: OrderItem[] = []; // OrderItem은 외부에서 직접 접근 불가
private totalPrice: Money;
// 외부에서 OrderItem에 직접 접근하는 것을 막음
// Order를 통해서만 아이템 추가/삭제 가능
addItem(productId: string, price: Money, quantity: number): void {
if (this.status !== OrderStatus.PENDING) {
throw new DomainException("PENDING 상태에서만 상품을 추가할 수 있습니다");
}
const item = new OrderItem(productId, price, quantity);
this.items.push(item);
this.recalculateTotal();
}
// Domain Event 발행
confirm(): OrderConfirmedEvent {
if (this.items.length === 0) {
throw new DomainException("주문 상품이 없습니다");
}
this.status = OrderStatus.CONFIRMED;
return new OrderConfirmedEvent(this.id, this.totalPrice); // 이벤트 반환
}
private recalculateTotal(): void {
this.totalPrice = this.items.reduce(
(sum, item) => sum.add(item.getSubtotal()),
new Money(0, "KRW"),
);
}
}
예상 출력 구조:
새 주문 생성 후:
- order.status = PENDING
- order.items = []
addItem() 호출 후:
- order.items = [OrderItem { productId: 'prod-1', price: Money(1000, KRW), quantity: 2 }]
- order.totalPrice = Money(2000, KRW)
confirm() 호출 후:
- order.status = CONFIRMED
- 반환값: OrderConfirmedEvent { orderId: '...', totalPrice: Money(2000, KRW) }
확정된 주문에 addItem() 시도:
- throws DomainException: 'PENDING 상태에서만 상품을 추가할 수 있습니다'

”모듈러 모놀리스 → MSA 전환” — DDD가 설계하는 진화 경로

섹션 제목: “”모듈러 모놀리스 → MSA 전환” — DDD가 설계하는 진화 경로”

실무에서 처음부터 MSA로 시작하면 아직 불명확한 도메인 경계 때문에 나중에 서비스를 다시 합쳐야 하는 역설이 발생한다. DDD의 Bounded Context를 활용한 **모듈러 모놀리스(Modular Monolith)**는 이 문제를 해결하는 현실적 전략이다.

진화 단계:
[1단계] 일반 모놀리스 [2단계] 모듈러 모놀리스
src/ src/
users/ orders/ ← 명확한 경계
orders/ domain/
payments/ application/
shared/ ← 경계 없이 뒤섞임 infrastructure/
payments/ ← 독립 모듈
users/
shared/kernel/ ← 최소 공유만
[3단계] MSA (경계가 검증된 후)
order-service/ → 독립 배포
payment-service/ → 독립 배포
user-service/ → 독립 배포

왜 모듈러 모놀리스가 좋은 중간 단계인가?

  • 각 모듈이 자체 DB 테이블을 소유하고, 다른 모듈의 DB를 직접 조회하지 않는다
  • 모듈 간 통신은 인터페이스를 통해서만 한다 → 나중에 HTTP/메시지큐로 교체 가능
  • 하나의 프로세스로 배포되므로 MSA의 분산 시스템 복잡도가 없다
  • 경계가 잘 잡혔다고 판단되면, 모듈을 독립 서비스로 추출하는 것이 자연스러워진다
// NestJS 모듈러 모놀리스 예시 — 모듈 간 통신은 반드시 인터페이스로
// orders/application/ports/payment.port.ts (Domain/Application 레이어)
export interface PaymentPort {
charge(
orderId: string,
amount: number,
): Promise<{ success: boolean; txId: string }>;
}
// orders/orders.module.ts — 다른 모듈의 구현체를 DI로 주입
@Module({
providers: [
CreateOrderUseCase,
{
provide: "PaymentPort",
useClass: PaymentAdapter, // payments 모듈의 어댑터 (나중에 HTTP 클라이언트로 교체)
},
],
})
export class OrdersModule {}
핵심 원칙:
orders 모듈 → TypeORM으로 orders 테이블 직접 쿼리 ✅
orders 모듈 → TypeORM으로 payments 테이블 직접 쿼리 ❌ (모듈 경계 위반)
orders 모듈 → PaymentPort 인터페이스 호출 ✅

📖 더 보기: Mastering DDD with NestJS — A Final Reflection (Codanyks) — NestJS에서 DDD 전 과정을 적용한 6편 시리즈의 최종 정리. 모듈러 모놀리스부터 MSA 전환 관점까지 포함 (중급)

DDD 패턴은 잘못 설계하면 오히려 시스템을 더 복잡하게 만든다.

Aggregate가 너무 커지는 신호:

Order Aggregate가 OrderItem, Shipment, Payment, Review까지 포함하는 경우:
- 하나의 트랜잭션으로 묶이면 Lock 경쟁 심화 (동시 주문 처리 시 병목)
- Aggregate 저장 p95가 SLO 한계 초과 → 경계 재분리 신호
(예: 동시 RPS 50, PostgreSQL row lock 기준 p95 100ms 초과를 자체 경고선으로 두는 식.
절대 임계값이 아닌 환경별 측정값으로, 권위 있는 단일 숫자는 존재하지 않는다.
Vaughn Vernon "Effective Aggregate Design"도 절대 ms 임계값 대신 "small aggregates" 원칙만 제시)
- 팀원이 Aggregate 내부 구조를 외워야 하는 경우 → 책임 과부하
해결: Vaughn Vernon "Design Small Aggregates" 권고(한 트랜잭션 = 한 Aggregate 변경)에 따라
Order, Shipment, Payment를 각각 독립 Aggregate로 분리하고
Domain Event(OrderConfirmed)로 느슨하게 연결한다.

Bounded Context 분리가 실패하는 조건:

Context 간 동기 HTTP 호출이 많아지는 경우:
- 주문 Context → 결제 Context → 배송 Context (동기 체인)
- 결제 Context 장애 시 주문 Context까지 전파 → 분산 모놀리스
→ 이벤트 기반 비동기 통신으로 전환 필요 (SQS, Kafka)
Shared Kernel이 비대해지는 경우:
- 여러 Context가 공유하는 타입/유틸이 늘어나면 Context 독립성 상실
- Kernel 변경 시 모든 Context 영향 → 의존성 역전 원칙 위반
→ Kernel을 최소화(ID 타입, 공용 이벤트 인터페이스만)하거나 Context를 재통합
AB차이점
EntityValue ObjectEntity는 ID로 식별, 가변. VO는 속성 값으로 동일성 판단, 불변
AggregateEntityAggregate는 연관 Entity의 묶음 + Root + 불변식. Entity는 그 단위 객체
Aggregate Root모든 Entity외부 접근의 유일한 진입점 . 트랜잭션 경계 = Aggregate 경계
Bounded Context마이크로서비스Bounded Context는 개념적 경계 (한 프로세스 안에서도 가능). MSA는 물리적 경계 . 보통 Context = 서비스 1개로 매핑
Ubiquitous Language도메인 용어 사전Ubiquitous Language는 코드에까지 강제 된 언어. 사전은 산출물의 일종
Domain Event일반 이벤트(Kafka)Domain Event는 도메인 의미가 있는 사건 (OrderConfirmed). Kafka 이벤트는 인프라 수단. Domain Event를 Kafka로 발행하는 형태
Domain ServiceApplication ServiceDomain Service는 도메인 로직 (PricingService). Application Service는 Use Case 조율 (use case = application service)
  • “공유 Customer 모델의 시간 경과” 시나리오로 Bounded Context 필요성을 설명할 수 있는가?
  • Ubiquitous Language가 코드 메서드 이름에 어떻게 반영되어야 하는지 예시를 들 수 있는가?
  • Aggregate Root가 불변식을 보호하는 방식(addItem이 상태 검증 + 자동 재계산)을 코드로 보일 수 있는가?
  • Aggregate가 너무 커지는 신호 3가지(Lock 경쟁, p95 SLO 초과, 책임 과부하)를 들 수 있는가?
  • 모듈러 모놀리스 → MSA 진화 경로에서 모듈러 모놀리스가 왜 좋은 중간 단계인지 설명할 수 있는가?
  1. Bounded Context: 도메인을 경계로 나눠 각자의 모델을 유지. 공유 모델은 시간 경과로 신 객체가 된다.
  2. Ubiquitous Language: 도메인 전문가와 개발자가 같은 용어를 사용. 코드 메서드 이름(order.confirm())에까지 반영.
  3. Aggregate Root: 불변식을 보호. 외부 접근은 Root를 통해서만. 트랜잭션 경계 = Aggregate 경계.
  4. 모듈러 모놀리스 → MSA: DDD가 설계하는 진화 경로. 처음부터 MSA는 도메인 경계 미확정 상태의 함정.
  5. 깨지는 조건: Aggregate가 너무 크면 Lock 경쟁, Context 간 동기 호출 많으면 분산 모놀리스, Shared Kernel 비대화는 Context 독립성 상실.