Theory
고급 2-3시간 이론

DDD 이론 10: Domain Events

도메인에서 발생하는 중요한 사건을 모델링하고, 이벤트 기반 통합과 최종 일관성을 구현합니다.

Domain EventsEvent PublishingOutbox PatternSagaEventual Consistency
학습 목표
  • • Domain Event의 본질과 명명 규칙을 이해한다
  • • Outbox 패턴으로 안전하게 이벤트를 발행할 수 있다
  • • 멱등성을 보장하는 이벤트 핸들러를 구현할 수 있다
  • • Saga 패턴으로 분산 트랜잭션을 조율할 수 있다
  • • 이벤트 버전 관리와 DLQ 처리를 구현할 수 있다

1. Domain Events의 본질

"도메인 전문가가 관심을 가지는 어떤 사건이 발생했음을 나타낸다. Domain Event는 과거형으로 명명된다."

— Vaughn Vernon

1.1 Domain Event란?

Domain Event는 도메인에서 발생한 비즈니스적으로 의미 있는 사건입니다. 과거에 일어난 일이므로 불변이며, 과거형으로 명명합니다.

✅ 좋은 이벤트 이름

  • • OrderCreated (주문 생성됨)
  • • OrderConfirmed (주문 확정됨)
  • • PaymentCompleted (결제 완료됨)
  • • ItemShipped (상품 배송됨)
  • • CustomerRegistered (고객 등록됨)

❌ 나쁜 이벤트 이름

  • • CreateOrder (명령형)
  • • OrderEvent (너무 일반적)
  • • OrderUpdated (무엇이 변경?)
  • • ProcessPayment (명령형)
  • • CustomerData (이벤트가 아님)

1.2 Domain Event 구조

// Domain Event 기본 구조
interface DomainEvent {
  readonly eventId: string;        // 이벤트 고유 ID
  readonly occurredAt: Date;       // 발생 시각
  readonly aggregateId: string;    // 관련 Aggregate ID
  readonly aggregateType: string;  // Aggregate 타입
  readonly eventType: string;      // 이벤트 타입
}

// 구체적인 Domain Event
class OrderConfirmedEvent implements DomainEvent {
  readonly eventId: string;
  readonly occurredAt: Date;
  readonly aggregateId: string;
  readonly aggregateType = 'Order';
  readonly eventType = 'OrderConfirmed';

  constructor(
    readonly orderId: OrderId,
    readonly customerId: CustomerId,
    readonly items: ReadonlyArray<{
      productId: string;
      quantity: number;
      unitPrice: number;
    }>,
    readonly totalAmount: number
  ) {
    this.eventId = crypto.randomUUID();
    this.occurredAt = new Date();
    this.aggregateId = orderId.value;
  }
}

// Aggregate에서 이벤트 발행
class Order extends AggregateRoot<OrderId> {
  confirm(): void {
    this.ensureCanConfirm();
    this._status = OrderStatus.CONFIRMED;
    
    // 도메인 이벤트 등록
    this.addDomainEvent(new OrderConfirmedEvent(
      this.id,
      this._customerId,
      this._items.map(item => ({
        productId: item.productId.value,
        quantity: item.quantity.value,
        unitPrice: item.unitPrice.amount
      })),
      this._totalAmount.amount
    ));
  }
}

1.3 Domain Event vs Integration Event

구분Domain EventIntegration Event
범위Bounded Context 내부Bounded Context 간
전달 방식인메모리 / 동기메시지 브로커 / 비동기
스키마도메인 객체 포함 가능원시 타입만 (직렬화)
버전 관리덜 엄격엄격한 스키마 버전 관리

2. 이벤트 발행과 처리 패턴

2.1 이벤트 발행 패턴

