목차
1. 왜 DDD인가: 복잡한 도메인을 다루는 구조적 접근
소프트웨어의 본질적 복잡성은 기술이 아니라 **도메인**에서 비롯됩니다. Eric Evans가 2003년 저서 "Domain-Driven Design: Tackling Complexity in the Heart of Software"에서 제안한 DDD는 이 복잡성에 정면으로 맞서는 설계 철학입니다.
1.1 전통적 접근의 한계
많은 프로젝트가 데이터베이스 스키마부터 설계합니다. 테이블을 만들고, CRUD API를 작성하고, UI를 붙입니다. 단순한 애플리케이션에서는 문제가 없지만, 도메인이 복잡해지면 한계에 부딪힙니다.
**증상들:**
- 비즈니스 로직이 Service 클래스 여기저기에 흩어져 있음
- 개발자와 도메인 전문가가 서로 다른 언어를 사용
- "주문"이라는 단어가 영업팀, 물류팀, 결제팀에서 다른 의미로 쓰임
- 하나의 변경이 예상치 못한 곳에 연쇄적으로 영향을 미침
- 모델이 점점 거대해지며 아무도 전체를 이해하지 못함
1.2 DDD가 해결하는 것
DDD는 두 가지 수준에서 복잡성을 다룹니다:
| 수준 | 관심사 | 핵심 도구 |
|------|--------|-----------|
| **전략적 설계** | 큰 그림, 팀 간 경계 | Bounded Context, Context Map, Ubiquitous Language |
| **전술적 설계** | 코드 수준 모델링 | Entity, Value Object, Aggregate, Repository, Domain Event |
1.3 DDD가 적합한 프로젝트
DDD는 만능이 아닙니다. 다음 기준으로 판단하세요:
- **적합**: 복잡한 비즈니스 규칙, 여러 하위 도메인, 도메인 전문가와 긴밀한 협업 필요
- **부적합**: 단순 CRUD, 기술 중심 프로젝트(파일 변환 등), 프로토타입/MVP
2. 전략적 패턴: 큰 그림 설계
전략적 패턴은 시스템을 어떻게 나누고 팀 간 관계를 어떻게 정의하는지에 대한 것입니다.
2.1 Ubiquitous Language (유비쿼터스 언어)
DDD의 가장 근본적인 개념입니다. 개발팀과 도메인 전문가가 **동일한 용어**를 사용해야 합니다.
**나쁜 예:**
- 도메인 전문가: "고객이 주문을 넣으면..."
- 개발자 코드: `user.createRequest()`
**좋은 예:**
- 도메인 전문가: "고객이 주문을 배치하면..."
- 개발자 코드: `customer.placeOrder()`
유비쿼터스 언어는 다음 모든 곳에서 일관되어야 합니다:
1. 코드 (클래스명, 메서드명, 변수명)
2. 데이터베이스 스키마
3. API 엔드포인트
4. 문서
5. 팀 대화
// 나쁜 예: 기술 중심 네이밍
class DataProcessor {
processRecord(data: Record<string, unknown>): void {
// ...
}
}
// 좋은 예: 유비쿼터스 언어 반영
class OrderFulfillmentService {
fulfillOrder(order: Order): FulfillmentResult {
// ...
}
}
2.2 Bounded Context (바운디드 컨텍스트)
같은 용어가 다른 맥락에서 다른 의미를 가질 수 있습니다. "상품(Product)"이라는 단어를 생각해보세요:
- **카탈로그 컨텍스트**: 이름, 설명, 이미지, 카테고리
- **재고 컨텍스트**: SKU, 수량, 창고 위치
- **가격 컨텍스트**: 정가, 할인율, 프로모션 규칙
- **배송 컨텍스트**: 무게, 크기, 배송 제한
하나의 거대한 `Product` 모델로 모든 것을 표현하면 모든 팀이 결합됩니다. Bounded Context는 이 문제를 해결합니다.
┌─────────────────┐ ┌─────────────────┐
│ Catalog Context │ │ Inventory Context│
│ │ │ │
│ Product: │ │ Product: │
│ - name │ │ - sku │
│ - description │ │ - quantity │
│ - images │ │ - warehouse │
│ - category │ │ - reorderLevel │
└─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ Pricing Context │ │ Shipping Context │
│ │ │ │
│ Product: │ │ Product: │
│ - basePrice │ │ - weight │
│ - discount │ │ - dimensions │
│ - promotions │ │ - restrictions │
└─────────────────┘ └─────────────────┘
2.3 Context Map (컨텍스트 맵)
Bounded Context 간의 관계를 정의합니다. 7가지 관계 패턴이 있습니다:
Partnership (파트너십)
두 팀이 긴밀하게 협력하며 함께 성공하거나 함께 실패합니다.
[주문 컨텍스트] ←→ [결제 컨텍스트]
Partnership
Shared Kernel (공유 커널)
두 컨텍스트가 공통 모델의 일부를 공유합니다. 변경 시 양측 합의가 필요합니다.
[컨텍스트 A] ── Shared Kernel ── [컨텍스트 B]
(공유 모듈)
Customer-Supplier (고객-공급자)
업스트림(공급자)이 다운스트림(고객)의 요구를 수용합니다.
[주문 컨텍스트] → [배송 컨텍스트]
(Upstream) (Downstream)
Supplier Customer
Conformist (순응주의자)
다운스트림이 업스트림의 모델을 그대로 따릅니다. 협상력이 없는 경우입니다.
[외부 결제 API] → [우리 결제 연동]
(Upstream) (Downstream)
Conformist
Anti-Corruption Layer (부패 방지 레이어)
다운스트림이 번역 레이어를 두어 자신의 모델을 보호합니다.
// Anti-Corruption Layer 예시
class ExternalPaymentACL {
private externalClient: ExternalPaymentClient;
async processPayment(domainPayment: Payment): Promise<PaymentResult> {
// 우리 도메인 모델 → 외부 API 모델로 변환
const externalRequest = {
amt: domainPayment.amount.value,
ccy: domainPayment.amount.currency.code,
merchant_ref: domainPayment.orderId.toString(),
card_tkn: domainPayment.paymentMethod.token,
};
const externalResponse = await this.externalClient.charge(externalRequest);
// 외부 API 모델 → 우리 도메인 모델로 변환
return new PaymentResult(
externalResponse.status === 'OK'
? PaymentStatus.APPROVED
: PaymentStatus.DECLINED,
new TransactionId(externalResponse.txn_id)
);
}
}
Open Host Service (공개 호스트 서비스)
업스트림이 여러 다운스트림을 위해 잘 정의된 프로토콜(API)을 제공합니다.
[다운스트림 A]
↗
[OHS API] ──→ [다운스트림 B]
↘
[다운스트림 C]
Published Language (공개된 언어)
컨텍스트 간 교환 형식을 표준화합니다. JSON Schema, Protobuf, Avro 등이 해당합니다.
// Published Language 예시: Protocol Buffers
syntax = "proto3";
message OrderPlacedEvent {
string order_id = 1;
string customer_id = 2;
repeated OrderLineItem items = 3;
Money total_amount = 4;
google.protobuf.Timestamp placed_at = 5;
}
2.4 Context Map 다이어그램 예시
이커머스 시스템의 전체 Context Map:
┌──────────────┐ Partnership ┌──────────────┐
│ Identity │←──────────────────→│ Customer │
│ Context │ │ Context │
└──────┬───────┘ └──────┬───────┘
│ │
│ OHS/PL │ Customer-Supplier
│ │
▼ ▼
┌──────────────┐ Customer-Supplier ┌──────────────┐
│ Catalog │───────────────────→│ Order │
│ Context │ │ Context │
└──────────────┘ └──────┬───────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Payment │ │ Inventory │ │ Shipping │
│ Context │ │ Context │ │ Context │
└─────┬──────┘ └────────────┘ └────────────┘
│
│ ACL
▼
┌────────────┐
│ External │
│ Payment GW │
└────────────┘
3. 전술적 패턴: 코드 수준 모델링
전술적 패턴은 Bounded Context 내부에서 도메인 모델을 구현하는 구체적인 방법입니다.
3.1 Entity (엔티티)
**고유한 식별자**로 구분되는 도메인 객체입니다. 시간이 지나도 동일한 식별자를 가지면 같은 객체입니다.
// Entity 예시: Order
class Order {
private readonly id: OrderId;
private status: OrderStatus;
private items: OrderLineItem[];
private readonly customerId: CustomerId;
private readonly placedAt: Date;
constructor(
id: OrderId,
customerId: CustomerId,
items: OrderLineItem[]
) {
if (items.length === 0) {
throw new EmptyOrderError();
}
this.id = id;
this.customerId = customerId;
this.items = items;
this.status = OrderStatus.PLACED;
this.placedAt = new Date();
}
get totalAmount(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero('KRW')
);
}
confirm(): void {
if (this.status !== OrderStatus.PLACED) {
throw new InvalidOrderStateError(
`Cannot confirm order in ${this.status} status`
);
}
this.status = OrderStatus.CONFIRMED;
}
cancel(reason: CancellationReason): void {
if (this.status === OrderStatus.SHIPPED) {
throw new InvalidOrderStateError(
'Cannot cancel shipped order'
);
}
this.status = OrderStatus.CANCELLED;
}
// 식별자로 동등성 판단
equals(other: Order): boolean {
return this.id.equals(other.id);
}
}
3.2 Value Object (값 객체)
식별자가 없고 **속성의 값**으로 동등성을 판단합니다. 불변(immutable)이어야 합니다.
// Value Object 예시: Money
class Money {
private constructor(
readonly value: number,
readonly currency: Currency
) {
if (value < 0) {
throw new NegativeMoneyError();
}
}
static of(value: number, currency: string): Money {
return new Money(value, Currency.of(currency));
}
static zero(currency: string): Money {
return new Money(0, Currency.of(currency));
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.value + other.value, this.currency);
}
subtract(other: Money): Money {
this.assertSameCurrency(other);
const result = this.value - other.value;
if (result < 0) {
throw new InsufficientFundsError();
}
return new Money(result, this.currency);
}
multiply(factor: number): Money {
return new Money(
Math.round(this.value * factor),
this.currency
);
}
// 값으로 동등성 판단
equals(other: Money): boolean {
return (
this.value === other.value &&
this.currency.equals(other.currency)
);
}
private assertSameCurrency(other: Money): void {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchError(
this.currency,
other.currency
);
}
}
toString(): string {
return `${this.currency.symbol}${this.value.toLocaleString()}`;
}
}
// Value Object 예시: Address
class Address {
constructor(
readonly street: string,
readonly city: string,
readonly state: string,
readonly zipCode: string,
readonly country: Country
) {
this.validate();
}
private validate(): void {
if (!this.street || !this.city || !this.zipCode) {
throw new InvalidAddressError();
}
}
equals(other: Address): boolean {
return (
this.street === other.street &&
this.city === other.city &&
this.state === other.state &&
this.zipCode === other.zipCode &&
this.country.equals(other.country)
);
}
withStreet(newStreet: string): Address {
return new Address(
newStreet,
this.city,
this.state,
this.zipCode,
this.country
);
}
}
**Entity vs Value Object 판단 기준:**
| 기준 | Entity | Value Object |
|------|--------|-------------|
| 식별자 | 고유 ID로 구분 | 값으로 구분 |
| 가변성 | 상태 변경 가능 | 불변 (새 객체 생성) |
| 생명주기 | 생성-변경-삭제 | 생성-사용-폐기 |
| 예시 | 주문, 고객, 계좌 | 금액, 주소, 날짜범위 |
3.3 Aggregate (애그리거트)
데이터 변경의 **일관성 경계**입니다. Aggregate는 다음 규칙을 따릅니다:
1. **Aggregate Root를 통해서만 접근**: 외부에서 Aggregate 내부 객체에 직접 접근 불가
2. **트랜잭션 경계**: 하나의 트랜잭션에서 하나의 Aggregate만 수정
3. **ID로만 참조**: 다른 Aggregate는 ID로만 참조, 직접 객체 참조 금지
4. **결과적 일관성**: Aggregate 간에는 도메인 이벤트를 통한 결과적 일관성
// Aggregate Root: Order
class Order {
private readonly id: OrderId;
private items: OrderLineItem[]; // Aggregate 내부 엔티티
private shippingAddress: Address; // Aggregate 내부 값 객체
private status: OrderStatus;
private readonly domainEvents: DomainEvent[] = [];
// 외부에서 OrderLineItem에 직접 접근 불가
// Aggregate Root의 메서드를 통해서만 조작
addItem(
productId: ProductId,
productName: string,
price: Money,
quantity: Quantity
): void {
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidOrderStateError(
'Can only add items to draft orders'
);
}
const existingItem = this.items.find(
item => item.productId.equals(productId)
);
if (existingItem) {
existingItem.increaseQuantity(quantity);
} else {
this.items.push(
new OrderLineItem(productId, productName, price, quantity)
);
}
this.domainEvents.push(
new OrderItemAdded(this.id, productId, quantity)
);
}
removeItem(productId: ProductId): void {
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidOrderStateError(
'Can only remove items from draft orders'
);
}
this.items = this.items.filter(
item => !item.productId.equals(productId)
);
}
place(): void {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidOrderStateError(
'Can only place draft orders'
);
}
this.status = OrderStatus.PLACED;
this.domainEvents.push(
new OrderPlaced(this.id, this.customerId, this.totalAmount)
);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this.domainEvents];
this.domainEvents.length = 0;
return events;
}
}
**Aggregate 설계 가이드라인:**
좋은 Aggregate 설계: 나쁜 Aggregate 설계:
┌───────────────────┐ ┌──────────────────────────┐
│ Order (Root) │ │ Order (Root) │
│ ├─ LineItem │ │ ├─ LineItem │
│ ├─ LineItem │ │ ├─ LineItem │
│ └─ ShippingAddr │ │ ├─ Customer (Entity!) │
└───────────────────┘ │ ├─ Product (Entity!) │
│ ├─ PaymentInfo │
작고 집중된 Aggregate │ └─ ShippingHistory │
└──────────────────────────┘
너무 큰 God Aggregate
3.4 Repository (리포지토리)
Aggregate의 저장과 조회를 담당하는 추상화입니다. **Aggregate Root 단위**로만 존재합니다.
// Repository 인터페이스 (도메인 레이어)
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
nextId(): OrderId;
}
// Repository 구현 (인프라 레이어)
class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async findById(id: OrderId): Promise<Order | null> {
const result = await this.pool.query(
`SELECT o.*, json_agg(oi.*) as items
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.id = $1
GROUP BY o.id`,
[id.value]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
async save(order: Order): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(
`INSERT INTO orders (id, customer_id, status, placed_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET status = $3`,
[order.id.value, order.customerId.value,
order.status, order.placedAt]
);
// Aggregate 내부 엔티티도 함께 저장
await client.query(
'DELETE FROM order_items WHERE order_id = $1',
[order.id.value]
);
for (const item of order.items) {
await client.query(
`INSERT INTO order_items
(order_id, product_id, name, price, quantity)
VALUES ($1, $2, $3, $4, $5)`,
[order.id.value, item.productId.value,
item.name, item.price.value, item.quantity.value]
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
nextId(): OrderId {
return OrderId.generate();
}
private toDomain(row: any): Order {
// DB 행 → 도메인 객체 변환
return Order.reconstitute(
new OrderId(row.id),
new CustomerId(row.customer_id),
OrderStatus.from(row.status),
row.items.map((i: any) => OrderLineItem.reconstitute(
new ProductId(i.product_id),
i.name,
Money.of(i.price, 'KRW'),
new Quantity(i.quantity)
)),
new Date(row.placed_at)
);
}
}
3.5 Domain Service (도메인 서비스)
하나의 Entity나 Value Object에 속하지 않는 도메인 로직을 담습니다.
// Domain Service: 주문 가격 계산
class OrderPricingService {
calculateTotal(
items: OrderLineItem[],
discountPolicy: DiscountPolicy,
customer: CustomerGrade
): Money {
const subtotal = items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero('KRW')
);
const discount = discountPolicy.calculateDiscount(
subtotal,
customer
);
return subtotal.subtract(discount);
}
}
// Domain Service vs Application Service 구분
// Domain Service: 순수한 도메인 로직
class TransferService {
transfer(
source: Account,
target: Account,
amount: Money
): void {
source.withdraw(amount);
target.deposit(amount);
}
}
// Application Service: 유스케이스 조율 (인프라 의존)
class TransferApplicationService {
constructor(
private accountRepo: AccountRepository,
private transferService: TransferService,
private eventBus: EventBus
) {}
async execute(command: TransferCommand): Promise<void> {
const source = await this.accountRepo.findById(
command.sourceAccountId
);
const target = await this.accountRepo.findById(
command.targetAccountId
);
this.transferService.transfer(
source, target, command.amount
);
await this.accountRepo.save(source);
await this.accountRepo.save(target);
await this.eventBus.publish(
new MoneyTransferred(
command.sourceAccountId,
command.targetAccountId,
command.amount
)
);
}
}
3.6 Domain Event (도메인 이벤트)
도메인에서 발생한 의미 있는 사건을 표현합니다. Aggregate 간 결과적 일관성을 달성하는 핵심 메커니즘입니다.
// Domain Event 정의
abstract class DomainEvent {
readonly occurredAt: Date;
readonly eventId: string;
constructor() {
this.occurredAt = new Date();
this.eventId = crypto.randomUUID();
}
abstract get eventType(): string;
}
class OrderPlaced extends DomainEvent {
constructor(
readonly orderId: OrderId,
readonly customerId: CustomerId,
readonly totalAmount: Money,
readonly items: ReadonlyArray<OrderItemSnapshot>
) {
super();
}
get eventType(): string {
return 'order.placed';
}
}
class PaymentCompleted extends DomainEvent {
constructor(
readonly orderId: OrderId,
readonly paymentId: PaymentId,
readonly amount: Money
) {
super();
}
get eventType(): string {
return 'payment.completed';
}
}
// Event Handler
class OrderPlacedHandler {
constructor(
private inventoryService: InventoryService,
private notificationService: NotificationService
) {}
async handle(event: OrderPlaced): Promise<void> {
// 재고 예약
await this.inventoryService.reserveStock(
event.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
}))
);
// 주문 확인 알림 발송
await this.notificationService.sendOrderConfirmation(
event.customerId,
event.orderId
);
}
}
3.7 Factory (팩토리)
복잡한 Aggregate 생성 로직을 캡슐화합니다.
class OrderFactory {
constructor(
private orderRepo: OrderRepository,
private pricingService: OrderPricingService
) {}
createOrder(
customerId: CustomerId,
items: CreateOrderItemDTO[],
shippingAddress: Address,
discountCode?: string
): Order {
const orderId = this.orderRepo.nextId();
const orderItems = items.map(item =>
new OrderLineItem(
new ProductId(item.productId),
item.productName,
Money.of(item.price, 'KRW'),
new Quantity(item.quantity)
)
);
const order = new Order(
orderId,
customerId,
orderItems,
shippingAddress
);
if (discountCode) {
order.applyDiscountCode(new DiscountCode(discountCode));
}
return order;
}
}
4. Event Storming: 도메인 탐색 워크숍
Event Storming은 Alberto Brandolini가 고안한 워크숍 기법입니다. 개발자와 도메인 전문가가 함께 도메인을 탐색합니다.
4.1 Event Storming의 3단계
Big Picture Event Storming
목적: 전체 도메인의 큰 그림 파악
**진행 방법:**
1. 긴 벽면에 긴 종이를 붙입니다
2. **주황색 포스트잇**: 도메인 이벤트 (과거형으로 작성)
3. 시간순으로 왼쪽에서 오른쪽으로 배치
4. **핫스팟** (빨간 포스트잇): 불확실하거나 논쟁이 있는 부분 표시
5. **피봇 이벤트**: 프로세스의 주요 전환점 식별
시간 흐름 →
[고객 등록됨] [상품 검색됨] [장바구니에 담김] [주문 생성됨]
|
[결제 요청됨]
|
[결제 승인됨] OR [결제 실패됨]
|
[주문 확정됨]
|
[배송 시작됨]
|
[배송 완료됨]
Process Modeling Event Storming
목적: 각 프로세스의 상세 흐름 파악
**추가 요소:**
- **파란색 포스트잇**: Command (사용자 의도)
- **노란색 포스트잇**: Actor (행위자)
- **보라색 포스트잇**: Policy (자동화 규칙 "~하면 ~한다")
- **초록색 포스트잇**: Read Model (의사결정에 필요한 정보)
[고객] → [주문하기] → [Order Aggregate] → [주문 생성됨]
(Actor) (Command) (Aggregate) (Event)
|
[결제 정책]
(Policy)
|
[결제 요청하기]
(Command)
Software Design Event Storming
목적: 구현 수준의 설계
**추가 요소:**
- Aggregate 경계 식별
- Bounded Context 경계 도출
- Command Handler 매핑
4.2 Event Storming 퍼실리테이션 팁
1. **참가자 구성**: 도메인 전문가 + 개발자 (6~15명)
2. **공간**: 8미터 이상의 벽면, 서서 진행
3. **재료**: 4색 이상 포스트잇, 마커
4. **시간**: Big Picture 2~4시간, Process Modeling 4~8시간
5. **규칙**: "정답은 없다", 모든 의견 존중, 논쟁 환영
**금지 사항:**
- 기술 용어 사용 (DB, API, 마이크로서비스 등)
- 한 사람이 독점하는 것
- 완벽한 결과를 기대하는 것
5. Python으로 구현하는 DDD
TypeScript 외에 Python으로도 DDD를 구현할 수 있습니다.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from uuid import uuid4
from datetime import datetime
from enum import Enum
Value Object
@dataclass(frozen=True)
class Money:
amount: int
currency: str = "KRW"
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError("Money amount cannot be negative")
def add(self, other: Money) -> Money:
self._assert_same_currency(other)
return Money(self.amount + other.amount, self.currency)
def subtract(self, other: Money) -> Money:
self._assert_same_currency(other)
if self.amount - other.amount < 0:
raise ValueError("Insufficient funds")
return Money(self.amount - other.amount, self.currency)
def _assert_same_currency(self, other: Money) -> None:
if self.currency != other.currency:
raise ValueError(
f"Currency mismatch: {self.currency} vs {other.currency}"
)
@dataclass(frozen=True)
class OrderId:
value: str
@staticmethod
def generate() -> OrderId:
return OrderId(str(uuid4()))
@dataclass(frozen=True)
class CustomerId:
value: str
Entity
class OrderStatus(Enum):
DRAFT = "DRAFT"
PLACED = "PLACED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
CANCELLED = "CANCELLED"
@dataclass
class OrderLineItem:
product_id: str
product_name: str
price: Money
quantity: int
@property
def subtotal(self) -> Money:
return Money(
self.price.amount * self.quantity,
self.price.currency
)
Domain Event
@dataclass(frozen=True)
class DomainEvent:
event_id: str = field(default_factory=lambda: str(uuid4()))
occurred_at: datetime = field(default_factory=datetime.utcnow)
@dataclass(frozen=True)
class OrderPlacedEvent(DomainEvent):
order_id: str = ""
customer_id: str = ""
total_amount: int = 0
Aggregate Root
class Order:
def __init__(
self,
order_id: OrderId,
customer_id: CustomerId,
) -> None:
self._id = order_id
self._customer_id = customer_id
self._items: list[OrderLineItem] = []
self._status = OrderStatus.DRAFT
self._events: list[DomainEvent] = []
self._created_at = datetime.utcnow()
@property
def id(self) -> OrderId:
return self._id
@property
def status(self) -> OrderStatus:
return self._status
@property
def total_amount(self) -> Money:
if not self._items:
return Money(0)
total = Money(0)
for item in self._items:
total = total.add(item.subtotal)
return total
def add_item(
self,
product_id: str,
product_name: str,
price: Money,
quantity: int,
) -> None:
if self._status != OrderStatus.DRAFT:
raise ValueError("Can only add items to draft orders")
if quantity <= 0:
raise ValueError("Quantity must be positive")
self._items.append(
OrderLineItem(product_id, product_name, price, quantity)
)
def place(self) -> None:
if not self._items:
raise ValueError("Cannot place an empty order")
if self._status != OrderStatus.DRAFT:
raise ValueError("Can only place draft orders")
self._status = OrderStatus.PLACED
self._events.append(
OrderPlacedEvent(
order_id=self._id.value,
customer_id=self._customer_id.value,
total_amount=self.total_amount.amount,
)
)
def confirm(self) -> None:
if self._status != OrderStatus.PLACED:
raise ValueError("Can only confirm placed orders")
self._status = OrderStatus.CONFIRMED
def cancel(self) -> None:
if self._status in (OrderStatus.SHIPPED, OrderStatus.DELIVERED):
raise ValueError("Cannot cancel shipped or delivered orders")
self._status = OrderStatus.CANCELLED
def collect_events(self) -> list[DomainEvent]:
events = list(self._events)
self._events.clear()
return events
Repository Interface
from abc import ABC, abstractmethod
class OrderRepository(ABC):
@abstractmethod
async def find_by_id(self, order_id: OrderId) -> Optional[Order]:
...
@abstractmethod
async def save(self, order: Order) -> None:
...
@abstractmethod
def next_id(self) -> OrderId:
...
Application Service (Use Case)
class PlaceOrderUseCase:
def __init__(
self,
order_repo: OrderRepository,
event_publisher: EventPublisher,
) -> None:
self._order_repo = order_repo
self._event_publisher = event_publisher
async def execute(self, command: PlaceOrderCommand) -> str:
order_id = self._order_repo.next_id()
order = Order(order_id, CustomerId(command.customer_id))
for item in command.items:
order.add_item(
product_id=item.product_id,
product_name=item.product_name,
price=Money(item.price),
quantity=item.quantity,
)
order.place()
await self._order_repo.save(order)
for event in order.collect_events():
await self._event_publisher.publish(event)
return order_id.value
6. DDD + CQRS + Event Sourcing
6.1 CQRS (Command Query Responsibility Segregation)
명령(쓰기)과 조회(읽기)를 분리하는 패턴입니다. DDD와 자연스럽게 결합됩니다.
┌──────────────┐
│ Client │
└──────┬───────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Command │ │ Query │
│ Handler │ │ Handler │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Write │ │ Read │
│ Model │ ─sync─→│ Model │
│ (DDD) │ │ (DTO) │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Write DB │ │ Read DB │
│ (Postgres) │ │ (Redis/ES) │
└─────────────┘ └─────────────┘
// Command Side
class PlaceOrderCommandHandler {
constructor(
private orderRepo: OrderRepository,
private eventBus: EventBus
) {}
async handle(command: PlaceOrderCommand): Promise<OrderId> {
const order = OrderFactory.create(
command.customerId,
command.items,
command.shippingAddress
);
order.place();
await this.orderRepo.save(order);
const events = order.pullDomainEvents();
for (const event of events) {
await this.eventBus.publish(event);
}
return order.id;
}
}
// Query Side
class OrderQueryService {
constructor(private readDb: ReadDatabase) {}
async getOrderSummary(
orderId: string
): Promise<OrderSummaryDTO> {
return this.readDb.query(
'SELECT * FROM order_summaries WHERE id = $1',
[orderId]
);
}
async getCustomerOrders(
customerId: string,
page: number,
size: number
): Promise<PaginatedResult<OrderListDTO>> {
return this.readDb.query(
`SELECT * FROM order_summaries
WHERE customer_id = $1
ORDER BY placed_at DESC
LIMIT $2 OFFSET $3`,
[customerId, size, (page - 1) * size]
);
}
}
// Projection (이벤트 → Read Model 동기화)
class OrderSummaryProjection {
constructor(private readDb: ReadDatabase) {}
async on(event: OrderPlaced): Promise<void> {
await this.readDb.execute(
`INSERT INTO order_summaries
(id, customer_id, total, status, placed_at)
VALUES ($1, $2, $3, $4, $5)`,
[event.orderId, event.customerId,
event.totalAmount, 'PLACED', event.occurredAt]
);
}
async on(event: OrderConfirmed): Promise<void> {
await this.readDb.execute(
`UPDATE order_summaries SET status = 'CONFIRMED'
WHERE id = $1`,
[event.orderId]
);
}
}
6.2 Event Sourcing
상태를 저장하는 대신 **이벤트의 시퀀스**를 저장합니다.
// Event-Sourced Aggregate
class EventSourcedOrder {
private id: OrderId;
private status: OrderStatus;
private items: OrderLineItem[] = [];
private version: number = 0;
private uncommittedEvents: DomainEvent[] = [];
// 이벤트로부터 상태 복원
static fromHistory(events: DomainEvent[]): EventSourcedOrder {
const order = new EventSourcedOrder();
for (const event of events) {
order.apply(event, false);
}
return order;
}
place(customerId: CustomerId, items: OrderLineItem[]): void {
this.apply(
new OrderPlaced(OrderId.generate(), customerId, items),
true
);
}
confirm(): void {
if (this.status !== OrderStatus.PLACED) {
throw new InvalidOrderStateError();
}
this.apply(new OrderConfirmed(this.id), true);
}
private apply(event: DomainEvent, isNew: boolean): void {
this.when(event);
this.version++;
if (isNew) {
this.uncommittedEvents.push(event);
}
}
private when(event: DomainEvent): void {
if (event instanceof OrderPlaced) {
this.id = event.orderId;
this.status = OrderStatus.PLACED;
this.items = [...event.items];
} else if (event instanceof OrderConfirmed) {
this.status = OrderStatus.CONFIRMED;
} else if (event instanceof OrderCancelled) {
this.status = OrderStatus.CANCELLED;
}
}
getUncommittedEvents(): DomainEvent[] {
return [...this.uncommittedEvents];
}
clearUncommittedEvents(): void {
this.uncommittedEvents = [];
}
}
// Event Store
class EventStore {
async save(
aggregateId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void> {
await this.db.query(
`INSERT INTO event_store
(aggregate_id, event_type, event_data, version, created_at)
VALUES ($1, $2, $3, $4, $5)`,
events.map((event, i) => [
aggregateId,
event.eventType,
JSON.stringify(event),
expectedVersion + i + 1,
event.occurredAt,
])
);
}
async load(aggregateId: string): Promise<DomainEvent[]> {
const result = await this.db.query(
`SELECT event_type, event_data
FROM event_store
WHERE aggregate_id = $1
ORDER BY version ASC`,
[aggregateId]
);
return result.rows.map(row =>
this.deserialize(row.event_type, row.event_data)
);
}
}
7. DDD + 마이크로서비스
7.1 하나의 Bounded Context = 하나의 서비스
DDD의 Bounded Context는 마이크로서비스 경계를 결정하는 가장 좋은 도구입니다.
┌─────────────────────────────────────────────────┐
│ 이커머스 시스템 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Catalog │ │ Order │ │ Payment │ │
│ │ Service │ │ Service │ │ Service │ │
│ │ │ │ │ │ │ │
│ │ (Catalog │ │ (Order │ │ (Payment │ │
│ │ BC) │ │ BC) │ │ BC) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──────────────┴──────────────┘ │
│ Event Bus │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Inventory │ │ Shipping │ │Notific. │ │
│ │ Service │ │ Service │ │ Service │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
7.2 Context Mapping = 서비스 간 통신 패턴
| Context Map 관계 | 마이크로서비스 통신 패턴 |
|------------------|------------------------|
| Partnership | 동기 API + 공유 이벤트 |
| Customer-Supplier | API Gateway / REST API |
| Conformist | 외부 API 그대로 사용 |
| ACL | API Adapter / Translator 서비스 |
| OHS/PL | GraphQL / gRPC 공개 API |
| Shared Kernel | 공유 라이브러리 / 공유 스키마 |
7.3 서비스 간 이벤트 기반 통신
// Order Service - 이벤트 발행
class OrderService {
async placeOrder(command: PlaceOrderCommand): Promise<void> {
const order = this.orderFactory.create(command);
order.place();
await this.orderRepo.save(order);
// 이벤트 발행 (Outbox Pattern)
await this.outbox.store(
order.pullDomainEvents().map(event => ({
aggregateId: order.id.value,
eventType: event.eventType,
payload: JSON.stringify(event),
}))
);
}
}
// Inventory Service - 이벤트 구독
class InventoryEventHandler {
@Subscribe('order.placed')
async onOrderPlaced(event: OrderPlacedEvent): Promise<void> {
for (const item of event.items) {
await this.inventoryService.reserveStock(
item.productId,
item.quantity
);
}
}
@Subscribe('order.cancelled')
async onOrderCancelled(
event: OrderCancelledEvent
): Promise<void> {
for (const item of event.items) {
await this.inventoryService.releaseStock(
item.productId,
item.quantity
);
}
}
}
8. 안티 패턴과 리팩토링
8.1 빈혈 도메인 모델 (Anemic Domain Model)
가장 흔한 안티 패턴입니다. Entity가 단순히 데이터만 보관하고 모든 로직이 Service에 있는 경우입니다.
// 안티 패턴: 빈혈 도메인 모델
class Order {
id: string;
status: string;
items: OrderItem[];
totalAmount: number;
// getter/setter만 존재, 비즈니스 로직 없음
}
class OrderService {
placeOrder(order: Order): void {
// 모든 비즈니스 로직이 Service에!
if (order.items.length === 0) {
throw new Error('Empty order');
}
if (order.status !== 'DRAFT') {
throw new Error('Invalid status');
}
order.status = 'PLACED';
order.totalAmount = order.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
}
}
// 해결: 풍부한 도메인 모델 (Rich Domain Model)
class Order {
private status: OrderStatus;
private items: OrderLineItem[];
place(): void {
this.assertNotEmpty();
this.assertDraft();
this.status = OrderStatus.PLACED;
}
get totalAmount(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero('KRW')
);
}
private assertNotEmpty(): void {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
}
private assertDraft(): void {
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidOrderStateError(this.status);
}
}
}
8.2 God Aggregate
하나의 Aggregate에 너무 많은 것을 넣는 패턴입니다.
// 안티 패턴: God Aggregate
class Customer {
id: CustomerId;
name: string;
email: Email;
orders: Order[]; // 모든 주문!
addresses: Address[]; // 모든 주소!
payments: PaymentMethod[]; // 모든 결제수단!
reviews: Review[]; // 모든 리뷰!
wishlist: Product[]; // 위시리스트!
loyaltyPoints: number;
// ...수백 개의 필드
}
// 해결: 작은 Aggregate로 분리
class Customer {
id: CustomerId;
name: CustomerName;
email: Email;
grade: CustomerGrade;
}
class CustomerAddressBook {
customerId: CustomerId;
addresses: Address[];
}
class CustomerWallet {
customerId: CustomerId;
paymentMethods: PaymentMethod[];
loyaltyPoints: LoyaltyPoints;
}
8.3 CRUD에서 DDD로 리팩토링
기존 CRUD 시스템을 점진적으로 DDD로 전환하는 전략:
**Step 1: 유비쿼터스 언어 수립**
Before: UserService.updateUserStatus(userId, "active")
After: customer.activate()
**Step 2: Value Object 추출**
// Before: 원시 타입 남용
function createOrder(
price: number,
currency: string,
street: string,
city: string,
zip: string
): void { /* ... */ }
// After: Value Object 도입
function createOrder(
amount: Money,
address: Address
): void { /* ... */ }
**Step 3: 도메인 로직을 Entity로 이동**
// Before: Service에 있는 로직
class OrderService {
canCancel(order: OrderDTO): boolean {
return order.status !== 'SHIPPED'
&& order.status !== 'DELIVERED';
}
}
// After: Entity에 있는 로직
class Order {
canCancel(): boolean {
return !this.isShipped() && !this.isDelivered();
}
}
**Step 4: Aggregate 경계 설정**
**Step 5: Domain Event 도입**
**Step 6: Repository 패턴 적용**
9. DDD 프로젝트 구조
9.1 TypeScript 프로젝트 구조
src/
├── modules/
│ ├── order/ # Order Bounded Context
│ │ ├── domain/
│ │ │ ├── model/
│ │ │ │ ├── Order.ts # Aggregate Root
│ │ │ │ ├── OrderLineItem.ts # Entity
│ │ │ │ ├── OrderId.ts # Value Object
│ │ │ │ ├── OrderStatus.ts # Value Object (enum)
│ │ │ │ └── Money.ts # Value Object
│ │ │ ├── event/
│ │ │ │ ├── OrderPlaced.ts
│ │ │ │ └── OrderConfirmed.ts
│ │ │ ├── service/
│ │ │ │ └── OrderPricingService.ts
│ │ │ ├── repository/
│ │ │ │ └── OrderRepository.ts # Interface
│ │ │ └── factory/
│ │ │ └── OrderFactory.ts
│ │ ├── application/
│ │ │ ├── command/
│ │ │ │ ├── PlaceOrderCommand.ts
│ │ │ │ └── PlaceOrderHandler.ts
│ │ │ ├── query/
│ │ │ │ ├── GetOrderQuery.ts
│ │ │ │ └── GetOrderHandler.ts
│ │ │ └── event-handler/
│ │ │ └── OrderPlacedHandler.ts
│ │ └── infrastructure/
│ │ ├── persistence/
│ │ │ └── PostgresOrderRepository.ts
│ │ ├── messaging/
│ │ │ └── KafkaOrderEventPublisher.ts
│ │ └── api/
│ │ └── OrderController.ts
│ │
│ ├── payment/ # Payment Bounded Context
│ │ ├── domain/
│ │ ├── application/
│ │ └── infrastructure/
│ │
│ └── shared/ # Shared Kernel
│ ├── domain/
│ │ ├── DomainEvent.ts
│ │ ├── AggregateRoot.ts
│ │ └── ValueObject.ts
│ └── infrastructure/
│ ├── EventBus.ts
│ └── UnitOfWork.ts
│
└── main.ts
9.2 Python 프로젝트 구조
src/
├── order/ # Order Bounded Context
│ ├── domain/
│ │ ├── __init__.py
│ │ ├── model/
│ │ │ ├── order.py # Aggregate Root
│ │ │ ├── order_line_item.py # Entity
│ │ │ └── value_objects.py # Money, OrderId, etc.
│ │ ├── events/
│ │ │ └── order_events.py
│ │ ├── services/
│ │ │ └── pricing_service.py
│ │ └── repositories/
│ │ └── order_repository.py # ABC
│ ├── application/
│ │ ├── commands/
│ │ │ └── place_order.py
│ │ └── queries/
│ │ └── get_order.py
│ └── infrastructure/
│ ├── persistence/
│ │ └── sqlalchemy_order_repo.py
│ └── api/
│ └── order_routes.py
│
├── payment/ # Payment Bounded Context
│ ├── domain/
│ ├── application/
│ └── infrastructure/
│
└── shared_kernel/
├── domain_event.py
├── aggregate_root.py
└── value_object.py
10. 실전 체크리스트
10.1 DDD 도입 체크리스트
- Bounded Context를 식별했는가
- 각 BC별 유비쿼터스 언어 용어집이 있는가
- Context Map을 그렸는가 (팀 간 관계 명시)
- Aggregate 경계가 적절한가 (너무 크지 않은가)
- Entity vs Value Object를 올바르게 구분했는가
- Repository가 Aggregate Root 단위인가
- Domain Event로 Aggregate 간 통신하는가
- 도메인 로직이 Entity/Value Object 안에 있는가 (빈혈 모델 아닌가)
- ACL로 외부 시스템과의 경계를 보호하는가
10.2 Aggregate 설계 체크리스트
- Aggregate Root를 통해서만 내부 엔티티에 접근하는가
- 하나의 트랜잭션에서 하나의 Aggregate만 수정하는가
- 다른 Aggregate를 ID로만 참조하는가 (객체 참조 X)
- Aggregate가 충분히 작은가 (필요한 최소 크기)
- 불변식(invariant)이 항상 보장되는가
11. 퀴즈
지금까지 학습한 내용을 점검해봅시다.
**A1.**
Entity는 **고유한 식별자(ID)**로 구분됩니다. 속성이 모두 변해도 같은 ID면 같은 객체입니다. 반면 Value Object는 **속성의 값**으로 동등성을 판단합니다. 식별자가 없고 불변이어야 합니다. 예를 들어 "주문(Order)"은 Entity(주문번호로 구분)이고, "금액(Money)"은 Value Object(10000원과 10000원은 같은 가치)입니다.
**A2.**
1. **Aggregate Root를 통해서만 접근**: 외부에서 내부 객체에 직접 접근 불가
2. **트랜잭션 경계**: 하나의 트랜잭션에서 하나의 Aggregate만 수정
3. **ID로만 참조**: 다른 Aggregate는 ID로만 참조 (직접 객체 참조 금지)
4. **결과적 일관성**: Aggregate 간에는 Domain Event를 통한 비동기 일관성
**A3.**
ACL은 **다운스트림 시스템이 자신의 도메인 모델을 보호**해야 할 때 사용합니다. 특히 외부 시스템(레거시 시스템, 서드파티 API)의 모델이 우리 도메인과 크게 다를 때, 번역(Translation) 레이어를 두어 외부 모델이 내부 모델을 오염시키지 않도록 합니다. 외부 API의 필드명이나 구조가 변해도 ACL만 수정하면 됩니다.
**A4.**
빈혈 도메인 모델은 Entity가 데이터(getter/setter)만 가지고 **비즈니스 로직은 모두 Service 클래스에 있는** 패턴입니다. Martin Fowler가 "안티 패턴"이라 명명했습니다. 문제점: (1) 도메인 지식이 Service에 흩어져 중복 발생, (2) 불변식(invariant) 보장이 어려움, (3) 객체가 아닌 절차적 프로그래밍이 됨. 해결책은 비즈니스 로직을 Entity 안으로 이동시키는 것입니다.
**A5.**
**CQRS**: 쓰기 모델(DDD Aggregate)과 읽기 모델(최적화된 DTO)을 분리합니다. 쓰기는 도메인 무결성을, 읽기는 성능을 각각 최적화할 수 있습니다. **Event Sourcing**: 상태 대신 이벤트를 저장하여 (1) 완전한 감사 로그, (2) 시간여행 디버깅, (3) 이벤트 기반 통합, (4) Aggregate 상태의 다양한 뷰(Projection) 생성이 가능합니다. 둘 모두 복잡한 도메인에서 위력을 발휘하지만, 단순 CRUD에서는 오버엔지니어링입니다.
12. 참고 자료
1. **Eric Evans** - "Domain-Driven Design: Tackling Complexity in the Heart of Software" (2003)
2. **Vaughn Vernon** - "Implementing Domain-Driven Design" (2013)
3. **Vaughn Vernon** - "Domain-Driven Design Distilled" (2016)
4. **Alberto Brandolini** - "Introducing EventStorming" (2021)
5. **Scott Millett** - "Patterns, Principles, and Practices of Domain-Driven Design" (2015)
6. **Martin Fowler** - "Anemic Domain Model" (블로그 게시물)
7. **Greg Young** - "CQRS and Event Sourcing" (기술 발표 자료)
8. **Udi Dahan** - "Clarified CQRS" (블로그 시리즈)
9. **DDD Community** - ddd-crew GitHub (Context Mapping 템플릿)
10. **EventStorming.com** - Alberto Brandolini의 공식 사이트
11. **Microsoft** - ".NET Microservices Architecture Guide" (DDD 적용 가이드)
12. **Martin Fowler** - "BoundedContext" (블로그 게시물)
13. **Chris Richardson** - "Microservices Patterns" (이벤트 기반 아키텍처)
14. **Nick Tune** - "Domain-Driven Design Starter Modelling Process" (GitHub)
현재 단락 (1/1294)
소프트웨어의 본질적 복잡성은 기술이 아니라 **도메인**에서 비롯됩니다. Eric Evans가 2003년 저서 "Domain-Driven Design: Tackling Comple...