Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

목차

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...

작성 글자: 0원문 글자: 30,120작성 단락: 0/1294