EDA가 분산된 모놀리스로 끝나지 않으려면 — Outbox·Saga·CQRS 핵심 패턴과 프로덕션 함정 3가지
저도 처음 Kafka(고처리량 분산 메시지 스트리밍 플랫폼)를 도입할 때 "이벤트 한 번 보내면 끝 아니야?"라고 생각했습니다. 그러다 중복 이벤트로 재고가 두 번 차감되는 사고를 냈습니다. 주문이 완료됐는데 재고가 두 개 빠져나가는 걸 발견했을 때의 당혹감이 아직도 생생합니다. EDA(이벤트 기반 아키텍처, Event-Driven Architecture)는 개념은 단순하지만, 프로덕션에서 안전하게 쓰려면 꼭 알아야 할 패턴들이 있습니다.
모놀리식 서비스를 마이크로서비스로 쪼개다 보면 어느 순간 이런 고민이 찾아옵니다. "주문 서비스가 결제 서비스를 직접 호출하면 편한데, 결제 서비스가 느려지면 주문 서비스도 같이 죽는 거 아닌가?" 동기 HTTP 호출로 서비스를 엮다 보면 결국 분산된 모놀리스가 탄생합니다. 트래픽이 몰리거나 하나가 장애를 맞으면 도미노처럼 무너지는 구조죠. EDA는 이 문제를 "서비스가 서로를 모르게 만들자"는 발상으로 풉니다. 주문 서비스는 그냥 "주문이 생성됐어요"라는 사실을 브로커에 던지고 끝냅니다.
이 글에서 다루는 네 가지 패턴(Outbox, Saga, CQRS + Event Sourcing, Event-Carried State Transfer)을 적용하면 재고 이중 차감 같은 사고를 사전에 막을 수 있습니다. 제가 직접 겪으며 배운 것들과, 프로덕션에서 흔히 빠지는 함정 세 가지를 같이 정리했습니다.
핵심 개념
이벤트는 명령이 아니라 '과거의 사실'
EDA를 처음 배울 때 가장 많이 헷갈리는 부분이 이벤트 이름입니다. CreateOrder가 아니라 OrderCreated를 써야 하는 이유가 있습니다.
이벤트는 이미 발생한 불변의 사실(immutable fact)입니다. 명령(Command)은 실행되지 않을 수 있지만, 이벤트는 이미 일어난 일입니다. 소비자는 이를 거부할 수 없고, 반응(react)할 뿐입니다.
EDA는 세 가지 구성 요소로 이루어집니다.
| 구성 요소 | 역할 | 예시 |
|---|---|---|
| 이벤트 생산자 (Producer) | 상태 변화 발생 시 이벤트를 브로커에 발행 | 주문 서비스, 결제 서비스 |
| 이벤트 브로커 (Broker) | 이벤트를 수신·저장·전달하는 중앙 허브 | Kafka, RabbitMQ, EventBridge |
| 이벤트 소비자 (Consumer) | 관심 있는 이벤트를 구독하고 비즈니스 로직 실행 | 알림 서비스, 정산 서비스 |
Pub/Sub vs. Event Streaming — 어떻게 다른가
두 모델을 헷갈리는 경우가 많은데, 핵심 차이는 "이벤트를 언제 읽느냐"입니다.
| 모델 | 동작 방식 | 적합한 상황 |
|---|---|---|
| Pub/Sub | 브로커가 구독자에게 즉시 전달. 읽은 뒤 사라짐 | 실시간 알림, 단방향 팬아웃 |
| Event Streaming | 소비자가 로그의 원하는 오프셋부터 직접 읽음 | 재처리, 감사, Event Sourcing |
Kafka가 강력한 이유가 여기에 있습니다. 이벤트를 디스크에 영구 저장하기 때문에 새로운 서비스를 붙이면 과거 이벤트를 처음부터 재처리할 수 있습니다. 이건 Pub/Sub 방식에서는 불가능한 일입니다.
실전 적용
개념을 살펴봤으니, 이제 이 개념들이 실제 코드로 어떻게 구현되는지 네 가지 패턴으로 나눠서 봅니다.
예시 1: Transactional Outbox — DB 저장과 이벤트 발행의 원자성 확보
실무에서 가장 먼저 맞닥뜨리는 난제가 있습니다. "DB에 데이터를 저장하고 Kafka에 이벤트를 발행해야 하는데, 둘 다 성공을 어떻게 보장하지?" DB 저장은 성공했는데 Kafka 발행이 실패하면? 반대로 Kafka는 성공했는데 DB가 롤백되면?
이 이중 쓰기(dual write) 문제를 해결하는 것이 Transactional Outbox 패턴입니다.
[서비스]
│
└──── 단일 DB 트랜잭션 ─────┬──► [orders 테이블] (도메인 데이터)
└──► [outbox 테이블] (발행할 이벤트)
│
[Debezium CDC] ← DB 변경을 실시간으로 감지해 Kafka로 전달
│
[Kafka 브로커]
│
[알림/정산/재고 서비스]핵심은 "비즈니스 로직 실행"과 "이벤트 기록"을 같은 DB 트랜잭션에 묶는 것입니다. Debezium(PostgreSQL, MySQL 등의 변경 데이터를 실시간으로 캡처하는 CDC 도구)이 outbox 테이블 변경을 감지해 Kafka로 전달합니다.
-- outbox 테이블 스키마
CREATE TABLE outbox (
id UUID PRIMARY KEY,
event_type VARCHAR(255) NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
published BOOLEAN DEFAULT FALSE -- 폴링 방식에서 발행 완료 여부 추적
);// 주문 생성 서비스 — 단일 트랜잭션으로 처리 (Knex 기준)
async function createOrder(orderData: CreateOrderDto): Promise<void> {
await db.transaction(async (trx) => {
const [order] = await trx('orders').insert(orderData).returning('*');
await trx('outbox').insert({
id: uuid(),
event_type: 'OrderCreated',
aggregate_id: order.id,
payload: JSON.stringify({
orderId: order.id,
userId: orderData.userId,
items: orderData.items,
totalAmount: orderData.totalAmount,
createdAt: new Date().toISOString(),
}),
});
});
// 트랜잭션 커밋 후 Debezium이 outbox 변경을 감지해 Kafka로 발행
}Debezium 없이 처음 시작한다면 아래 폴링 방식으로도 충분히 패턴을 체감할 수 있습니다. published 컬럼이 이 방식에서 의미를 갖습니다.
// 폴링 기반 발행자 (Knex 기준) — Debezium 없이 시작할 때
async function pollAndPublish(): Promise<void> {
const pending = await db('outbox')
.where({ published: false })
.orderBy('created_at', 'asc')
.limit(100);
for (const event of pending) {
await kafkaProducer.send({
topic: event.event_type,
messages: [{ key: event.aggregate_id, value: event.payload }],
});
// 발행 성공 후 완료 처리
await db('outbox').where({ id: event.id }).update({ published: true });
}
}
// 1초마다 미발행 이벤트 확인
setInterval(pollAndPublish, 1_000);Stripe, PayPal 같은 핀테크 기업이 결제 트랜잭션에서 이 패턴을 사용하는 이유가 바로 이겁니다. 결제 데이터와 이벤트 사이의 불일치는 금전적 손실로 직결되니까요.
언제 쓰면 좋은가: DB와 이벤트 브로커 사이의 원자성이 반드시 필요한 모든 상황. 피해야 할 상황: 이벤트 순서나 원자성이 중요하지 않은 단순 알림성 이벤트라면 Outbox 없이 직접 발행이 더 단순합니다.
예시 2: Saga 패턴 — 분산 트랜잭션을 이벤트로 엮기
"주문 → 결제 → 재고 차감 → 배송 준비" 같은 흐름에서 중간에 결제가 실패하면 어떻게 될까요? 단일 DB에서라면 롤백 한 방이지만, 각 단계가 다른 서비스에 있을 때는 얘기가 다릅니다. Saga 패턴은 이 문제를 **보상 트랜잭션(Compensating Transaction)**으로 해결합니다.
Saga를 구현하는 방식은 두 가지입니다.
| 구분 | Choreography (안무형) | Orchestration (지휘형) |
|---|---|---|
| 제어 방식 | 각 서비스가 이벤트를 보고 스스로 판단 | 중앙 Orchestrator가 순서를 지시 |
| 결합도 | 낮음 — 이벤트 계약만 공유 | 중간 — Orchestrator에 의존 |
| 디버깅 | 흐름 추적이 어려움 | 단일 진입점이라 파악이 쉬움 |
| 보상 로직 | 각 서비스가 개별 구현 | Orchestrator가 중앙 관리 |
| 추천 상황 | 단순한 선형 플로우 | 조건 분기, 타임아웃이 복잡한 경우 |
솔직히 말하면, Choreography는 서비스가 3~4개일 때는 깔끔하지만 그 이상으로 늘어나면 "어떤 이벤트가 어디서 발행되고 어디가 구독하는지" 파악이 점점 어려워집니다. 우리 팀도 처음엔 Choreography로 시작했다가 서비스가 6개를 넘어서면서 흐름 추적이 너무 힘들어져 Orchestration으로 전환했습니다. 프로덕션 시스템 대부분이 Temporal이나 AWS Step Functions 같은 Orchestration 엔진을 택하는 이유가 거기 있습니다.
Temporal은 Saga 워크플로우를 코드로 표현할 수 있게 해주는 오케스트레이션 엔진입니다. TypeScript SDK 기준으로는 아래처럼 사용합니다.
// Temporal TypeScript SDK 기준 — 주문 Saga 워크플로우
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';
const {
processPayment, deductInventory, prepareShipment,
cancelShipment, restoreInventory, refundPayment,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '10 minutes',
});
export async function orderSagaWorkflow(orderId: string): Promise<void> {
try {
await processPayment({ orderId });
await deductInventory({ orderId });
await prepareShipment({ orderId });
} catch (error) {
// 실패 시 보상 트랜잭션을 역순으로 실행
await cancelShipment({ orderId });
await restoreInventory({ orderId });
await refundPayment({ orderId });
throw error;
}
}Temporal의 매력은 워크플로우 실행 히스토리를 자동으로 저장한다는 점입니다. 서버가 중간에 재시작되더라도 중단된 지점부터 재개할 수 있습니다. "AWS Step Functions의 코드 버전"이라고 생각하면 이해하기 쉽습니다.
언제 쓰면 좋은가: 여러 서비스에 걸친 비즈니스 트랜잭션이 필요할 때. 피해야 할 상황: 단계가 2개 이하로 단순하거나, 강한 일관성이 필요한 경우 — 보상 트랜잭션은 "이미 발생한 것을 되돌리는" 방식이라 엄밀한 원자성을 보장하지 않습니다.
예시 3: CQRS + Event Sourcing — 읽기와 쓰기의 분리
주문 목록 조회는 초당 수천 번이지만 주문 생성은 그보다 훨씬 적을 때, 같은 DB 모델을 쓰는 건 비효율적입니다. CQRS(Command Query Responsibility Segregation)는 이 문제를 "쓰기 모델과 읽기 모델을 아예 다르게 설계하자"로 풉니다.
Command Side (쓰기) Query Side (읽기)
──────────────────── ────────────────────
OrderService Projection Handler
│ │
▼ ▼
Event Store ──── 이벤트 발행 ─────► Read Model DB
(이벤트 전체 이력) (검색 최적화 뷰)
예: Elasticsearch, RedisEvent Sourcing을 함께 적용하면 현재 상태 대신 이벤트 전체 이력을 저장소로 사용합니다. "현재 주문 상태가 뭐지?"라는 질문에 답하려면 저장된 이벤트를 순서대로 재생(replay)해서 도출합니다. 코드에서 eventStore.getEvents('order', orderId)의 'order'는 DDD의 aggregate(집합체) 타입입니다 — 관련 이벤트를 하나의 논리적 단위로 묶는 개념으로, 여기서는 '주문'이라는 집합체에 속한 모든 이벤트를 가져옵니다.
// Order 인터페이스 정의
interface Order {
status: string;
trackingNumber?: string;
cancelReason?: string;
[key: string]: unknown;
}
const initialOrder: Order = { status: 'UNKNOWN' };
// Event Store에서 주문 상태 재구성 (Knex 기준)
async function rebuildOrderState(orderId: string): Promise<Order> {
const events = await eventStore.getEvents('order', orderId);
return events.reduce<Order>((order, event) => {
switch (event.type) {
case 'OrderCreated':
return { ...order, ...event.payload, status: 'CREATED' };
case 'PaymentConfirmed':
return { ...order, status: 'PAID' };
case 'OrderShipped':
return { ...order, status: 'SHIPPED', trackingNumber: event.payload.trackingNumber };
case 'OrderCancelled':
return { ...order, status: 'CANCELLED', cancelReason: event.payload.reason };
default:
return order;
}
}, initialOrder);
}한 가지 꼭 짚어둘 점이 있습니다. 이벤트가 수만 건 누적되면 매번 처음부터 replay하는 비용이 급격히 커집니다. 이를 해결하는 것이 Snapshot 패턴입니다. 일정 이벤트 개수마다(예: 100건마다) 현재 상태를 스냅샷으로 저장해두고, 이후 replay는 가장 최근 스냅샷에서 시작합니다. Event Sourcing을 프로덕션에 쓸 생각이라면 이 패턴을 처음부터 함께 설계하는 것을 권장합니다.
이벤트 #1 ~ #100 → [Snapshot: 100번째 상태 저장]
이벤트 #101 ~ #200 → [Snapshot: 200번째 상태 저장]
현재 상태 조회 = 최신 Snapshot + #201 이후 이벤트만 replayEvent Sourcing이 빛나는 순간: 법적 감사 로그가 필수인 금융·의료 시스템, "3시간 전 주문 상태가 어땠는지" 같은 타임 트래블 디버깅이 필요한 복잡한 도메인.
언제 쓰면 좋은가: 읽기/쓰기 트래픽 비율이 극단적으로 다르거나, 전체 변경 이력 보존이 비즈니스 요건인 경우. 피해야 할 상황: 단순 CRUD 도메인, 소규모 팀, 빠른 MVP가 필요한 경우. "내가 지금 감사 로그나 시간여행 디버깅이 필요한가?"라는 질문에 "아니오"라면, 일반적인 DB 설계가 더 나은 선택입니다.
예시 4: Event-Carried State Transfer — 추가 API 호출 없애기
이벤트를 받은 소비자가 "근데 이 주문의 배송 주소가 뭐지?"라며 주문 서비스를 다시 조회하는 패턴은 EDA의 장점을 반납하는 것과 같습니다. 이때 이벤트 페이로드에 소비자가 필요한 데이터를 충분히 담아두는 것이 Event-Carried State Transfer 패턴입니다.
// 너무 빈약한 이벤트 — 소비자가 추가 조회 필요
const thinEvent = {
type: 'OrderCreated',
orderId: '12345',
};
// 소비자가 자립할 수 있는 이벤트
const richEvent = {
type: 'OrderCreated',
id: uuid(),
source: 'order-service',
time: new Date().toISOString(),
data: {
orderId: '12345',
userId: 'user-789',
userEmail: 'customer@example.com', // 알림 서비스가 필요한 데이터
items: [{ productId: 'prod-1', quantity: 2, price: 15000 }],
totalAmount: 30000,
shippingAddress: { // 배송 서비스가 필요한 데이터
zipCode: '06234',
address: '서울시 강남구...',
},
},
};페이로드가 커지는 트레이드오프가 있지만, 서비스 간 런타임 의존을 끊는 효과가 더 클 때가 많습니다. 단, 이 패턴을 쓰면 안 되는 상황도 분명히 있습니다. 페이로드가 수 MB에 달하거나, 여러 서비스에 PII(개인정보)를 복제해야 하는 경우가 대표적입니다. 전자는 이벤트 브로커에 부담이 크고, 후자는 GDPR의 '잊혀질 권리' 요청이 들어올 때 복제된 모든 서비스의 데이터를 찾아 삭제해야 하는 악몽이 됩니다.
언제 쓰면 좋은가: 소비자가 이벤트만으로 처리를 완결할 수 있을 때. 피해야 할 상황: PII 포함 데이터, 수 MB 규모 페이로드, 또는 소비자가 항상 최신 상태를 봐야 하는 경우.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 느슨한 결합 | 생산자와 소비자가 서로의 존재를 모름. 독립 배포·확장 가능 |
| 탄력적 확장 | 소비자를 수평 확장해 처리량을 선형으로 늘릴 수 있음 |
| 장애 격리 | 소비자 장애 시 이벤트가 브로커에 보존되어 자동 재처리 |
| 감사 로그 | Event Sourcing과 결합하면 전체 이력이 자동 확보됨 |
| 유연한 통합 | 새 소비자를 추가해도 기존 생산자를 수정할 필요 없음 |
단점 및 주의사항
| 항목 | 설명 | 대응 방안 |
|---|---|---|
| 중복 이벤트 | 대부분의 브로커는 최소 1회 전달 보장 (at-least-once) | 소비자 측 멱등성 구현 필수 |
| 순서 보장 어려움 | Kafka는 파티션 내에서만 순서 보장 | 파티션 키를 신중히 설계 (예: orderId) |
| 디버깅 복잡성 | 동기 호출과 달리 콜 스택이 없음 | Correlation ID + OpenTelemetry 분산 추적 필수 |
| 최종 일관성 | 서비스 간 즉각적 일관성 보장 불가 | "처리 중" 상태를 UX에 명시적으로 노출 |
| 스키마 진화 | 구버전 이벤트를 소비자가 처리해야 함 | Schema Registry로 호환성 정책 강제 |
| 보상 트랜잭션 비용 | Saga 실패 시 자동 롤백 없음 | 단계마다 보상 로직 수동 구현 필요 |
최종 일관성(Eventual Consistency): 분산 시스템에서 모든 노드가 "언젠가는" 같은 상태로 수렴하지만, 그 직후에는 일시적으로 다른 값을 볼 수 있는 일관성 모델입니다. EDA를 택하는 순간 이 트레이드오프를 받아들이게 됩니다.
멱등성(Idempotency): 같은 연산을 여러 번 실행해도 결과가 동일한 성질입니다. EDA에서 소비자가 반드시 갖춰야 할 속성입니다.
실무에서 가장 흔한 실수
-
멱등성 구현을 미루는 것 — "보통은 한 번만 오니까 괜찮겠지"라고 생각하다가 장애 상황에서 중복 이벤트가 쏟아질 때 대형 사고가 납니다. 이벤트 ID 기반 중복 체크는 처음부터 구현해두는 것을 권장합니다.
-
Correlation ID 없이 시작하는 것 — 서비스가 3개만 넘어도 "이 이벤트가 어디서 시작됐는지" 추적이 불가능해집니다. 이벤트 봉투에
correlationId와causationId를 처음부터 포함시키고, OpenTelemetry(분산 추적을 위한 오픈소스 표준 라이브러리)로 체인을 연결하면 나중에 훨씬 편합니다. -
스키마를 구두로만 약속하는 것 — "OrderCreated 이벤트에 userId 필드 추가할게요"를 Slack으로만 공유하면 소비자 팀이 배포하기 전까지 장애가 날 수 있습니다. Confluent Schema Registry나 Apicurio Registry로 스키마 변경을 코드 수준에서 강제하는 것이 안전합니다.
마치며
EDA를 안전하게 쓰려면 Outbox로 원자성을, Saga로 분산 트랜잭션을, 멱등성으로 중복을 막아야 합니다. 개념보다 이 세 가지 축을 구현하는 과정에서 실력이 쌓입니다.
지금 바로 시작해볼 수 있는 3단계:
-
Outbox 테이블 추가 — 새 기능을 개발할 때 이벤트를 직접 Kafka에 발행하는 대신
outbox테이블에 먼저 기록하는 구조로 바꿔봅니다. 위의 폴링 코드에서kafkaProducer.send부분을 로그 출력으로 교체하면 Debezium 없이도 패턴 자체를 바로 체감할 수 있습니다. -
이벤트에 Correlation ID 붙이기 — 현재 발행 중인 이벤트 페이로드에
id,correlationId,source,time필드를 추가하고, 소비자가correlationId를 그대로 전달하도록 연결합니다. CloudEvents 스펙을 참고하면 표준 필드 구조를 바로 가져올 수 있습니다. -
소비자 하나를 멱등하게 리팩터링 — 가장 중요한 소비자 하나를 골라 처리된 이벤트 ID를 Redis나 DB에 기록하고 중복 이벤트를 skip하는 로직을 추가합니다. 저희 팀도 이걸 첫 번째 소비자에 적용한 뒤 나머지에 자연스럽게 확산됐습니다.
참고 자료
입문·개요
- Event-Driven Architecture: A Complete Guide (2026) | RisingWave
- Event-Driven Architecture (EDA): A Complete Introduction | Confluent
- 이벤트 구동 아키텍처 스타일 | Azure Architecture Center (한국어)
- 이벤트 기반 아키텍처 | AWS
패턴 심화
- Best Architectural Patterns for Event-Driven Systems | Gravitee
- The Ultimate Guide to Event-Driven Architecture Patterns | Solace
- Saga Design Pattern | Azure Architecture Center
- Saga choreography pattern | AWS Prescriptive Guidance
- Saga Pattern Demystified: Orchestration vs Choreography | ByteByteGo
- 이벤트 드리븐 아키텍처 + CQRS + Saga 패턴: MSA 실전 설계 | youngju.dev
프로덕션 운영
- Event-Driven Architecture: Production Pitfalls & Fixes | Medium
- Observability in Event-Driven Architectures | Datadog
- Designing Resilient Event-Driven Systems at Scale | InfoQ
표준 및 트렌드