Outbox 패턴 (권장)
// 트랜잭션 내에서 이벤트를 Outbox 테이블에 저장
class OrderApplicationService {
  @Transactional
  async confirmOrder(orderId: OrderId): Promise<void> {
    const order = await this.orderRepository.findById(orderId);
    order.confirm();
    
    // 1. Aggregate 저장
    await this.orderRepository.save(order);
    
    // 2. 이벤트를 Outbox 테이블에 저장 (같은 트랜잭션)
    for (const event of order.domainEvents) {
      await this.outboxRepository.save({
        id: event.eventId,
        aggregateId: event.aggregateId,
        eventType: event.eventType,
        payload: JSON.stringify(event),
        createdAt: event.occurredAt,
        published: false
      });
    }
    
    order.clearDomainEvents();
  }
}

// 별도 프로세스에서 Outbox 폴링하여 발행
class OutboxPublisher {
  @Scheduled('*/5 * * * * *')  // 5초마다
  async publishPendingEvents(): Promise<void> {
    const events = await this.outboxRepository.findUnpublished(100);
    
    for (const event of events) {
      try {
        await this.messageBroker.publish(event.eventType, event.payload);
        await this.outboxRepository.markAsPublished(event.id);
      } catch (error) {
        // 재시도 로직
        await this.outboxRepository.incrementRetryCount(event.id);
      }
    }
  }
}

2.2 이벤트 핸들러 구현

// 이벤트 핸들러 인터페이스
interface EventHandler<T extends DomainEvent> {
  handle(event: T): Promise<void>;
}

// 재고 차감 핸들러
class ReserveInventoryHandler implements EventHandler<OrderConfirmedEvent> {
  constructor(private readonly inventoryRepository: InventoryRepository) {}

  async handle(event: OrderConfirmedEvent): Promise<void> {
    for (const item of event.items) {
      const inventory = await this.inventoryRepository.findByProductId(
        ProductId.from(item.productId)
      );
      
      inventory.reserve(Quantity.of(item.quantity));
      await this.inventoryRepository.save(inventory);
    }
  }
}

// 알림 발송 핸들러
class SendOrderConfirmationHandler implements EventHandler<OrderConfirmedEvent> {
  constructor(
    private readonly customerRepository: CustomerRepository,
    private readonly emailService: EmailService
  ) {}

  async handle(event: OrderConfirmedEvent): Promise<void> {
    const customer = await this.customerRepository.findById(
      CustomerId.from(event.customerId.value)
    );
    
    await this.emailService.send({
      to: customer.email.value,
      subject: '주문이 확정되었습니다',
      template: 'order-confirmed',
      data: { orderId: event.orderId.value, items: event.items }
    });
  }
}

// 이벤트 디스패처
class EventDispatcher {
  private handlers: Map<string, EventHandler<any>[]> = new Map();

  register<T extends DomainEvent>(
    eventType: string,
    handler: EventHandler<T>
  ): void {
    const existing = this.handlers.get(eventType) || [];
    this.handlers.set(eventType, [...existing, handler]);
  }

  async dispatch(event: DomainEvent): Promise<void> {
    const handlers = this.handlers.get(event.eventType) || [];
    
    await Promise.all(
      handlers.map(handler => handler.handle(event))
    );
  }
}

2.3 멱등성 보장

// 이벤트 중복 처리 방지
class IdempotentEventHandler<T extends DomainEvent> implements EventHandler<T> {
  constructor(
    private readonly inner: EventHandler<T>,
    private readonly processedEventRepository: ProcessedEventRepository
  ) {}

  async handle(event: T): Promise<void> {
    // 이미 처리된 이벤트인지 확인
    const alreadyProcessed = await this.processedEventRepository.exists(
      event.eventId
    );
    
    if (alreadyProcessed) {
      console.log(`Event ${event.eventId} already processed, skipping`);
      return;
    }

    // 이벤트 처리
    await this.inner.handle(event);

    // 처리 완료 기록
    await this.processedEventRepository.save({
      eventId: event.eventId,
      processedAt: new Date()
    });
  }
}

4. 이벤트 기반 통합 패턴

4.1 Saga 패턴

Saga는 여러 서비스에 걸친 비즈니스 트랜잭션을 이벤트로 조율하는 패턴입니다. 분산 트랜잭션 대신 보상 트랜잭션으로 일관성을 유지합니다.

// Choreography 기반 Saga (이벤트 체인)
// 주문 → 결제 → 재고 → 배송

