Theory
고급 2-3시간 이론

DDD 이론 12: Event Sourcing과 DDD 실천

Event Sourcing의 원리와 구현, Event Storming 워크샵, DDD 아키텍처 패턴, 그리고 점진적 도입 전략을 학습합니다.

Event SourcingEvent StormingHexagonal ArchitectureClean ArchitectureTeam Topology
학습 목표
  • • Event Sourcing의 원리와 구현 방법을 이해한다
  • • Snapshot과 Projection을 구현할 수 있다
  • • Event Storming 워크샵을 진행할 수 있다
  • • Hexagonal/Clean Architecture를 DDD와 함께 적용할 수 있다
  • • 레거시 시스템에 DDD를 점진적으로 도입할 수 있다
  • • 12개 세션의 DDD 핵심 개념을 종합적으로 이해한다

1. Event Sourcing의 본질

"현재 상태를 저장하는 대신, 상태 변경을 일으킨 이벤트들의 시퀀스를 저장한다. 현재 상태는 이벤트들을 재생하여 도출한다."

— Martin Fowler

1.1 State Sourcing vs Event Sourcing

State Sourcing (전통적)

Account Table:
| id | balance | updated_at |
| 1  | 1000    | 2024-01-15 |

→ 현재 잔액만 알 수 있음
→ 어떻게 1000이 되었는지 모름
→ 이력 추적 불가
  • • 현재 상태만 저장
  • • UPDATE로 덮어쓰기
  • • 이력 손실

Event Sourcing

Event Store:
| AccountOpened(id=1, initial=0)
| MoneyDeposited(id=1, amount=500)
| MoneyDeposited(id=1, amount=700)
| MoneyWithdrawn(id=1, amount=200)

→ 재생: 0+500+700-200 = 1000
→ 전체 이력 보존
  • • 이벤트 시퀀스 저장
  • • Append-only
  • • 완전한 감사 로그

1.2 Event Sourcing 구현

// Event Sourced Aggregate
abstract class EventSourcedAggregate<TId> {
  private _uncommittedEvents: DomainEvent[] = [];
  private _version: number = 0;

  get uncommittedEvents(): ReadonlyArray<DomainEvent> {
    return this._uncommittedEvents;
  }

  get version(): number {
    return this._version;
  }

  // 이벤트 적용 (상태 변경)
  protected apply(event: DomainEvent): void {
    this.when(event);
    this._uncommittedEvents.push(event);
  }

  // 이벤트 재생 (복원)
  loadFromHistory(events: DomainEvent[]): void {
    for (const event of events) {
      this.when(event);
      this._version++;
    }
  }

  // 이벤트별 상태 변경 로직 (하위 클래스에서 구현)
  protected abstract when(event: DomainEvent): void;

  clearUncommittedEvents(): void {
    this._uncommittedEvents = [];
  }
}

// 구체적인 Event Sourced Aggregate
class Account extends EventSourcedAggregate<AccountId> {
  private _id!: AccountId;
  private _balance: Money = Money.zero();
  private _status: AccountStatus = AccountStatus.ACTIVE;

  get id(): AccountId { return this._id; }
  get balance(): Money { return this._balance; }

  // Factory Method
  static open(id: AccountId, initialDeposit: Money): Account {
    const account = new Account();
    account.apply(new AccountOpenedEvent(id, initialDeposit, new Date()));
    return account;
  }

  // Command Methods
  deposit(amount: Money): void {
    if (this._status !== AccountStatus.ACTIVE) {
      throw new AccountNotActiveError();
    }
    this.apply(new MoneyDepositedEvent(this._id, amount, new Date()));
  }

  withdraw(amount: Money): void {
    if (this._status !== AccountStatus.ACTIVE) {
      throw new AccountNotActiveError();
    }
    if (this._balance.isLessThan(amount)) {
      throw new InsufficientFundsError();
    }
    this.apply(new MoneyWithdrawnEvent(this._id, amount, new Date()));
  }

