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 Event | Integration 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