// 1. 주문 서비스
class OrderService {
  async createOrder(command: CreateOrderCommand): Promise<void> {
    const order = Order.create(command);
    await this.orderRepository.save(order);
    // OrderCreated 이벤트 발행 → 결제 서비스가 구독
  }

  // 결제 실패 시 보상
  @OnEvent('PaymentFailed')
  async handlePaymentFailed(event: PaymentFailedEvent): Promise<void> {
    const order = await this.orderRepository.findById(event.orderId);
    order.cancel('결제 실패');
    await this.orderRepository.save(order);
  }
}

// 2. 결제 서비스
class PaymentService {
  @OnEvent('OrderCreated')
  async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
    try {
      const payment = await this.processPayment(event);
      // PaymentCompleted 이벤트 발행 → 재고 서비스가 구독
    } catch (error) {
      // PaymentFailed 이벤트 발행 → 주문 서비스가 보상
    }
  }
}

// 3. 재고 서비스
class InventoryService {
  @OnEvent('PaymentCompleted')
  async handlePaymentCompleted(event: PaymentCompletedEvent): Promise<void> {
    try {
      await this.reserveInventory(event.items);
      // InventoryReserved 이벤트 발행
    } catch (error) {
      // InventoryReservationFailed 발행 → 결제 환불 트리거
    }
  }

  // 배송 실패 시 보상
  @OnEvent('ShipmentFailed')
  async handleShipmentFailed(event: ShipmentFailedEvent): Promise<void> {
    await this.releaseInventory(event.items);
  }
}

4.2 Orchestration vs Choreography

🎭 Choreography

각 서비스가 이벤트를 발행하고 구독하여 자율적으로 동작

  • ✓ 느슨한 결합
  • ✓ 단순한 서비스
  • ✗ 흐름 파악 어려움
  • ✗ 순환 의존 위험

🎼 Orchestration

중앙 오케스트레이터가 전체 흐름을 제어

  • ✓ 명확한 흐름
  • ✓ 쉬운 모니터링
  • ✗ 단일 실패점
  • ✗ 오케스트레이터 복잡도
Orchestration 기반 Saga
// Saga Orchestrator
class OrderSagaOrchestrator {
  private steps: SagaStep[] = [
    { execute: 'reserveInventory', compensate: 'releaseInventory' },
    { execute: 'processPayment', compensate: 'refundPayment' },
    { execute: 'createShipment', compensate: 'cancelShipment' }
  ];

  async execute(orderId: string): Promise<void> {
    const context = new SagaContext(orderId);
    const completedSteps: SagaStep[] = [];

    try {
      for (const step of this.steps) {
        await this.executeStep(step.execute, context);
        completedSteps.push(step);
      }
      await this.markSagaCompleted(orderId);
    } catch (error) {
      // 역순으로 보상 트랜잭션 실행
      for (const step of completedSteps.reverse()) {
        try {
          await this.executeStep(step.compensate, context);
        } catch (compensateError) {
          // 보상 실패 로깅 및 수동 개입 필요
          await this.alertManualIntervention(orderId, step, compensateError);
        }
      }
      await this.markSagaFailed(orderId, error);
    }
  }
}

4.3 이벤트 버전 관리

// 이벤트 버전 관리 전략
// 1. 하위 호환성 유지 (필드 추가만)
interface OrderCreatedEventV1 {
  orderId: string;
  customerId: string;
  items: OrderItem[];
  totalAmount: number;
}

interface OrderCreatedEventV2 extends OrderCreatedEventV1 {
  // 새 필드 추가 (선택적)
  couponCode?: string;
  discountAmount?: number;
}

// 2. 이벤트 업캐스터 (구버전 → 신버전 변환)
class OrderCreatedEventUpcaster {
  canUpcast(event: any): boolean {
    return event.eventType === 'OrderCreated' && !event.version;
  }

  upcast(event: OrderCreatedEventV1): OrderCreatedEventV2 {
    return {
      ...event,
      couponCode: undefined,
      discountAmount: 0
    };
  }
}