  close(): void {
    if (!this._balance.isZero()) {
      throw new AccountHasBalanceError();
    }
    this.apply(new AccountClosedEvent(this._id, new Date()));
  }

  // Event Handlers (상태 변경)
  protected when(event: DomainEvent): void {
    if (event instanceof AccountOpenedEvent) {
      this._id = event.accountId;
      this._balance = event.initialDeposit;
      this._status = AccountStatus.ACTIVE;
    } else if (event instanceof MoneyDepositedEvent) {
      this._balance = this._balance.add(event.amount);
    } else if (event instanceof MoneyWithdrawnEvent) {
      this._balance = this._balance.subtract(event.amount);
    } else if (event instanceof AccountClosedEvent) {
      this._status = AccountStatus.CLOSED;
    }
  }
}

1.3 Event Store

// Event Store Interface
interface EventStore {
  // 이벤트 저장
  append(
    streamId: string,
    events: DomainEvent[],
    expectedVersion: number
  ): Promise<void>;

  // 이벤트 조회
  getEvents(
    streamId: string,
    fromVersion?: number
  ): Promise<DomainEvent[]>;

  // 전체 이벤트 스트림 (Projection용)
  getAllEvents(
    fromPosition?: number,
    limit?: number
  ): Promise<{ events: DomainEvent[]; lastPosition: number }>;
}

// PostgreSQL 기반 Event Store 구현
class PostgresEventStore implements EventStore {
  async append(
    streamId: string,
    events: DomainEvent[],
    expectedVersion: number
  ): Promise<void> {
    await this.db.transaction(async (tx) => {
      // Optimistic Concurrency Check
      const currentVersion = await tx.query(
        'SELECT MAX(version) as version FROM events WHERE stream_id = $1',
        [streamId]
      );

      if (currentVersion.rows[0].version !== expectedVersion) {
        throw new ConcurrencyError(
          `Expected version ${expectedVersion}, but was ${currentVersion.rows[0].version}`
        );
      }

      // Append Events
      let version = expectedVersion;
      for (const event of events) {
        version++;
        await tx.query(`
          INSERT INTO events (
            event_id, stream_id, version, event_type, 
            payload, metadata, created_at
          ) VALUES ($1, $2, $3, $4, $5, $6, $7)
        `, [
          event.eventId,
          streamId,
          version,
          event.constructor.name,
          JSON.stringify(event),
          JSON.stringify({ correlationId: event.correlationId }),
          event.occurredAt
        ]);
      }
    });
  }

  async getEvents(streamId: string, fromVersion: number = 0): Promise<DomainEvent[]> {
    const result = await this.db.query(`
      SELECT event_type, payload 
      FROM events 
      WHERE stream_id = $1 AND version > $2
      ORDER BY version ASC
    `, [streamId, fromVersion]);

    return result.rows.map(row => 
      this.deserialize(row.event_type, row.payload)
    );
  }
}

1.4 Event Sourcing 장단점

장점

  • ✓ 완전한 감사 로그
  • ✓ 시간 여행 (특정 시점 상태 복원)
  • ✓ 이벤트 재생으로 버그 재현
  • ✓ 새로운 Read Model 추가 용이
  • ✓ 도메인 이벤트 자연스럽게 도출

단점

  • ✗ 학습 곡선
  • ✗ 이벤트 스키마 진화 복잡
  • ✗ 조회 시 재생 비용 (Snapshot 필요)
  • ✗ 삭제 어려움 (GDPR 대응)
  • ✗ 디버깅 복잡도

2. Snapshot과 Projection

2.1 Snapshot 패턴

이벤트가 많아지면 재생 시간이 길어집니다. Snapshot은 특정 시점의 상태를 저장하여 재생 시작점을 앞당깁니다.

// Snapshot 저장소
interface SnapshotStore {
  save(aggregateId: string, snapshot: Snapshot): Promise<void>;
  get(aggregateId: string): Promise<Snapshot | null>;
}

interface Snapshot {
  aggregateId: string;
  version: number;
  state: any;
  createdAt: Date;
}

