- Published on
DDD(도메인 주도 설계) 완전 가이드 2025: 전략적/전술적 패턴, Bounded Context, 이벤트 스토밍
- Authors

- Name
- Youngju Kim
- @fjvbn20031
목차
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()
유비쿼터스 언어는 다음 모든 곳에서 일관되어야 합니다:
- 코드 (클래스명, 메서드명, 변수명)
- 데이터베이스 스키마
- API 엔드포인트
- 문서
- 팀 대화
// 나쁜 예: 기술 중심 네이밍
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는 다음 규칙을 따릅니다:
- Aggregate Root를 통해서만 접근: 외부에서 Aggregate 내부 객체에 직접 접근 불가
- 트랜잭션 경계: 하나의 트랜잭션에서 하나의 Aggregate만 수정
- ID로만 참조: 다른 Aggregate는 ID로만 참조, 직접 객체 참조 금지
- 결과적 일관성: 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
목적: 전체 도메인의 큰 그림 파악
진행 방법:
- 긴 벽면에 긴 종이를 붙입니다
- 주황색 포스트잇: 도메인 이벤트 (과거형으로 작성)
- 시간순으로 왼쪽에서 오른쪽으로 배치
- 핫스팟 (빨간 포스트잇): 불확실하거나 논쟁이 있는 부분 표시
- 피봇 이벤트: 프로세스의 주요 전환점 식별
시간 흐름 →
[고객 등록됨] [상품 검색됨] [장바구니에 담김] [주문 생성됨]
|
[결제 요청됨]
|
[결제 승인됨] 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 퍼실리테이션 팁
- 참가자 구성: 도메인 전문가 + 개발자 (6~15명)
- 공간: 8미터 이상의 벽면, 서서 진행
- 재료: 4색 이상 포스트잇, 마커
- 시간: Big Picture 2
4시간, Process Modeling 48시간 - 규칙: "정답은 없다", 모든 의견 존중, 논쟁 환영
금지 사항:
- 기술 용어 사용 (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. 퀴즈
지금까지 학습한 내용을 점검해봅시다.
Q1. Entity와 Value Object의 핵심 차이점은 무엇인가요?
A1. Entity는 **고유한 식별자(ID)**로 구분됩니다. 속성이 모두 변해도 같은 ID면 같은 객체입니다. 반면 Value Object는 속성의 값으로 동등성을 판단합니다. 식별자가 없고 불변이어야 합니다. 예를 들어 "주문(Order)"은 Entity(주문번호로 구분)이고, "금액(Money)"은 Value Object(10000원과 10000원은 같은 가치)입니다.
Q2. Aggregate의 4가지 핵심 규칙은 무엇인가요?
A2.
- Aggregate Root를 통해서만 접근: 외부에서 내부 객체에 직접 접근 불가
- 트랜잭션 경계: 하나의 트랜잭션에서 하나의 Aggregate만 수정
- ID로만 참조: 다른 Aggregate는 ID로만 참조 (직접 객체 참조 금지)
- 결과적 일관성: Aggregate 간에는 Domain Event를 통한 비동기 일관성
Q3. Anti-Corruption Layer(ACL)는 어떤 상황에서 사용하나요?
A3. ACL은 다운스트림 시스템이 자신의 도메인 모델을 보호해야 할 때 사용합니다. 특히 외부 시스템(레거시 시스템, 서드파티 API)의 모델이 우리 도메인과 크게 다를 때, 번역(Translation) 레이어를 두어 외부 모델이 내부 모델을 오염시키지 않도록 합니다. 외부 API의 필드명이나 구조가 변해도 ACL만 수정하면 됩니다.
Q4. 빈혈 도메인 모델(Anemic Domain Model)이란 무엇이며, 왜 문제인가요?
A4. 빈혈 도메인 모델은 Entity가 데이터(getter/setter)만 가지고 비즈니스 로직은 모두 Service 클래스에 있는 패턴입니다. Martin Fowler가 "안티 패턴"이라 명명했습니다. 문제점: (1) 도메인 지식이 Service에 흩어져 중복 발생, (2) 불변식(invariant) 보장이 어려움, (3) 객체가 아닌 절차적 프로그래밍이 됨. 해결책은 비즈니스 로직을 Entity 안으로 이동시키는 것입니다.
Q5. CQRS와 Event Sourcing을 DDD와 함께 사용하면 어떤 이점이 있나요?
A5. CQRS: 쓰기 모델(DDD Aggregate)과 읽기 모델(최적화된 DTO)을 분리합니다. 쓰기는 도메인 무결성을, 읽기는 성능을 각각 최적화할 수 있습니다. Event Sourcing: 상태 대신 이벤트를 저장하여 (1) 완전한 감사 로그, (2) 시간여행 디버깅, (3) 이벤트 기반 통합, (4) Aggregate 상태의 다양한 뷰(Projection) 생성이 가능합니다. 둘 모두 복잡한 도메인에서 위력을 발휘하지만, 단순 CRUD에서는 오버엔지니어링입니다.
12. 참고 자료
- Eric Evans - "Domain-Driven Design: Tackling Complexity in the Heart of Software" (2003)
- Vaughn Vernon - "Implementing Domain-Driven Design" (2013)
- Vaughn Vernon - "Domain-Driven Design Distilled" (2016)
- Alberto Brandolini - "Introducing EventStorming" (2021)
- Scott Millett - "Patterns, Principles, and Practices of Domain-Driven Design" (2015)
- Martin Fowler - "Anemic Domain Model" (블로그 게시물)
- Greg Young - "CQRS and Event Sourcing" (기술 발표 자료)
- Udi Dahan - "Clarified CQRS" (블로그 시리즈)
- DDD Community - ddd-crew GitHub (Context Mapping 템플릿)
- EventStorming.com - Alberto Brandolini의 공식 사이트
- Microsoft - ".NET Microservices Architecture Guide" (DDD 적용 가이드)
- Martin Fowler - "BoundedContext" (블로그 게시물)
- Chris Richardson - "Microservices Patterns" (이벤트 기반 아키텍처)
- Nick Tune - "Domain-Driven Design Starter Modelling Process" (GitHub)