// 3. 이벤트 핸들러에서 버전 처리
class OrderCreatedHandler {
  async handle(event: OrderCreatedEventV1 | OrderCreatedEventV2): Promise<void> {
    const discountAmount = 'discountAmount' in event 
      ? event.discountAmount 
      : 0;
    
    // 버전에 관계없이 처리
  }
}

4.4 Dead Letter Queue (DLQ)

처리 실패한 이벤트를 별도 큐에 저장하여 나중에 분석하고 재처리합니다.

// DLQ 처리 전략
class EventConsumer {
  private readonly maxRetries = 3;

  async consume(event: DomainEvent): Promise<void> {
    let retryCount = 0;

    while (retryCount < this.maxRetries) {
      try {
        await this.handler.handle(event);
        return; // 성공
      } catch (error) {
        retryCount++;
        
        if (this.isRetryable(error) && retryCount < this.maxRetries) {
          await this.delay(this.calculateBackoff(retryCount));
          continue;
        }
        
        // 재시도 불가 또는 최대 재시도 초과
        await this.sendToDeadLetterQueue(event, error, retryCount);
        return;
      }
    }
  }

  private calculateBackoff(retryCount: number): number {
    // 지수 백오프: 1초, 2초, 4초...
    return Math.pow(2, retryCount - 1) * 1000;
  }

  private async sendToDeadLetterQueue(
    event: DomainEvent,
    error: Error,
    retryCount: number
  ): Promise<void> {
    await this.dlqRepository.save({
      eventId: event.eventId,
      eventType: event.eventType,
      payload: JSON.stringify(event),
      error: error.message,
      stackTrace: error.stack,
      retryCount,
      failedAt: new Date()
    });
    
    // 알림 발송
    await this.alertService.notify({
      type: 'DLQ_EVENT',
      message: `Event ${event.eventId} moved to DLQ after ${retryCount} retries`
    });
  }
}

5. 워크샵: 이커머스 이벤트 흐름 설계

📋 시나리오

이커머스 플랫폼에서 주문 생성부터 배송 완료까지의 이벤트 흐름을 설계합니다. 여러 Bounded Context 간의 통합과 보상 트랜잭션을 포함합니다.

5.1 이벤트 흐름 다이어그램

┌─────────────────────────────────────────────────────────────────────────┐
│                        이커머스 이벤트 흐름                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  [고객]                                                                  │
│    │                                                                    │
│    ▼ CreateOrder                                                        │
│  ┌─────────────┐                                                        │
│  │ Order       │──OrderCreated──▶┌─────────────┐                        │
│  │ Context     │                 │ Payment     │                        │
│  │             │◀─PaymentFailed──│ Context     │                        │
│  │             │◀─PaymentCompleted│            │                        │
│  └─────────────┘                 └─────────────┘                        │
│        │                               │                                │
│        │ OrderConfirmed                │ PaymentCompleted               │
│        ▼                               ▼                                │
│  ┌─────────────┐                 ┌─────────────┐                        │
│  │ Inventory   │◀────────────────│ Shipping    │                        │
│  │ Context     │ ShipmentFailed  │ Context     │                        │
│  │             │──InventoryReserved──▶         │                        │
│  └─────────────┘                 └─────────────┘                        │
│        │                               │                                │
│        │                               │ ShipmentDelivered              │
│        ▼                               ▼                                │
│  ┌─────────────┐                 ┌─────────────┐                        │
│  │ Notification│◀────────────────│ Analytics   │                        │
│  │ Context     │                 │ Context     │                        │
│  └─────────────┘                 └─────────────┘                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

5.2 이벤트 정의

// ===== Order Context Events =====
interface OrderCreatedEvent {
  eventId: string;
  occurredAt: Date;
  orderId: string;
  customerId: string;
  items: Array<{
    productId: string;
    productName: string;
    quantity: number;
    unitPrice: number;
  }>;
  shippingAddress: {
    street: string;
    city: string;
    zipCode: string;
  };
  totalAmount: number;
}

interface OrderConfirmedEvent {
  eventId: string;
  occurredAt: Date;
  orderId: string;
  confirmedAt: Date;
}