// Snapshot을 활용한 Repository
class EventSourcedAccountRepository {
  constructor(
    private readonly eventStore: EventStore,
    private readonly snapshotStore: SnapshotStore,
    private readonly snapshotFrequency: number = 100  // 100 이벤트마다 스냅샷
  ) {}

  async findById(id: AccountId): Promise<Account | null> {
    const streamId = `account-${id.value}`;
    
    // 1. 스냅샷 조회
    const snapshot = await this.snapshotStore.get(streamId);
    
    // 2. 스냅샷 이후 이벤트만 조회
    const fromVersion = snapshot?.version ?? 0;
    const events = await this.eventStore.getEvents(streamId, fromVersion);
    
    if (!snapshot && events.length === 0) {
      return null;
    }

    // 3. Aggregate 복원
    const account = new Account();
    
    if (snapshot) {
      account.restoreFromSnapshot(snapshot.state);
    }
    
    account.loadFromHistory(events);
    
    return account;
  }

  async save(account: Account): Promise<void> {
    const streamId = `account-${account.id.value}`;
    const events = account.uncommittedEvents;
    
    if (events.length === 0) return;

    // 1. 이벤트 저장
    await this.eventStore.append(streamId, [...events], account.version);
    
    // 2. 스냅샷 저장 (주기적으로)
    const newVersion = account.version + events.length;
    if (newVersion % this.snapshotFrequency === 0) {
      await this.snapshotStore.save(streamId, {
        aggregateId: streamId,
        version: newVersion,
        state: account.toSnapshot(),
        createdAt: new Date()
      });
    }

    account.clearUncommittedEvents();
  }
}

// Aggregate에 Snapshot 지원 추가
class Account extends EventSourcedAggregate<AccountId> {
  // ... 기존 코드 ...

  toSnapshot(): AccountSnapshot {
    return {
      id: this._id.value,
      balance: this._balance.amount,
      currency: this._balance.currency,
      status: this._status
    };
  }

  restoreFromSnapshot(snapshot: AccountSnapshot): void {
    this._id = AccountId.from(snapshot.id);
    this._balance = Money.of(snapshot.balance, snapshot.currency);
    this._status = snapshot.status;
  }
}

2.2 Projection 구현

// Projection: 이벤트 스트림을 Read Model로 변환
interface Projection {
  name: string;
  handle(event: DomainEvent, position: number): Promise<void>;
}

// Account Balance Projection
class AccountBalanceProjection implements Projection {
  name = 'account-balance';

  constructor(private readonly readDb: ReadDatabase) {}

  async handle(event: DomainEvent, position: number): Promise<void> {
    if (event instanceof AccountOpenedEvent) {
      await this.readDb.execute(`
        INSERT INTO account_balances (account_id, balance, currency, status, last_updated)
        VALUES ($1, $2, $3, 'ACTIVE', $4)
      `, [event.accountId.value, event.initialDeposit.amount, 
          event.initialDeposit.currency, event.occurredAt]);
    }
    
    if (event instanceof MoneyDepositedEvent) {
      await this.readDb.execute(`
        UPDATE account_balances 
        SET balance = balance + $2, last_updated = $3
        WHERE account_id = $1
      `, [event.accountId.value, event.amount.amount, event.occurredAt]);
    }
    
    if (event instanceof MoneyWithdrawnEvent) {
      await this.readDb.execute(`
        UPDATE account_balances 
        SET balance = balance - $2, last_updated = $3
        WHERE account_id = $1
      `, [event.accountId.value, event.amount.amount, event.occurredAt]);
    }
  }
}

// Transaction History Projection
class TransactionHistoryProjection implements Projection {
  name = 'transaction-history';

  constructor(private readonly readDb: ReadDatabase) {}

