Saga 패턴 심화: Choreography vs Orchestration, 그리고 Temporal로 보상 로직 구현하기
이커머스 주문 처리를 예로 들면 상황이 명확해집니다. 주문 생성 → 결제 → 재고 차감 → 배송 예약, 네 단계가 각각 다른 서비스에서 실행됩니다. 결제까지 성공했는데 재고 차감이 실패하면, 이미 빠져나간 결제를 어떻게 되돌릴 것인가.
2PC(Two-Phase Commit)는 분산 환경에서 현실적인 선택지가 아닙니다. 락을 장시간 보유하고, 참여 서비스 중 하나라도 응답하지 않으면 시스템 전체가 블로킹됩니다. Saga 패턴은 이 문제를 다르게 접근합니다. 글로벌 트랜잭션을 서비스별 로컬 트랜잭션 여러 개로 쪼개고, 중간에 실패하면 이미 성공한 단계들을 역순으로 취소하는 보상 트랜잭션(Compensating Transaction)을 실행합니다. 글로벌 락 없이 최종 일관성(Eventual Consistency)을 달성하는 것이 목표입니다.
이 글에서는 Saga를 구현하는 두 방식인 Choreography와 Orchestration을 코드 수준에서 비교하고, Temporal TypeScript SDK로 보상 로직을 구현하는 방법을 단계별로 살펴봅니다. 개념 정리보다는 각 방식이 실제로 어떤 지점에서 복잡해지는지를 중심으로 전개합니다.
핵심 개념: 보상 트랜잭션이란
Saga는 분산된 여러 로컬 트랜잭션의 시퀀스입니다. 각 단계는 자신의 DB에 커밋하고, 실패 시 이전 단계들을 취소하는 보상 트랜잭션이 역순으로 실행됩니다.
보상 트랜잭션은 진정한 의미의 롤백이 아닙니다. 이미 커밋된 변경을 되돌리는 새로운 트랜잭션입니다. "재고 차감"의 보상은 "재고 복원"이고, "결제 처리"의 보상은 "환불"입니다. 이 차이가 중요한 이유는 보상 자체도 실패할 수 있기 때문입니다. 그 상황까지 설계에 포함해야 합니다.
Choreography: 이벤트로 연결되는 서비스들
동작 방식
Choreography에서는 중앙 조율자가 없습니다. 각 서비스는 자신의 로컬 트랜잭션을 완료하면 이벤트를 발행하고, 다음 서비스가 그 이벤트를 구독해서 작업을 수행합니다. 보상도 이벤트로 트리거됩니다.
재고 차감이 실패하면, 그 이전 단계인 결제 환불과 주문 취소도 이벤트 체인으로 트리거해야 합니다. 보상 흐름도 정상 흐름과 동일한 이벤트 기반 구조로 설계해야 한다는 뜻입니다.
코드 예시: Kafka 기반 Choreography
// order-service/order.consumer.ts
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ brokers: ['localhost:9092'] });
const producer = kafka.producer();
const consumer = kafka.consumer({ groupId: 'order-service' });
export async function createOrderAndPublish(ctx: OrderContext): Promise<void> {
await orderRepository.create({ id: ctx.orderId, status: 'PENDING' });
await producer.send({
topic: 'order-events',
messages: [{ key: ctx.orderId, value: JSON.stringify({ type: 'OrderCreated', ...ctx }) }],
});
}
// 보상 이벤트 구독: 결제 환불이 완료된 뒤 주문 취소
await consumer.subscribe({ topics: ['payment-events'] });
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value!.toString());
if (event.type === 'PaymentRefunded') {
await orderRepository.updateStatus(event.orderId, 'CANCELLED');
}
},
});// payment-service/payment.consumer.ts
import { Kafka } from 'kafkajs';
const consumer = kafka.consumer({ groupId: 'payment-service' });
const producer = kafka.producer();
// 정상 흐름 이벤트와 보상 이벤트를 모두 구독
await consumer.subscribe({ topics: ['order-events', 'inventory-events'] });
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value!.toString());
if (event.type === 'OrderCreated') {
const result = await paymentGateway.charge(event);
await producer.send({
topic: 'payment-events',
messages: [{
key: event.orderId,
value: JSON.stringify(
result.success
? { type: 'PaymentProcessed', orderId: event.orderId, transactionId: result.transactionId }
: { type: 'PaymentFailed', orderId: event.orderId, reason: result.reason }
),
}],
});
}
// 재고 실패 시 환불 (보상 트랜잭션)
if (event.type === 'InventoryFailed') {
await paymentGateway.refund({ orderId: event.orderId, transactionId: event.transactionId });
await producer.send({
topic: 'payment-events',
messages: [{
key: event.orderId,
value: JSON.stringify({ type: 'PaymentRefunded', orderId: event.orderId }),
}],
});
}
},
});각 서비스가 정상 흐름 이벤트와 보상 이벤트를 모두 구독합니다. 서비스 수가 늘어날수록 각 컨슈머가 담당해야 할 이벤트 케이스도 함께 증가합니다. 전체 흐름은 여러 파일에 분산되어 있어 "어떤 이벤트가 어떤 순서로 흘러가는지"를 추적하려면 모든 서비스의 컨슈머 코드를 함께 봐야 합니다. 분산 추적(Jaeger, OpenTelemetry) 없이 운영하면 장애 디버깅 비용이 급증하는 지점이 여기입니다.
Transactional Outbox: DB와 Kafka 사이의 원자성 확보
Choreography를 구성할 때 빠지기 쉬운 함정이 있습니다. "DB는 업데이트됐는데 Kafka 메시지 발행은 실패"하는 상황입니다. DB 커밋과 Kafka 발행은 원자적 단위가 아니기 때문입니다. Outbox 패턴은 이를 해결합니다.
// TypeORM + PostgreSQL 예시
async function createOrderWithOutbox(
ctx: OrderContext,
queryRunner: QueryRunner
): Promise<void> {
await queryRunner.startTransaction();
try {
await queryRunner.manager.save(Order, {
id: ctx.orderId,
status: 'PENDING',
amount: ctx.amount,
});
// Kafka에 직접 발행하는 대신 outbox 테이블에 기록 (같은 트랜잭션)
await queryRunner.manager.save(OutboxEvent, {
id: uuidv4(),
aggregateType: 'Order',
aggregateId: ctx.orderId,
eventType: 'OrderCreated',
payload: JSON.stringify(ctx),
createdAt: new Date(),
});
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
}
// Debezium 같은 CDC 도구가 outbox 테이블 변경을 감지 → Kafka 발행
}이벤트를 Kafka에 직접 발행하는 대신 같은 DB 트랜잭션 안에 outbox 테이블에 기록합니다. CDC 도구(Debezium 등)가 outbox 테이블 변경을 감지해서 Kafka로 발행합니다. Outbox 없이 Choreography 기반 Saga를 운영하면, 드문 타이밍에서 이벤트 유실이 발생하고 그 원인을 재현하기 어렵습니다.
Orchestration: 오케스트레이터가 흐름을 통제하기
Orchestration에서는 오케스트레이터가 각 서비스에 순서대로 명령을 내립니다. 실패하면 오케스트레이터가 보상 트랜잭션을 역순으로 직접 실행합니다. 각 실패 지점에서 그 이전 단계들의 보상이 모두 연쇄적으로 실행됩니다.
배송 예약 실패 시 재고 복원 → 결제 환불 → 주문 취소 세 단계가 순서대로 실행됩니다. 이 전체 흐름이 오케스트레이터 코드 한 곳에 표현됩니다.
Temporal로 Orchestration Saga 구현하기
Temporal의 역할
Temporal은 Durable Execution(내구성 있는 실행) 플랫폼입니다. 워크플로우의 모든 실행 이력(Activity 시작, 완료, 실패 등)을 서버에 영속적으로 기록합니다.
서비스가 재시작되거나 네트워크 장애가 발생해도, 워크플로우는 정확히 실패한 지점부터 재개됩니다. 이 영속성은 Temporal 서버 자체가 클러스터로 구성되기 때문에 가능합니다. Frontend, History, Matching 서비스가 각각 복수 인스턴스로 실행되고, 워크플로우 히스토리는 외부 DB(Cassandra 또는 PostgreSQL)에 저장됩니다. Temporal 서버 인스턴스 하나가 죽어도 워크플로우 상태는 유실되지 않습니다. 오케스트레이터의 단일 장애점 문제가 이 구조로 완화됩니다.
그리고 try...catch 같은 표준 언어 구조체가 보상 로직의 보장 수단이 됩니다. 별도의 상태 머신이나 이벤트 핸들러를 설계하지 않아도 됩니다.
패키지 설치
pnpm add @temporalio/workflow @temporalio/activity @temporalio/worker @temporalio/clientActivity 정의
// activities.ts
import { log, ApplicationFailure } from '@temporalio/activity';
export interface OrderContext {
orderId: string;
userId: string;
amount: number;
items: Array<{ productId: string; quantity: number }>;
}
export async function createOrder(ctx: OrderContext): Promise<void> {
await orderRepository.create({ id: ctx.orderId, userId: ctx.userId, status: 'PENDING', amount: ctx.amount });
log.info('주문 생성 완료', { orderId: ctx.orderId });
}
export async function cancelOrder(orderId: string): Promise<void> {
// 이미 취소된 주문이면 건너뜀 (멱등성 보장)
const order = await orderRepository.findById(orderId);
if (order?.status === 'CANCELLED') return;
await orderRepository.updateStatus(orderId, 'CANCELLED');
log.info('주문 취소 완료', { orderId });
}
export async function processPayment(ctx: OrderContext): Promise<string> {
const result = await paymentGateway.charge({
orderId: ctx.orderId,
userId: ctx.userId,
amount: ctx.amount,
});
if (!result.success) {
// 결제 거절은 재시도해도 결과가 바뀌지 않으므로 non-retryable
throw ApplicationFailure.nonRetryable(`결제 실패: ${result.reason}`, 'PAYMENT_DECLINED');
}
return result.transactionId;
}
export async function refundPayment(orderId: string, transactionId: string): Promise<void> {
const existing = await paymentRepository.findRefund(orderId);
if (existing) {
log.info('이미 환불 처리됨, 건너뜀', { orderId });
return;
}
await paymentGateway.refund({ orderId, transactionId, idempotencyKey: `refund-${orderId}` });
await paymentRepository.markRefunded(orderId, transactionId);
log.info('환불 완료', { orderId });
}
export async function updateInventory(ctx: OrderContext): Promise<void> {
// 주의: 이 Activity가 재시도될 경우 이미 차감된 항목이 다시 차감될 수 있습니다.
// 프로덕션에서는 각 decrement 호출에 idempotency key를 사용하거나,
// 차감 전 현재 재고를 조회하여 이미 처리됐는지 확인하는 로직이 필요합니다.
const decremented: typeof ctx.items = [];
for (const item of ctx.items) {
const success = await inventoryService.decrement(item.productId, item.quantity);
if (!success) {
// 재고 부족 — 이미 차감한 항목은 이 Activity 내에서 복원 후 non-retryable throw
for (const done of decremented) {
await inventoryService.increment(done.productId, done.quantity);
}
throw ApplicationFailure.nonRetryable(`재고 부족: ${item.productId}`, 'INSUFFICIENT_INVENTORY');
}
decremented.push(item);
}
}
export async function restoreInventory(ctx: OrderContext): Promise<void> {
for (const item of ctx.items) {
await inventoryService.increment(item.productId, item.quantity);
}
log.info('재고 복원 완료', { orderId: ctx.orderId });
}
export async function scheduleShipping(orderId: string): Promise<void> {
await shippingService.schedule({ orderId });
}
export async function cancelShipping(orderId: string): Promise<void> {
await shippingService.cancel({ orderId });
log.info('배송 취소 완료', { orderId });
}보상 스택 패턴으로 Workflow 구현
// workflows.ts
import { proxyActivities, log } from '@temporalio/workflow';
import type * as activities from './activities';
import type { OrderContext } from './activities';
const {
createOrder, cancelOrder,
processPayment, refundPayment,
updateInventory, restoreInventory,
scheduleShipping, cancelShipping,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '10 minutes',
retry: {
maximumAttempts: 3,
initialInterval: '1 second',
backoffCoefficient: 2,
},
});
export async function orderSagaWorkflow(ctx: OrderContext): Promise<void> {
// 보상 스택: unshift()로 맨 앞에 추가하면 for...of 순회 시 자동으로 역순 실행
const compensations: Array<() => Promise<void>> = [];
try {
await createOrder(ctx);
compensations.unshift(() => cancelOrder(ctx.orderId));
const transactionId = await processPayment(ctx);
// transactionId를 클로저로 캡처 — 보상 시점에 별도 상태 관리 불필요
compensations.unshift(() => refundPayment(ctx.orderId, transactionId));
await updateInventory(ctx);
compensations.unshift(() => restoreInventory(ctx));
await scheduleShipping(ctx.orderId);
compensations.unshift(() => cancelShipping(ctx.orderId));
log.info('주문 처리 완료', { orderId: ctx.orderId });
} catch (err) {
log.error('주문 처리 실패, 보상 트랜잭션 실행', { orderId: ctx.orderId, error: String(err) });
for (const compensate of compensations) {
try {
await compensate();
} catch (compensationErr) {
// 보상 실패 — 시스템이 불일치 상태로 남을 수 있음
// 알림 발송 또는 DLQ 적재로 수동 복구 경로 확보 필요
log.error('보상 트랜잭션 실패', { error: String(compensationErr) });
// 이 보상이 실패해도 나머지 보상은 계속 시도
}
}
throw err;
}
}Worker와 Client
// worker.ts
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
async function run() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'order-saga',
});
await worker.run();
}
run().catch(console.error);// client.ts
import { Client } from '@temporalio/client';
import { orderSagaWorkflow } from './workflows';
import type { OrderContext } from './activities';
const client = new Client();
const orderCtx: OrderContext = {
orderId: 'ORD-20260712-001',
userId: 'user-123',
amount: 59000,
items: [{ productId: 'PROD-456', quantity: 2 }],
};
const handle = await client.workflow.start(orderSagaWorkflow, {
taskQueue: 'order-saga',
// orderId를 workflowId로 사용: 같은 주문의 중복 실행을 방지
// 네트워크 재시도로 start()가 두 번 호출되더라도 Temporal이 멱등성을 보장
workflowId: `order-${orderCtx.orderId}`,
args: [orderCtx],
});
console.log(`Workflow 시작: ${handle.workflowId}`);
await handle.result();workflowId에 orderId를 사용하는 게 핵심입니다. 네트워크 재시도로 start()가 두 번 호출되더라도, Temporal은 동일한 workflowId를 가진 Workflow의 중복 실행을 방지합니다. uuidv4()처럼 매번 새 값을 생성하면 이 멱등성이 깨집니다.
보상 로직의 필수 조건: 멱등성
Temporal은 실패한 Activity를 재시도합니다. 이 원칙은 보상 Activity뿐 아니라 모든 Activity에 적용됩니다. maximumAttempts: 3 설정 하에 네트워크 오류로 processPayment가 재시도된다면, 결제 API를 두 번 호출하는 상황이 생길 수 있습니다. 같은 입력으로 여러 번 호출되어도 부작용이 한 번만 발생하도록 설계해야 합니다.
// 멱등성 보장 예시 — 환불 Activity
export async function refundPayment(orderId: string, transactionId: string): Promise<void> {
const existing = await paymentRepository.findRefund(orderId);
if (existing) {
log.info('이미 환불 처리됨, 건너뜀', { orderId });
return; // 두 번 호출돼도 두 번 환불이 일어나지 않음
}
await paymentGateway.refund({
orderId,
transactionId,
idempotencyKey: `refund-${orderId}`, // 외부 결제 API에도 멱등성 키 전달
});
await paymentRepository.markRefunded(orderId, transactionId);
}보상 실패에 대한 처리도 설계에 포함해야 합니다. 보상 자체가 실패하면 데이터 불일치가 남습니다. 최소한 알림을 발송하거나 Dead Letter Queue에 적재해서 수동 처리 경로를 확보해야 합니다.
추가로 주의할 점: Temporal의 workflow.terminate()는 finally 블록을 실행하지 않습니다. 보상 로직이 finally에 있다면 강제 종료 시 롤백이 실행되지 않습니다. 강제 종료가 필요하다면 Signal을 활용해서 Workflow 내부에서 정상적으로 종료되도록 유도해야 합니다.
두 방식 비교
| 기준 | Choreography | Orchestration with Temporal |
|---|---|---|
| 흐름 파악 | 각 서비스 컨슈머 코드를 모두 따라가야 함 | 오케스트레이터 코드 한 곳에서 전체 파악 |
| 서비스 결합도 | 이벤트를 통한 간접 의존 | 오케스트레이터 ↔ 서비스 직접 의존 |
| 디버깅 | 분산 추적 필수, 이벤트 상관관계 파악 어려움 | Temporal UI에서 각 단계 상태 즉시 확인 |
| 보상 로직 위치 | 각 서비스에 분산 | 오케스트레이터에 집중 |
| 단일 장애점 | 없음, 메시지 브로커 이중화 필요 | Temporal 클러스터가 고가용성 담당 |
| 단계 증가 시 복잡도 | 이벤트·컨슈머·보상 이벤트 수가 폭발적으로 증가 | 오케스트레이터 코드가 선형적으로 증가 |
| 보상 순서 보장 | 이벤트 체인 설계에 의존 | 보상 스택이 역순 실행 자동 보장 |
| 필수 인프라 | Kafka/RabbitMQ + CDC/Outbox + 분산 추적 | Temporal Server + DB |
어떤 방식을 선택할까
Choreography는 이벤트 기반 아키텍처에 이미 투자한 팀에서, 서비스 간 결합을 최소화하는 것이 최우선 목표일 때 적합합니다. 단, Saga 단계가 3개 이상으로 길어지면 이벤트 체인과 보상 설계의 복잡도가 빠르게 올라갑니다. 분산 추적과 Outbox 패턴 없이 운영하면 장애 디버깅 비용이 급증합니다.
Orchestration은 비즈니스 흐름이 복잡하고 가시성이 중요할 때 적합합니다. 워크플로우 코드가 비즈니스 흐름을 직접 표현하기 때문에, 로직이 변경될 때 수정 범위가 명확합니다. Temporal의 경우 Workflow 실행 이력이 영속 저장되어 장애 재현과 상태 디버깅이 용이합니다.
마치며
Saga 패턴의 실질적 어려움은 보상 트랜잭션 구현 자체보다, 보상이 실패하는 상황과 서비스 재시작 후 어느 지점에서 재개할지를 다루는 데 있습니다.
Choreography는 이 복잡성을 여러 서비스로 분산시키고, Orchestration은 한 곳에 집중시킵니다. 어느 방식을 선택하든 지켜야 할 원칙은 동일합니다. 모든 Activity는 멱등하게 구현할 것, 보상 실패에 대한 처리 경로를 반드시 설계할 것, 전체 흐름을 관찰할 수 있는 수단을 갖출 것.
참고 자료
- Temporal 공식: Saga Pattern Made Easy
- Temporal 공식: Mastering Saga Patterns in Microservices
- Temporal 공식: Compensating Transactions
- Temporal 공식 문서: Use Cases & Design Patterns
- microservices.io: Pattern: Saga (Chris Richardson)
- ByteByteGo: Saga Pattern Demystified – Orchestration vs Choreography
- AWS Prescriptive Guidance: Saga Choreography Pattern
- AppScale Blog: Saga + Outbox Durable Distributed Transactions 2026
- DEV.to: Transactions in Microservices Part 3 – SAGA Pattern with Temporal.io
- GitHub: temporal-sa/temporal-order-saga