interface OrderCancelledEvent {
  eventId: string;
  occurredAt: Date;
  orderId: string;
  reason: string;
  cancelledBy: 'CUSTOMER' | 'SYSTEM' | 'ADMIN';
}

// ===== Payment Context Events =====
interface PaymentCompletedEvent {
  eventId: string;
  occurredAt: Date;
  paymentId: string;
  orderId: string;
  amount: number;
  method: 'CARD' | 'BANK_TRANSFER' | 'VIRTUAL_ACCOUNT';
  transactionId: string;
}

interface PaymentFailedEvent {
  eventId: string;
  occurredAt: Date;
  orderId: string;
  reason: string;
  errorCode: string;
}

interface PaymentRefundedEvent {
  eventId: string;
  occurredAt: Date;
  paymentId: string;
  orderId: string;
  refundAmount: number;
  reason: string;
}

// ===== Inventory Context Events =====
interface InventoryReservedEvent {
  eventId: string;
  occurredAt: Date;
  orderId: string;
  reservations: Array<{
    productId: string;
    quantity: number;
    warehouseId: string;
  }>;
}

interface InventoryReservationFailedEvent {
  eventId: string;
  occurredAt: Date;
  orderId: string;
  failedItems: Array<{
    productId: string;
    requestedQuantity: number;
    availableQuantity: number;
  }>;
}

// ===== Shipping Context Events =====
interface ShipmentCreatedEvent {
  eventId: string;
  occurredAt: Date;
  shipmentId: string;
  orderId: string;
  carrier: string;
  trackingNumber: string;
  estimatedDelivery: Date;
}

interface ShipmentDeliveredEvent {
  eventId: string;
  occurredAt: Date;
  shipmentId: string;
  orderId: string;
  deliveredAt: Date;
  receivedBy: string;
}

5.3 Saga 구현

// Order Saga - Choreography 방식
class OrderSaga {
  // 상태 머신 정의
  private readonly stateMachine = {
    CREATED: {
      on: {
        PaymentCompleted: 'PAID',
        PaymentFailed: 'CANCELLED'
      }
    },
    PAID: {
      on: {
        InventoryReserved: 'INVENTORY_RESERVED',
        InventoryReservationFailed: 'REFUNDING'
      }
    },
    INVENTORY_RESERVED: {
      on: {
        ShipmentCreated: 'SHIPPED',
        ShipmentFailed: 'RELEASING_INVENTORY'
      }
    },
    SHIPPED: {
      on: {
        ShipmentDelivered: 'COMPLETED'
      }
    },
    // 보상 상태
    REFUNDING: {
      on: {
        PaymentRefunded: 'CANCELLED'
      }
    },
    RELEASING_INVENTORY: {
      on: {
        InventoryReleased: 'REFUNDING'
      }
    },
    CANCELLED: { final: true },
    COMPLETED: { final: true }
  };
}

// 각 Context의 이벤트 핸들러
class PaymentEventHandler {
  @OnEvent('OrderCreated')
  async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
    try {
      const payment = await this.paymentService.processPayment({
        orderId: event.orderId,
        amount: event.totalAmount,
        customerId: event.customerId
      });
      
      await this.eventPublisher.publish(new PaymentCompletedEvent({
        paymentId: payment.id,
        orderId: event.orderId,
        amount: event.totalAmount,
        method: payment.method,
        transactionId: payment.transactionId
      }));
    } catch (error) {
      await this.eventPublisher.publish(new PaymentFailedEvent({
        orderId: event.orderId,
        reason: error.message,
        errorCode: error.code
      }));
    }
  }

  @OnEvent('InventoryReservationFailed')
  async handleInventoryFailed(event: InventoryReservationFailedEvent): Promise<void> {
    // 보상: 결제 환불
    const payment = await this.paymentRepository.findByOrderId(event.orderId);
    await this.paymentService.refund(payment.id, '재고 부족으로 인한 환불');
  }
}