  async handle(event: DomainEvent, position: number): Promise<void> {
    if (event instanceof MoneyDepositedEvent) {
      await this.readDb.execute(`
        INSERT INTO transactions (id, account_id, type, amount, occurred_at)
        VALUES ($1, $2, 'DEPOSIT', $3, $4)
      `, [event.eventId, event.accountId.value, event.amount.amount, event.occurredAt]);
    }
    
    if (event instanceof MoneyWithdrawnEvent) {
      await this.readDb.execute(`
        INSERT INTO transactions (id, account_id, type, amount, occurred_at)
        VALUES ($1, $2, 'WITHDRAWAL', $3, $4)
      `, [event.eventId, event.accountId.value, event.amount.amount, event.occurredAt]);
    }
  }
}

// Projection Manager
class ProjectionManager {
  private projections: Projection[] = [];
  private checkpointStore: CheckpointStore;

  register(projection: Projection): void {
    this.projections.push(projection);
  }

  async run(): Promise<void> {
    while (true) {
      for (const projection of this.projections) {
        await this.processProjection(projection);
      }
      await this.delay(100);  // Polling interval
    }
  }

  private async processProjection(projection: Projection): Promise<void> {
    const checkpoint = await this.checkpointStore.get(projection.name);
    const { events, lastPosition } = await this.eventStore.getAllEvents(
      checkpoint?.position ?? 0,
      100  // Batch size
    );

    for (const event of events) {
      await projection.handle(event, lastPosition);
    }

    if (events.length > 0) {
      await this.checkpointStore.save(projection.name, lastPosition);
    }
  }
}

2.3 이벤트 스키마 진화

// 이벤트 버전 관리
interface VersionedEvent {
  version: number;
}

// V1: 초기 버전
interface MoneyDepositedEventV1 extends VersionedEvent {
  version: 1;
  accountId: string;
  amount: number;  // 단일 통화 가정
}

// V2: 다중 통화 지원
interface MoneyDepositedEventV2 extends VersionedEvent {
  version: 2;
  accountId: string;
  amount: number;
  currency: string;  // 새 필드 추가
}

// Upcaster: 구버전 → 신버전 변환
interface EventUpcaster<TFrom, TTo> {
  canUpcast(event: any): boolean;
  upcast(event: TFrom): TTo;
}

class MoneyDepositedV1ToV2Upcaster implements EventUpcaster<MoneyDepositedEventV1, MoneyDepositedEventV2> {
  canUpcast(event: any): boolean {
    return event.type === 'MoneyDeposited' && event.version === 1;
  }

  upcast(event: MoneyDepositedEventV1): MoneyDepositedEventV2 {
    return {
      ...event,
      version: 2,
      currency: 'KRW'  // 기본값 설정
    };
  }
}

// Event Store에서 Upcasting 적용
class UpcastingEventStore implements EventStore {
  constructor(
    private readonly inner: EventStore,
    private readonly upcasters: EventUpcaster<any, any>[]
  ) {}

  async getEvents(streamId: string, fromVersion?: number): Promise<DomainEvent[]> {
    const events = await this.inner.getEvents(streamId, fromVersion);
    
    return events.map(event => this.upcast(event));
  }

  private upcast(event: any): DomainEvent {
    let current = event;
    
    for (const upcaster of this.upcasters) {
      if (upcaster.canUpcast(current)) {
        current = upcaster.upcast(current);
      }
    }
    
    return current;
  }
}

3. Event Storming

"Event Storming은 복잡한 비즈니스 도메인을 빠르게 탐색하는 워크샵 기반 방법이다. 개발자와 도메인 전문가가 함께 큰 그림을 그린다."

— Alberto Brandolini, Event Storming 창시자

3.1 Event Storming 진행 방법

1

Domain Events 나열 (오렌지)

과거형으로 "~되었다" 형태. 시간순으로 배치.

주문이 생성되었다결제가 완료되었다상품이 배송되었다
2

Commands 식별 (파란색)

이벤트를 발생시키는 명령. 명령형으로 작성.

주문하기결제하기배송 시작하기
3

Aggregates 식별 (노란색)

명령을 받고 이벤트를 발생시키는 주체.

주문결제배송
4

Bounded Context 경계 그리기

관련된 Aggregate들을 묶어 컨텍스트 경계 식별.

