Saga 패턴 실전 — 마이크로서비스 분산 트랜잭션 보상 설계 (Choreography vs Orchestration)
마이크로서비스로 전환하고 나서 가장 먼저 부딪히는 벽이 뭔지 아시나요? 저는 "주문은 생성됐는데 결제가 실패했을 때 재고를 어떻게 되돌리지?"라는 질문이었습니다. 모놀리식 시절엔 그냥 @Transactional 하나면 해결됐던 문제가, 서비스가 3개로 나뉘는 순간 완전히 다른 세계가 되더군요. DB가 다르고, 네트워크가 끊길 수 있고, 보상 로직이 뒤엉키기 시작합니다.
Saga 패턴은 이 문제에 대한 현업에서 가장 많이 채택되는 접근 중 하나입니다. 2PC(Two-Phase Commit)처럼 전체를 잠그는 대신, 각 서비스가 자신의 DB에만 커밋하고, 실패하면 "보상 트랜잭션"으로 논리적으로 되돌리는 방식이죠. 이 글에서는 Choreography와 Orchestration 두 구현 방식을 TypeScript 코드로 직접 비교하고, 실무에서 보상 이벤트를 어떻게 설계하면 좋은지 짚어봅니다.
이 글을 읽고 나면, "어떤 상황에서 어떤 방식을 쓸지" 그리고 "Outbox 패턴이 왜 사실상 필수인지"에 대한 감이 잡히실 거라 생각합니다. 본문 코드는 TypeScript 기반이며, Kafka 같은 메시지 브로커를 다뤄본 경험이 있는 백엔드 개발자라면 더 자연스럽게 따라오실 수 있습니다.
핵심 개념
Saga가 해결하는 문제
분산 시스템에서 여러 서비스에 걸친 트랜잭션을 처리하려면 크게 두 가지 선택지가 있습니다. 2PC처럼 분산 락으로 원자성을 강제하거나, Saga처럼 보상 이벤트로 일관성을 "나중에" 맞추거나.
실무에서 2PC를 선택하기 어려운 이유는 명확합니다. 코디네이터가 죽으면 전체 서비스가 멈추고, 락을 길게 잡는 동안 처리량이 급감합니다. 특히 Kafka나 외부 결제 API처럼 트랜잭션에 참여할 수 없는 시스템이 끼면 2PC 자체가 불가능해지기도 하죠.
Saga는 이 문제를 다른 각도로 접근합니다.
[전통적인 분산 트랜잭션 — 2PC]
BEGIN DISTRIBUTED TX
Order Service.insert() ← 락
Inventory Service.update() ← 락
Payment Service.charge() ← 락
COMMIT (or ROLLBACK) ← 모두 동시에
[Saga 패턴]
Order Service.insert() → 커밋 → 이벤트 발행
Inventory Service.update() → 커밋 → 이벤트 발행
Payment Service.charge() → 커밋 (실패 시: 보상 이벤트 역순 실행)결과적 일관성(Eventual Consistency) : 즉각적인 일관성 대신, 시간이 지나면 모든 서비스의 상태가 일치하게 되는 성질. Saga는 이 트레이드오프를 의도적으로 수용합니다.
한 가지 솔직하게 말씀드리면, Saga는 "격리성을 포기"합니다. 주문이 생성되고 결제가 처리되는 사이 잠깐, 다른 Saga가 아직 완료되지 않은 주문의 중간 상태를 읽을 수 있습니다. Saga 문헌에서는 이를 Lost Update 또는 Intermediate State Visibility 문제라고 부릅니다. DB의 "더티 리드"와는 다른 개념으로, 각 서비스에서는 이미 커밋된 데이터지만 전체 Saga는 아직 완료되지 않은 상태죠. 이런 중간 상태 노출이 허용되는 비즈니스 요구사항인지 먼저 확인하는 게 좋습니다.
보상 트랜잭션이란 무엇인가
보상 트랜잭션은 DB 롤백이 아닙니다. 이미 커밋된 상태를 새로운 역방향 작업으로 되돌리는 것입니다.
| 원래 작업 | 보상 트랜잭션 |
|---|---|
| 주문 생성 (status: PENDING) | 주문 취소 (status: CANCELLED) |
| 재고 예약 (reserved: 10개) | 재고 반환 (reserved: -10개) |
| 결제 처리 (charged: 50,000원) | 결제 환불 (refunded: 50,000원) |
중요한 점은 보상 트랜잭션도 실패할 수 있다는 겁니다. 그래서 모든 보상 작업은 멱등(idempotent)하게 설계하는 것을 권장합니다. 같은 환불 요청이 두 번 들어와도 한 번만 처리되어야 하죠.
실전 적용
예시 1: Choreography — 이벤트로 자율 조율하기
Choreography는 중앙 조율자 없이 각 서비스가 이벤트를 발행하고 구독하며 스스로 움직이는 방식입니다. 저도 처음에 이 방식이 정말 깔끔해 보였습니다. 서비스마다 독립적이고, 메시지 브로커만 있으면 되니까요. 새 서비스를 추가할 때 기존 서비스를 건드릴 필요가 없다는 점이 특히 매력적이었습니다.
// @EventHandler는 메시지 브로커 구독을 나타내는 커스텀 데코레이터입니다.
// NestJS의 @nestjs/event-emitter나 Kafka consumer 래퍼 등으로 구현할 수 있습니다.
// 1. Order Service — 주문 생성 후 Outbox 패턴으로 이벤트 발행
class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly db: DataSource,
) {}
async createOrder(dto: CreateOrderDto): Promise<void> {
// DB 커밋과 이벤트 삽입을 같은 트랜잭션에서 처리 (Outbox 패턴)
await this.db.transaction(async (em) => {
const order = await em.save(Order, {
...dto,
status: OrderStatus.PENDING,
});
await em.save(OutboxEvent, {
aggregateId: order.id,
type: 'ORDER_CREATED',
payload: JSON.stringify({ orderId: order.id, items: dto.items }),
processedAt: null,
});
});
}
// 실패 이벤트 수신 시 보상: 주문 취소
@EventHandler('INVENTORY_RESERVATION_FAILED')
async handleReservationFailed(event: ReservationFailedEvent): Promise<void> {
await this.db.transaction(async (em) => {
await em.update(Order, event.orderId, { status: OrderStatus.CANCELLED });
await em.save(OutboxEvent, {
aggregateId: event.orderId,
type: 'ORDER_CANCELLED',
payload: JSON.stringify({ orderId: event.orderId, reason: event.reason }),
processedAt: null,
});
});
}
}
// 2. Inventory Service — OrderCreated 구독 후 재고 예약 시도
class InventoryService {
constructor(private readonly db: DataSource) {}
@EventHandler('ORDER_CREATED')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
// 멱등성 보장: 이미 처리한 이벤트면 스킵
const alreadyProcessed = await this.db.getRepository(InboxEvent)
.existsBy({ eventId: event.eventId });
if (alreadyProcessed) return;
const available = await this.checkStock(event.items);
if (!available) {
// inbox 기록과 실패 이벤트를 같은 트랜잭션으로 묶습니다
await this.db.transaction(async (em) => {
await em.save(InboxEvent, { eventId: event.eventId });
await em.save(OutboxEvent, {
aggregateId: event.orderId,
type: 'INVENTORY_RESERVATION_FAILED',
payload: JSON.stringify({ orderId: event.orderId, reason: 'OUT_OF_STOCK' }),
processedAt: null,
});
});
return;
}
// reserveStock, inbox 기록, 성공 이벤트를 하나의 트랜잭션으로 묶어야 합니다.
// reserveStock 후 inbox 기록 전에 프로세스가 죽으면 재시도 시 재고가 이중 차감될 수 있습니다.
await this.db.transaction(async (em) => {
await this.reserveStock(em, event.items);
await em.save(InboxEvent, { eventId: event.eventId });
await em.save(OutboxEvent, {
aggregateId: event.orderId,
type: 'INVENTORY_RESERVED',
payload: JSON.stringify({ orderId: event.orderId }),
processedAt: null,
});
});
}
}Choreography의 이벤트 흐름을 도식으로 표현하면 이렇습니다.
[Order Service]
│ ORDER_CREATED 발행
▼
[Inventory Service] ──── 재고 부족 ───► INVENTORY_RESERVATION_FAILED 발행
│ 성공 │
│ INVENTORY_RESERVED 발행 [Order Service] ◄──────────────┘
▼ ORDER_CANCELLED 발행
[Payment Service]
│ PAYMENT_PROCESSED 발행
▼
[Order Service] → 주문 확정그런데 서비스가 5개, 6개 넘어가면서 "이 이벤트가 어디서 발행되고 어디서 소비되는지" 추적하기가 점점 어려워지더군요. 디버깅할 때 Kafka 콘솔을 열어 이벤트를 하나씩 따라가야 하는데, 중간 어딘가에서 이벤트가 유실됐거나 처리 순서가 꼬인 경우에는 그 막막함이 상당합니다. 전체 플로우를 파악하려면 모든 서비스의 코드를 열어봐야 하는 상황이 생기죠.
예시 2: Orchestration — 중앙 오케스트레이터로 제어하기
Orchestration은 중앙의 Saga 오케스트레이터가 각 단계를 명시적으로 호출하고 상태를 관리합니다. 보상 순서가 중요하거나 단계가 많을 때 이 방식이 빛을 발합니다. 전체 흐름이 한 곳에 모여 있어서, "현재 어느 단계에서 실패했고 어떤 보상을 실행해야 하는지"가 코드만 봐도 명확하게 파악되거든요.
// Saga 상태 타입 정의
type SagaStep = 'RESERVE_INVENTORY' | 'PROCESS_PAYMENT' | 'CONFIRM_ORDER';
type SagaStatus = 'RUNNING' | 'COMPLETED' | 'COMPENSATING' | 'FAILED';
interface SagaState {
id: string;
orderId: string;
currentStep: SagaStep;
completedSteps: SagaStep[]; // DB에 누적 저장되며, 보상 시 역순으로 참조합니다
status: SagaStatus;
}
class OrderSagaOrchestrator {
constructor(
private readonly sagaRepo: SagaRepository,
private readonly inventoryClient: InventoryClient,
private readonly paymentClient: PaymentClient,
private readonly orderClient: OrderClient,
) {}
async execute(orderId: string): Promise<void> {
const saga = await this.sagaRepo.create({
orderId,
currentStep: 'RESERVE_INVENTORY',
completedSteps: [],
status: 'RUNNING',
});
try {
// Step 1: 재고 예약
await this.inventoryClient.reserve(orderId);
// recordStep은 completedSteps 배열에 해당 step을 추가해 DB에 영속화합니다.
// 오케스트레이터가 재시작된 후에도 보상 시 어느 단계까지 완료됐는지 알 수 있습니다.
await this.sagaRepo.recordStep(saga.id, 'RESERVE_INVENTORY');
// Step 2: 결제 처리
await this.paymentClient.process(orderId);
await this.sagaRepo.recordStep(saga.id, 'PROCESS_PAYMENT');
// Step 3: 주문 확정
await this.orderClient.confirm(orderId);
await this.sagaRepo.markCompleted(saga.id);
} catch (error) {
await this.sagaRepo.updateStatus(saga.id, 'COMPENSATING');
// findById로 최신 completedSteps를 다시 조회해 보상에 사용합니다
await this.compensate(await this.sagaRepo.findById(saga.id));
}
}
private async compensate(saga: SagaState): Promise<void> {
// 완료된 단계에 대해서만 역순으로 보상 실행
const compensations: Partial<Record<SagaStep, () => Promise<void>>> = {
PROCESS_PAYMENT: () => this.paymentClient.refund(saga.orderId),
RESERVE_INVENTORY: () => this.inventoryClient.release(saga.orderId),
// CONFIRM_ORDER는 보상 없음 — 이 단계까지 왔으면 전체가 성공한 것
};
const stepsToCompensate = [...saga.completedSteps].reverse();
for (const step of stepsToCompensate) {
const compensation = compensations[step];
if (compensation) {
try {
await compensation();
} catch (err) {
// 보상 실패 시: Dead Letter Queue로 이동하거나 수동 개입 알림
await this.notifyManualIntervention(saga.id, step, err);
}
}
}
await this.sagaRepo.markFailed(saga.id);
}
}Orchestration 방식의 흐름은 훨씬 명확합니다.
[OrderSagaOrchestrator]
│
├──► inventoryClient.reserve(orderId) ✓ → completedSteps: ['RESERVE_INVENTORY']
│
├──► paymentClient.process(orderId) ✗ 실패!
│
│ [보상 시작 — 역순]
├──► inventoryClient.release(orderId) (RESERVE_INVENTORY 보상)
│
└──► sagaRepo.markFailed(sagaId)Advanced: 자체 구현 대신 플랫폼 활용하기
예시 3: Temporal로 Durable Execution 구현하기
자체 Saga 엔진을 직접 구현하다 보면 "오케스트레이터가 재시작되면 어떻게 되지?", "네트워크 장애 후 어느 단계부터 재개해야 하지?" 같은 문제를 계속 마주칩니다. 2025년 기준으로 이 복잡성을 플랫폼에 위임하는 팀이 늘고 있는데, Temporal이 그 대표 선택지입니다.
import { proxyActivities, ApplicationFailure } from '@temporalio/workflow';
import type * as activities from './activities';
const { reserveInventory, processPayment, cancelReservation, refundPayment } =
proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
maximumAttempts: 3,
nonRetryableErrorTypes: ['InsufficientStockError', 'InvalidPaymentError'],
},
});
// Temporal이 이 워크플로우의 상태를 이벤트 소싱으로 내구적으로 관리합니다
export async function orderSagaWorkflow(orderId: string): Promise<void> {
let inventoryReserved = false;
let paymentProcessed = false;
try {
await reserveInventory(orderId);
inventoryReserved = true;
await processPayment(orderId);
paymentProcessed = true;
} catch (err) {
// 보상 — 역순으로, 각 보상이 실패해도 계속 진행
if (paymentProcessed) {
await refundPayment(orderId).catch(() => {
// 환불 실패 시 알림 발송 등 별도 처리
});
}
if (inventoryReserved) {
await cancelReservation(orderId);
}
throw ApplicationFailure.create({ message: `Order saga failed: ${orderId}` });
}
}Durable Execution : 워크플로우 코드의 실행 상태(어느 단계까지 완료됐는지)를 이벤트 소싱 방식으로 영속화해두고, 서버 재시작이나 네트워크 장애 후에도 중단된 지점부터 재개할 수 있게 하는 실행 모델. Temporal이 대표적인 구현체이며, AWS Step Functions도 유사한 보장을 제공합니다.
자체 Saga 엔진 구현에 자신 있다면 직접 구축하는 것도 충분히 의미 있는 선택입니다. 하지만 처음 도입이라면 Temporal 클라우드의 무료 플랜이나 AWS Step Functions로 프로토타입부터 만들어보시면 구현 비용을 상당히 줄이실 수 있습니다.
장단점 분석
Choreography vs Orchestration 비교
| 기준 | Choreography | Orchestration |
|---|---|---|
| 서비스 결합도 | 낮음 — 서비스 간 직접 의존 없음 | 높음 — 오케스트레이터가 각 서비스를 알아야 함 |
| 전체 흐름 가시성 | 낮음 — 코드 여러 곳에 분산 | 높음 — 한 파일에서 전체 상태 파악 가능 |
| 디버깅 난이도 | 서비스 수에 비례해 어려워짐 | 상대적으로 쉬움 |
| 복잡한 보상 순서 | 다루기 어려움 | 명시적으로 제어 가능 |
| 처리량·확장성 | 높음 — 비동기 처리에 유리 | 오케스트레이터 병목 가능성 |
| 독립 배포 | 각 팀이 독립적으로 배포 가능 | 오케스트레이터 변경 시 배포 필요 |
상황별 선택 기준
솔직히 "무조건 이걸 써야 한다"는 답은 없습니다. 아래 기준을 참고해서 상황에 맞게 고르시는 게 현실적입니다.
| 상황 | 권장 방식 |
|---|---|
| 서비스 수 3개 이하, 단순한 성공/실패 플로우 | Choreography |
| 보상 순서가 중요하거나 단계가 5개 이상 | Orchestration |
| 서비스 팀이 독립적으로 배포해야 하는 환경 | Choreography |
| 비즈니스 크리티컬한 워크플로우, 감사 로그 필수 | Orchestration |
| 새 서비스를 기존 흐름에 자주 추가해야 하는 경우 | Choreography |
| 실패 시 수동 개입이 필요한 복잡한 롤백 로직 | Orchestration |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Intermediate State Visibility | 단계 사이 다른 Saga가 중간 상태 데이터를 읽을 수 있음 | 비즈니스 요구사항 검토 후 허용 여부 결정. 필요시 Semantic Lock 패턴 고려 (처리 중 레코드에 status: PROCESSING 같은 표시를 남겨 다른 Saga가 조회 시 필터링하는 방식) |
| 보상 실패 | 보상 트랜잭션 자체가 실패할 수 있음 | Dead Letter Queue + 수동 개입 알림 + 보상의 멱등성 보장 |
| 오케스트레이터 단일 장애점 | Orchestration에서 오케스트레이터 장애 시 Saga 중단 | 오케스트레이터 상태를 DB에 영속화, 재시작 후 복구 가능하게 설계 |
| 이벤트 폭발 | Choreography에서 서비스 수가 늘수록 이벤트 관계 파악 어려움 | 이벤트 카탈로그(Event Catalog) 문서화, Correlation ID 추적 |
| 보상 불가능한 작업 | 이메일, SMS 발송은 되돌릴 수 없음 | Saga의 마지막 단계에 배치하거나 "발송 예약" 방식으로 지연 처리 |
Outbox 패턴 : DB 커밋과 메시지 발행을 원자적으로 처리하기 위한 패턴. 비즈니스 데이터와 같은 트랜잭션에서 Outbox 테이블에 이벤트를 저장하고, 별도 프로세스가 이를 읽어 메시지 브로커로 발행합니다. Saga의 각 단계에서 DB 커밋은 됐지만 이벤트 발행이 누락되는 상황을 방지할 수 있어 사실상 필수로 여겨집니다.
실무에서 가장 흔한 실수
-
보상 트랜잭션에 멱등성을 적용하지 않는 것 — 네트워크 타임아웃으로 보상 요청이 두 번 전달될 수 있습니다.
idempotency_key를 요청에 포함하고, Inbox 테이블로 중복 처리를 방지하는 방식이 효과적입니다. -
이메일·SMS 발송을 Saga 중간에 배치하는 것 — "주문 확인 메일"을 재고 예약 직후에 발송했다가 결제가 실패해 주문이 취소되면 이미 발송된 메일을 회수할 방법이 없습니다. 외부 통보는 항상 Saga의 마지막 단계에 두는 것을 권장합니다.
-
Saga 상태를 영속화하지 않는 것 — 오케스트레이터가 재시작되거나 네트워크가 끊겼을 때 어느 단계까지 완료됐는지 알 수 없으면 보상을 제대로 실행할 수 없습니다.
completedSteps같은 상태를 DB에 기록해두고 재시작 시 이어서 처리할 수 있게 설계하는 것이 중요합니다.
마치며
Saga 패턴을 도입하면서 가장 크게 깨달은 것은, 이 패턴이 "완벽한 원자성" 문제를 해결하는 게 아니라 "완벽한 원자성을 포기하는 대신 얼마나 우아하게 실패를 다룰 것인가"를 설계하는 일이라는 점입니다. Choreography냐 Orchestration이냐보다 더 중요한 건, 그 선택이 가져오는 트레이드오프 — 결합도, 가시성, 보상 복잡도 — 를 팀이 명확히 이해하고 받아들인 상태에서 쓰는 것입니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 서비스의 분산 트랜잭션 흐름을 그려볼 수 있습니다. 주문 → 재고 → 결제처럼 각 단계를 적고, 각 단계가 실패했을 때 이전 단계를 어떻게 되돌릴지 "보상 컬럼"을 옆에 채워보면 설계의 빈 곳이 보이기 시작합니다.
-
단순한 2~3단계 플로우에 Choreography로 먼저 구현해 볼 수 있습니다. Kafka나 RabbitMQ로
ORDER_CREATED→INVENTORY_RESERVED→PAYMENT_PROCESSED흐름을 만들어보고, 각 단계에서 Outbox 패턴을 함께 적용해보시면 좋습니다. -
플로우가 4단계 이상이거나 보상 순서가 중요해지면 Orchestration으로 전환하는 것을 고려해볼 수 있습니다. 직접 오케스트레이터를 구현하기 전에 Temporal 클라우드의 무료 플랜이나 AWS Step Functions로 먼저 프로토타입을 만들어보시면 구현 비용을 크게 줄이실 수 있습니다.
참고 자료
- Saga Pattern | microservices.io
- Saga design pattern | Microsoft Azure Architecture Center
- Saga Patterns | AWS Prescriptive Guidance
- Mastering Saga Patterns for Distributed Transactions | Temporal Blog
- To Choreograph or Orchestrate Your Saga | Temporal Blog
- Saga Orchestration with Outbox Pattern | InfoQ
- Compensation Transaction Patterns | Orkes Blog
- Saga Pattern in Distributed Systems | Orkes Blog
- Transactional Outbox Pattern | microservices.io
- Idempotent Consumer Pattern | microservices.io
- The Idempotent-Saga Pattern | Medium
- Saga Pattern Demystified | ByteByteGo
- Getting Started with Eventuate Tram Sagas