class InventoryEventHandler {
  @OnEvent('PaymentCompleted')
  async handlePaymentCompleted(event: PaymentCompletedEvent): Promise<void> {
    const order = await this.orderQueryService.getOrder(event.orderId);
    
    try {
      const reservations = await this.inventoryService.reserve(order.items);
      
      await this.eventPublisher.publish(new InventoryReservedEvent({
        orderId: event.orderId,
        reservations
      }));
    } catch (error) {
      await this.eventPublisher.publish(new InventoryReservationFailedEvent({
        orderId: event.orderId,
        failedItems: error.failedItems
      }));
    }
  }

  @OnEvent('ShipmentFailed')
  async handleShipmentFailed(event: ShipmentFailedEvent): Promise<void> {
    // 보상: 재고 해제
    await this.inventoryService.release(event.orderId);
  }
}

5.4 모니터링과 추적

// 이벤트 추적을 위한 Correlation ID
interface TrackedEvent extends DomainEvent {
  correlationId: string;  // 전체 흐름 추적
  causationId: string;    // 직접 원인 이벤트
}

// 이벤트 로그 저장
class EventLogger {
  async log(event: TrackedEvent): Promise<void> {
    await this.eventLogRepository.save({
      eventId: event.eventId,
      eventType: event.eventType,
      correlationId: event.correlationId,
      causationId: event.causationId,
      aggregateId: event.aggregateId,
      payload: JSON.stringify(event),
      occurredAt: event.occurredAt,
      context: this.getCurrentContext()
    });
  }
}

// Saga 상태 조회
class SagaMonitor {
  async getSagaStatus(orderId: string): Promise<SagaStatus> {
    const events = await this.eventLogRepository.findByCorrelationId(orderId);
    
    return {
      orderId,
      currentState: this.calculateCurrentState(events),
      timeline: events.map(e => ({
        eventType: e.eventType,
        occurredAt: e.occurredAt,
        context: e.context
      })),
      isCompleted: this.isTerminalState(events),
      hasFailed: this.hasFailureEvent(events)
    };
  }
}

// 대시보드 쿼리
class SagaDashboard {
  async getMetrics(timeRange: TimeRange): Promise<SagaMetrics> {
    return {
      totalSagas: await this.countSagas(timeRange),
      completedSagas: await this.countByState('COMPLETED', timeRange),
      failedSagas: await this.countByState('CANCELLED', timeRange),
      inProgressSagas: await this.countInProgress(timeRange),
      averageCompletionTime: await this.calculateAvgTime(timeRange),
      failureReasons: await this.groupFailureReasons(timeRange)
    };
  }
}

5.5 실습 과제

🎯 과제 1: 반품 이벤트 흐름
  • • ReturnRequested 이벤트 정의
  • • 반품 승인 → 재고 복구 → 환불 흐름
  • • 부분 반품 처리
🎯 과제 2: 재고 부족 알림
  • • LowStockDetected 이벤트 정의
  • • 자동 발주 트리거
  • • 관리자 알림 발송

3. FAQ 및 핵심 요약

Q1: 모든 상태 변경에 이벤트를 발행해야 하나요?

아니요. 비즈니스적으로 의미 있는 사건만 이벤트로 발행합니다. 도메인 전문가가 관심을 가질 만한 사건인지 판단하세요.

Q2: 이벤트 순서가 보장되나요?

일반적으로 보장되지 않습니다. 순서가 중요하다면 같은 파티션 키를 사용하거나, 이벤트에 시퀀스 번호를 포함하세요.

Q3: 이벤트 스키마가 변경되면?

하위 호환성을 유지하세요. 필드 추가는 괜찮지만, 삭제나 타입 변경은 새 버전의 이벤트를 만드세요.

4. 핵심 요약

📢 Domain Event
  • • 과거에 발생한 비즈니스 사건
  • • 불변, 과거형 이름
  • • Aggregate에서 발행
  • • 최종 일관성의 기반
🔄 발행 패턴
  • • Outbox 패턴 권장
  • • 트랜잭션과 함께 저장
  • • 멱등성 보장 필수
  • • 재시도 메커니즘
"Domain Event는 도메인 전문가가 관심을 가지는
어떤 사건이 발생했음을 나타낸다."

— Vaughn Vernon