3.2 Event Storming 결과물

┌─────────────────────────────────────────────────────────────────────────────┐
│                        이커머스 Event Storming 결과                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                    Order Context (주문 컨텍스트)                      │   │
│  │                                                                     │   │
│  │  [고객]──▶[주문하기]──▶[주문]──▶[주문이 생성되었다]                    │   │
│  │                         │                                          │   │
│  │                         ▼                                          │   │
│  │           [주문 확정하기]──▶[주문이 확정되었다]                        │   │
│  │                                                                     │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                              │                                             │
│                              │ 주문이 생성되었다                            │
│                              ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                   Payment Context (결제 컨텍스트)                     │   │
│  │                                                                     │   │
│  │  [결제하기]──▶[결제]──▶[결제가 완료되었다] / [결제가 실패했다]          │   │
│  │                                                                     │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                              │                                             │
│                              │ 결제가 완료되었다                            │
│                              ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                  Inventory Context (재고 컨텍스트)                    │   │
│  │                                                                     │   │
│  │  [재고 예약하기]──▶[재고]──▶[재고가 예약되었다] / [재고 부족]           │   │
│  │                                                                     │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                              │                                             │
│                              │ 재고가 예약되었다                            │
│                              ▼                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                  Shipping Context (배송 컨텍스트)                     │   │
│  │                                                                     │   │
│  │  [배송 시작하기]──▶[배송]──▶[배송이 시작되었다]──▶[배송이 완료되었다]   │   │
│  │                                                                     │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  범례: [Actor] [Command] [Aggregate] [Domain Event]                        │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

3.3 Event Storming 팁

✅ Do

  • • 도메인 전문가 반드시 참여
  • • 큰 벽면/화이트보드 사용
  • • 포스트잇 색상 규칙 준수
  • • 질문과 문제점 빨간 포스트잇으로 표시
  • • 타임박싱 (2-4시간)

❌ Don't

  • • 기술적 세부사항에 빠지지 않기
  • • 완벽함 추구하지 않기
  • • 한 사람이 독점하지 않기
  • • 처음부터 코드 생각하지 않기
  • • 너무 작은 이벤트 나열하지 않기

4. DDD와 아키텍처

4.1 Hexagonal Architecture (Ports & Adapters)

┌─────────────────────────────────────────────────────────────────────────┐
│                        Hexagonal Architecture                            │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│                    ┌─────────────────────────┐                          │
│    REST API ──────▶│                         │◀────── CLI               │
│                    │      Application        │                          │
│    GraphQL ───────▶│        (Ports)          │◀────── Message Queue     │
│                    │                         │                          │
│                    │  ┌─────────────────┐    │                          │
│                    │  │                 │    │                          │
│                    │  │     Domain      │    │                          │
│                    │  │   (Core Logic)  │    │                          │
│                    │  │                 │    │                          │
│                    │  └─────────────────┘    │                          │
│                    │                         │                          │
│                    │      Infrastructure     │                          │
│                    │        (Adapters)       │                          │
│                    └───────────┬─────────────┘                          │
│                                │                                        │
│              ┌─────────────────┼─────────────────┐                      │
│              ▼                 ▼                 ▼                      │
│         PostgreSQL         Redis            Kafka                       │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
// Hexagonal Architecture 구조
src/
├── domain/                    # 핵심 도메인 (의존성 없음)
│   ├── order/
│   │   ├── Order.ts          # Aggregate
│   │   ├── OrderItem.ts      # Entity
│   │   ├── OrderStatus.ts    # Value Object
│   │   └── events/
│   │       └── OrderCreated.ts
│   └── shared/
│       └── ValueObjects.ts
│
├── application/               # 유스케이스 (Port 정의)
│   ├── ports/
│   │   ├── inbound/          # Driving Ports (외부 → 내부)
│   │   │   ├── CreateOrderUseCase.ts
│   │   │   └── GetOrderUseCase.ts
│   │   └── outbound/         # Driven Ports (내부 → 외부)
│   │       ├── OrderRepository.ts
│   │       ├── PaymentGateway.ts
│   │       └── EventPublisher.ts
│   └── services/
│       ├── CreateOrderService.ts
│       └── GetOrderService.ts
│
└── infrastructure/            # Adapters (구현체)
    ├── adapters/
    │   ├── inbound/          # Driving Adapters
    │   │   ├── rest/
    │   │   │   └── OrderController.ts
    │   │   └── graphql/
    │   │       └── OrderResolver.ts
    │   └── outbound/         # Driven Adapters
    │       ├── persistence/
    │       │   └── TypeOrmOrderRepository.ts
    │       ├── payment/
    │       │   └── StripePaymentGateway.ts
    │       └── messaging/
    │           └── KafkaEventPublisher.ts
    └── config/
        └── DependencyInjection.ts

4.2 Clean Architecture

Entities (가장 안쪽)

핵심 비즈니스 규칙. 외부 변화에 영향받지 않음.

Use Cases

애플리케이션 비즈니스 규칙. 유스케이스 조율.

Interface Adapters

데이터 변환. Controller, Presenter, Gateway.

Frameworks (가장 바깥)

DB, Web Framework, UI. 가장 변경 가능성 높음.

의존성 규칙: 안쪽 레이어는 바깥쪽 레이어를 알지 못함. 의존성은 항상 안쪽을 향함.

4.3 Layered Architecture와 비교

특성LayeredHexagonalClean
의존성 방향위 → 아래바깥 → 안바깥 → 안
도메인 위치중간 레이어중심중심
인프라 교체어려움쉬움쉬움
테스트 용이성보통높음높음

4.4 팀 토폴로지와 Bounded Context

Conway's Law: 시스템 구조는 조직 구조를 반영한다. Bounded Context는 팀 경계와 일치시키는 것이 이상적입니다.

┌─────────────────────────────────────────────────────────────────────────┐
│                    Team Topology & Bounded Context                       │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐     │
│  │   Order Team    │    │  Payment Team   │    │ Shipping Team   │     │
│  │                 │    │                 │    │                 │     │
│  │ ┌─────────────┐ │    │ ┌─────────────┐ │    │ ┌─────────────┐ │     │
│  │ │   Order     │ │    │ │   Payment   │ │    │ │  Shipping   │ │     │
│  │ │  Context    │ │    │ │   Context   │ │    │ │   Context   │ │     │
│  │ └─────────────┘ │    │ └─────────────┘ │    │ └─────────────┘ │     │
│  │                 │    │                 │    │                 │     │
│  │ - Order API     │    │ - Payment API   │    │ - Shipping API  │     │
│  │ - Order DB      │    │ - Payment DB    │    │ - Shipping DB   │     │
│  │ - Order Events  │    │ - Payment Events│    │ - Shipping Events│    │
│  └─────────────────┘    └─────────────────┘    └─────────────────┘     │
│           │                     │                      │               │
│           └─────────────────────┼──────────────────────┘               │
│                                 │                                      │
│                    ┌────────────▼────────────┐                         │
│                    │    Platform Team        │                         │
│                    │  (Enabling Team)        │                         │
│                    │                         │                         │
│                    │  - Message Broker       │                         │
│                    │  - API Gateway          │                         │
│                    │  - Observability        │                         │
│                    └─────────────────────────┘                         │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

5. DDD 점진적 도입 전략

5.1 도입 단계

1

Ubiquitous Language 구축

도메인 전문가와 용어 정리. 용어집 작성. 코드에 반영.

예시: "주문" vs "Order", "결제" vs "Payment" 통일
2

Value Object 도입

Primitive Obsession 제거. Money, Email, Address 등 도입.

효과: 타입 안전성, 유효성 검증 중앙화, 가독성 향상
3

Entity와 Aggregate 식별

핵심 도메인부터 Aggregate 경계 설정. 불변식 정의.

주의: 처음엔 작게 시작, 필요시 확장
4

Repository 패턴 적용

Aggregate 단위로 Repository 구현. 인프라 분리.

효과: 테스트 용이성, 인프라 교체 가능
5

Domain Events 도입

Aggregate 간 통신을 이벤트로. 느슨한 결합.

효과: 확장성, 감사 로그, 비동기 처리
6

CQRS / Event Sourcing (선택)

필요한 경우에만. 복잡도 대비 이점 평가.

주의: 모든 시스템에 필요하지 않음

5.2 레거시 시스템에서의 DDD

Strangler Fig 패턴

레거시 시스템을 점진적으로 교체. 새 기능은 DDD로 구현하고, 기존 기능을 하나씩 마이그레이션.

Anti-Corruption Layer

레거시와 새 시스템 사이에 번역 계층 구축. 레거시 모델이 새 도메인 모델을 오염시키지 않도록 보호.

Bubble Context

레거시 내에 작은 DDD 영역 생성. 성공 사례를 만들고 점진적으로 확장.

5.3 DDD 적용 체크리스트

도메인 전문가와 정기적으로 대화하고 있는가?
Ubiquitous Language가 코드에 반영되어 있는가?
Bounded Context 경계가 명확한가?
Aggregate가 불변식을 보호하고 있는가?
도메인 로직이 도메인 레이어에 있는가?
인프라 의존성이 도메인에서 분리되어 있는가?
도메인 모델이 테스트 가능한가?
팀이 DDD 개념을 이해하고 있는가?

6. FAQ 및 DDD 시리즈 총정리

Q1: Event Sourcing은 언제 사용해야 하나요?

완전한 감사 로그가 필요하거나, 시간 여행(특정 시점 상태 복원)이 필요하거나, 복잡한 비즈니스 이벤트 흐름이 있는 경우에 적합합니다. 단순 CRUD에는 과도한 복잡도를 추가합니다.

Q2: DDD는 마이크로서비스에서만 사용하나요?

아니요. DDD는 모놀리식에서도 유용합니다. Bounded Context는 모듈 경계로 사용할 수 있고, 나중에 마이크로서비스로 분리할 때 자연스러운 경계가 됩니다.

Q3: 작은 프로젝트에도 DDD가 필요한가요?

전체 DDD를 적용할 필요는 없습니다. Value Object, Ubiquitous Language 같은 전술적 패턴은 작은 프로젝트에서도 유용합니다. 복잡도에 맞게 선택적으로 적용하세요.

Q4: DDD 학습 후 다음 단계는?

실제 프로젝트에 적용해보세요. Event Storming 워크샵을 진행하고, 작은 Bounded Context부터 시작하세요. "Implementing Domain-Driven Design" (Vaughn Vernon) 책을 추천합니다.

DDD 시리즈 총정리

세션주제핵심 개념
01DDD 소개복잡성 관리, 전략적/전술적 설계
02Ubiquitous Language공통 언어, 도메인 전문가 협업
03도메인과 서브도메인Core/Supporting/Generic 도메인
04Bounded Context컨텍스트 경계, 모델 분리
05Context Mapping컨텍스트 간 관계, 통합 패턴
06Entity와 Value Object식별성, 불변성, Primitive Obsession
07Aggregate일관성 경계, Aggregate Root
08Repository, Factory, Module영속성 추상화, 생성 패턴
09Service와 Supple DesignDomain/Application Service, 유연한 설계
10Domain Events이벤트 발행, Saga, Outbox 패턴
11CQRSCommand/Query 분리, Read Model
12Event Sourcing과 실천이벤트 저장, Event Storming, 아키텍처
🎯 전략적 설계
  • • Ubiquitous Language
  • • Bounded Context
  • • Context Mapping
  • • 서브도메인 분류
🔧 전술적 설계
  • • Entity & Value Object
  • • Aggregate
  • • Repository & Factory
  • • Domain Service
🚀 고급 패턴
  • • Domain Events
  • • CQRS
  • • Event Sourcing
  • • Saga
"DDD는 기술이 아니라 사고방식이다.
도메인을 이해하고, 그 이해를 코드에 반영하는 것이 핵심이다."

— Eric Evans