Saga Pattern
분류: Layer 8 - 데이터베이스 심화
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”Saga Pattern은 여러 마이크로서비스에 걸친 분산 트랜잭션을 “단계별 로컬 트랜잭션 + 실패 시 보상 트랜잭션”으로 나눠 처리하여 데이터 일관성을 유지하는 설계 패턴이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”“주문 서비스 → 결제 서비스 → 재고 서비스”처럼 여러 서비스에 걸친 작업을 처리할 때, 결제는 성공했는데 재고 차감이 실패하면 돈만 빠져나간 채로 주문이 완료되지 않는다. 단일 DB 트랜잭션처럼 ROLLBACK을 칠 수 없기 때문에, Saga가 없으면 데이터 정합성 버그가 운영 중에 조용히 쌓인다.
프론트→플랫폼 브릿지: Redux dispatch 체인과 Saga 단계 비교
프론트엔드에서 Redux-Saga나 Redux-Thunk로 비동기 액션 체인을 관리하는 방식이 있다.
// Redux 비동기 체인 (프론트)dispatch(createOrder()) → dispatch(processPayment()) → dispatch(decreaseInventory()) → 실패 시 dispatch(rollbackPayment())백엔드의 Saga Pattern은 이 구조를 서비스 수준으로 확장한 것이다.
OrderService.createOrder() ← 로컬 트랜잭션 T1 → PaymentService.charge() ← 로컬 트랜잭션 T2 → InventoryService.decrease() ← 로컬 트랜잭션 T3 → 실패 시 InventoryService.restore() ← 보상 트랜잭션 C3 PaymentService.refund() ← 보상 트랜잭션 C2Redux에서 액션 실패 시 rollback 액션을 dispatch하듯, Saga는 실패 단계 이후부터 역순으로 보상 트랜잭션을 실행한다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3.1 2PC(2 Phase Commit)의 한계 — Saga가 등장한 이유
섹션 제목: “3.1 2PC(2 Phase Commit)의 한계 — Saga가 등장한 이유”전통적으로 분산 트랜잭션을 처리하는 방법은 **2PC(2 Phase Commit)**이다. 코디네이터가 모든 참여자에게 “준비됐냐?” → “확정해라”를 두 단계로 실행한다.
Phase 1 - Prepare: Coordinator → OrderDB: "주문 삽입 준비해라" → "OK (Lock 보유)" Coordinator → PaymentDB: "결제 차감 준비해라" → "OK (Lock 보유)" Coordinator → InventoryDB: "재고 차감 준비해라" → "OK (Lock 보유)"
Phase 2 - Commit: Coordinator → 전체: "전부 커밋해라" → 성공 시 Lock 해제2PC의 문제점:
| 문제 | 설명 |
|---|---|
| 블로킹 | Prepare 후 코디네이터가 죽으면 모든 DB가 Lock을 보유한 채 대기 |
| 가용성 저하 | 분산 Lock으로 인한 처리량 감소 |
| MSA 부적합 | 각 서비스가 자신의 DB를 독립적으로 가져야 하는 MSA 원칙 위반 |
| 이기종 지원 불가 | SQS, Redis, 외부 API 등 non-DB 리소스는 2PC에 참여 불가 |
→ Saga는 Lock을 사용하지 않고 각 서비스가 독립적으로 로컬 트랜잭션을 완료한 뒤 이벤트/커맨드로 다음 단계를 트리거한다.
3.2 Choreography Saga (이벤트 기반, 서비스들이 서로 구독)
섹션 제목: “3.2 Choreography Saga (이벤트 기반, 서비스들이 서로 구독)”중앙 오케스트레이터 없이, 각 서비스가 이벤트를 발행하고 다음 서비스가 이를 구독하여 처리한다.
동작 원리 다이어그램:
[주문 서비스] 1. 주문 생성 (DB INSERT) 2. OrderCreated 이벤트 → SQS
[결제 서비스] ← OrderCreated 구독 3. 결제 처리 (DB UPDATE) 4. PaymentCompleted 이벤트 → SQS (실패 시) PaymentFailed 이벤트 → SQS
[재고 서비스] ← PaymentCompleted 구독 5. 재고 차감 (DB UPDATE) 6. InventoryDecreased 이벤트 → SQS (실패 시) InventoryFailed 이벤트 → SQS
[결제 서비스] ← InventoryFailed 구독 7. 결제 환불 (보상 트랜잭션) 8. PaymentRefunded 이벤트 → SQS
[주문 서비스] ← PaymentRefunded 구독 9. 주문 취소 (보상 트랜잭션)NestJS + AWS SQS 코드 예시:
// order.service.ts - 주문 생성 후 SQS로 이벤트 발행import { Injectable } from "@nestjs/common";import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";import { InjectRepository } from "@nestjs/typeorm";import { Repository, DataSource } from "typeorm";import { Order } from "./order.entity";
@Injectable()export class OrderService { private readonly sqsClient = new SQSClient({ region: "ap-northeast-2" });
constructor( @InjectRepository(Order) private readonly orderRepo: Repository<Order>, private readonly dataSource: DataSource, ) {}
async createOrder(userId: string, amount: number): Promise<Order> { // 주문 생성 + SQS 이벤트 발행 (Outbox 패턴은 cqrs-event-sourcing.md 참조) const order = this.orderRepo.create({ userId, amount, status: "PENDING" }); const saved = await this.orderRepo.save(order);
await this.sqsClient.send( new SendMessageCommand({ QueueUrl: process.env.ORDER_EVENTS_QUEUE_URL, MessageBody: JSON.stringify({ eventType: "OrderCreated", orderId: saved.id, userId, amount, }), }), );
return saved; }}// payment.controller.ts - SQS 이벤트 소비 (NestJS SQS Consumer)import { SqsMessageHandler } from "@ssut/nestjs-sqs";import { Injectable } from "@nestjs/common";
@Injectable()export class PaymentConsumer { @SqsMessageHandler("order-events-queue", false) async handleOrderCreated(message: AWS.SQS.Message) { const body = JSON.parse(message.Body); if (body.eventType !== "OrderCreated") return;
try { await this.paymentService.charge(body.orderId, body.amount); // PaymentCompleted 이벤트 발행 await this.publishEvent("PaymentCompleted", { orderId: body.orderId }); } catch (err) { // PaymentFailed 이벤트 발행 → 주문 서비스가 구독하여 취소 await this.publishEvent("PaymentFailed", { orderId: body.orderId, reason: err.message, }); } }}예상 출력 (SQS 이벤트 흐름 로그):
[OrderService] ORDER_001 생성 완료 → OrderCreated 이벤트 발행[PaymentService] OrderCreated 수신 → 결제 처리 중...[PaymentService] 결제 완료 → PaymentCompleted 이벤트 발행[InventoryService] PaymentCompleted 수신 → 재고 차감 중...[InventoryService] 재고 부족! → InventoryFailed 이벤트 발행[PaymentService] InventoryFailed 수신 → 결제 환불 처리 중...[PaymentService] 환불 완료 → PaymentRefunded 이벤트 발행[OrderService] PaymentRefunded 수신 → 주문 ORDER_001 취소 처리3.3 Orchestration Saga (중앙 오케스트레이터가 단계 제어)
섹션 제목: “3.3 Orchestration Saga (중앙 오케스트레이터가 단계 제어)”중앙 오케스트레이터(Saga Orchestrator)가 각 서비스에 커맨드를 보내고, 응답을 받아 다음 단계를 결정한다.
동작 원리 다이어그램:
[Saga Orchestrator] │ ┌─────────────┼─────────────┐ ▼ ▼ ▼ [주문 서비스] [결제 서비스] [재고 서비스]
1. Orchestrator → 주문 서비스: "주문 생성해라 (CreateOrder)"2. 주문 서비스 → Orchestrator: "완료 (OrderCreated)"3. Orchestrator → 결제 서비스: "결제 처리해라 (ProcessPayment)"4. 결제 서비스 → Orchestrator: "완료 (PaymentProcessed)"5. Orchestrator → 재고 서비스: "재고 차감해라 (DecreaseInventory)"6. 재고 서비스 → Orchestrator: "실패 (InventoryFailed)"
--- 보상 단계 ---7. Orchestrator → 결제 서비스: "환불해라 (RefundPayment)"8. 결제 서비스 → Orchestrator: "완료 (PaymentRefunded)"9. Orchestrator → 주문 서비스: "주문 취소해라 (CancelOrder)"10. 주문 서비스 → Orchestrator: "완료 (OrderCancelled)"NestJS 오케스트레이터 코드 예시:
import { Injectable, Logger } from "@nestjs/common";import { InjectRepository } from "@nestjs/typeorm";import { Repository } from "typeorm";import { SagaState } from "./saga-state.entity";
export enum SagaStep { CREATE_ORDER = "CREATE_ORDER", PROCESS_PAYMENT = "PROCESS_PAYMENT", DECREASE_INVENTORY = "DECREASE_INVENTORY", COMPLETED = "COMPLETED", // 보상 단계 REFUND_PAYMENT = "REFUND_PAYMENT", CANCEL_ORDER = "CANCEL_ORDER", FAILED = "FAILED",}
@Injectable()export class OrderSagaOrchestrator { private readonly logger = new Logger(OrderSagaOrchestrator.name);
constructor( @InjectRepository(SagaState) private readonly sagaStateRepo: Repository<SagaState>, private readonly orderService: OrderService, private readonly paymentService: PaymentService, private readonly inventoryService: InventoryService, ) {}
async execute(payload: { userId: string; amount: number; productId: string; }) { // Saga 상태 초기화 (DB에 현재 단계 저장 → 재시작 시 복구 가능) const saga = await this.sagaStateRepo.save({ step: SagaStep.CREATE_ORDER, payload: JSON.stringify(payload), status: "IN_PROGRESS", });
let orderId: string; let paymentId: string;
try { // Step 1: 주문 생성 this.logger.log(`[Saga ${saga.id}] Step: CREATE_ORDER`); orderId = await this.orderService.create(payload); await this.sagaStateRepo.update(saga.id, { step: SagaStep.PROCESS_PAYMENT, });
// Step 2: 결제 처리 this.logger.log(`[Saga ${saga.id}] Step: PROCESS_PAYMENT`); paymentId = await this.paymentService.charge(orderId, payload.amount); await this.sagaStateRepo.update(saga.id, { step: SagaStep.DECREASE_INVENTORY, });
// Step 3: 재고 차감 this.logger.log(`[Saga ${saga.id}] Step: DECREASE_INVENTORY`); await this.inventoryService.decrease(payload.productId, 1); await this.sagaStateRepo.update(saga.id, { step: SagaStep.COMPLETED, status: "COMPLETED", });
this.logger.log(`[Saga ${saga.id}] 완료`); } catch (error) { this.logger.warn( `[Saga ${saga.id}] 실패: ${error.message} → 보상 트랜잭션 시작`, ); await this.compensate(saga, orderId, paymentId); } }
private async compensate( saga: SagaState, orderId?: string, paymentId?: string, ) { // 역순으로 보상 트랜잭션 실행 if (paymentId) { this.logger.log(`[Saga ${saga.id}] 보상: REFUND_PAYMENT`); await this.paymentService .refund(paymentId) .catch((e) => this.logger.error(`환불 실패: ${e.message}`)); } if (orderId) { this.logger.log(`[Saga ${saga.id}] 보상: CANCEL_ORDER`); await this.orderService .cancel(orderId) .catch((e) => this.logger.error(`주문 취소 실패: ${e.message}`)); } await this.sagaStateRepo.update(saga.id, { step: SagaStep.FAILED, status: "FAILED", }); }}예상 출력:
[Saga abc-123] Step: CREATE_ORDER[Saga abc-123] Step: PROCESS_PAYMENT[Saga abc-123] Step: DECREASE_INVENTORY[Saga abc-123] 실패: 재고 부족 (stock: 0) → 보상 트랜잭션 시작[Saga abc-123] 보상: REFUND_PAYMENT[Saga abc-123] 보상: CANCEL_ORDER
-- saga_state 테이블 --id | step | status | updated_atabc-123 | CANCEL_ORDER | FAILED | 2026-04-09T10:32:113.4 보상 트랜잭션(Compensating Transaction) — 롤백 대신 역연산
섹션 제목: “3.4 보상 트랜잭션(Compensating Transaction) — 롤백 대신 역연산”RDBMS의 ROLLBACK은 커밋하지 않은 변경을 되돌린다. 반면 Saga의 각 단계는 이미 커밋된 로컬 트랜잭션이다. 따라서 되돌리려면 **역연산(보상 트랜잭션)**을 새로 실행해야 한다.
원본 트랜잭션 보상 트랜잭션 (역연산)───────────────────────── ──────────────────────────────결제 $100 차감 결제 $100 환불 (credit)재고 -5개 차감 재고 +5개 복구주문 상태 PENDING 주문 상태 CANCELLED보상 트랜잭션의 필수 조건:
- 멱등성(Idempotency): 같은 보상 트랜잭션을 여러 번 실행해도 결과가 동일해야 한다. SQS At-Least-Once 특성상 중복 메시지가 올 수 있기 때문이다.
- 재시도 가능(Retryable): 네트워크 오류로 보상 트랜잭션이 실패해도 재시도할 수 있어야 한다.
// payment.service.ts - 멱등성 보장 환불 처리async refund(paymentId: string): Promise<void> { const payment = await this.paymentRepo.findOneBy({ id: paymentId });
// 이미 환불된 경우 중복 처리 방지 (멱등성) if (payment.status === 'REFUNDED') { this.logger.warn(`[Refund] ${paymentId} 이미 환불됨 - 중복 요청 무시`); return; }
await this.dataSource.transaction(async (manager) => { await manager.update(Payment, paymentId, { status: 'REFUNDED' }); // 실제 PG사 환불 API 호출 await this.pgClient.refund(payment.pgTransactionId); });}3.5 Outbox Pattern과의 결합 (메시지 유실 방지)
섹션 제목: “3.5 Outbox Pattern과의 결합 (메시지 유실 방지)”Saga에서 가장 위험한 실패 시나리오는 “DB는 커밋됐는데 SQS/EventBridge 발행이 실패”하는 경우다. 이 경우 다음 서비스가 이벤트를 수신하지 못해 Saga가 멈춘다.
Saga에서 Outbox를 사용하는 이유: 보상 트랜잭션 이벤트를 DB 트랜잭션 내에서 원자적으로 기록하여 메시지 유실을 방지한다.
📌 Outbox Pattern 상세 구현(outbox-relay.service.ts, cron relay, idempotency key)은 cqrs-event-sourcing.md에서 다룹니다.
3.6 Saga의 한계: Isolation 부재와 대응 전략
섹션 제목: “3.6 Saga의 한계: Isolation 부재와 대응 전략”Saga는 분산 트랜잭션의 최종 일관성을 보장하지만, ACID의 **I(Isolation)**를 포기한다. 단일 DB 트랜잭션은 커밋 전까지 중간 상태를 외부에 숨기지만, Saga는 각 단계가 로컬 트랜잭션으로 커밋되므로 중간 상태가 다른 트랜잭션에 노출된다.
// 문제 시나리오 — Dirty Read 유사 현상Saga A: 주문 생성(T1 커밋) → 결제 처리(T2 진행 중)...Saga B: 이 시점에 orders 테이블을 조회하면 "결제 중인 주문"이 보임
→ Saga B가 이 주문에 접근해 재고를 먼저 차감하면 데이터 불일치 발생대응 전략 1: Semantic Lock — 가장 실용적
처리 중인 리소스에 명시적 상태 플래그를 설정해 다른 Saga가 접근하지 못하도록 막는다.
// 주문 상태 Enum — Saga 진행 중 상태를 명시적으로 구분enum OrderStatus { PENDING_PAYMENT = 'PENDING_PAYMENT', // Saga 진행 중 CONFIRMED = 'CONFIRMED', // Saga 완료 CANCELLED = 'CANCELLED', // 보상 트랜잭션 완료}
// Saga 시작 시 PENDING 상태로 잠금@Transactional()async startOrderSaga(orderId: string) { await this.orderRepo.update(orderId, { status: OrderStatus.PENDING_PAYMENT, sagaStartedAt: new Date(), }); // 다른 서비스가 PENDING_PAYMENT 상태를 보면 처리 대기}
// 조회 서비스에서 PENDING 주문 필터링async getConfirmedOrders(userId: string) { return this.orderRepo.find({ where: { userId, status: Not(OrderStatus.PENDING_PAYMENT), // 진행 중 주문 제외 }, });}예상 출력:
GET /orders?userId=user123→ CONFIRMED 상태 주문만 반환 (PENDING_PAYMENT 제외)대응 전략 2: Commutative Operations (교환 가능한 연산)
연산의 실행 순서가 결과에 영향을 미치지 않도록 설계한다. 어떤 순서로 실행되어도 최종 결과가 동일하게 만드는 것이 핵심이다.
// ❌ 순서 의존적 — 절대값 업데이트await inventoryRepo.update(id, { stock: newStock });
// ✅ Commutative — 증분으로 업데이트await inventoryRepo.decrement({ id }, "stock", quantity);// 어떤 순서로 실행되어도 최종 결과가 동일대응 전략 3: Pessimistic Lock (비관적 잠금, 최후 수단)
중요한 단계에서 SELECT FOR UPDATE로 강제 직렬화한다. 성능 비용이 크므로 결제 확인처럼 정합성이 절대적으로 중요한 단계에만 제한적으로 사용한다.
// TypeORM에서 SELECT FOR UPDATEconst order = await manager.findOne(Order, { where: { id: orderId }, lock: { mode: "pessimistic_write" }, // SELECT ... FOR UPDATE});⚠️ Pessimistic Lock은 처리량을 크게 낮춥니다. Saga를 도입한 이유(분산 환경 성능)와 상충하므로 정말 필요한 단일 단계에만 적용합니다.
Saga vs 2PC — 언제 무엇을 선택하는가
| 기준 | Saga | 2PC |
|---|---|---|
| 서비스 수 | 3개 이상 마이크로서비스 | 동일 DB 또는 2개 이하 |
| 처리량 | 높음 (비동기) | 낮음 (동기 잠금) |
| Isolation | ❌ 포기 | ✅ 보장 |
| 장애 내성 | ✅ 높음 | ❌ 코디네이터 단일 장애점 |
| 구현 복잡도 | 높음 (보상 트랜잭션 설계) | 중간 |
| 대표 사용 사례 | 주문·결제·배송 MSA 흐름 | 내부 계좌 이체, 배치 집계 등 |
판단 기준: “모든 서비스가 같은 DB를 쓰고, 트랜잭션 처리량이 낮다면” → 2PC. “서비스별 DB를 가지며, 높은 처리량이 필요하다면” → Saga + Semantic Lock.
실무에서 MSA 환경에서 2PC를 쓰는 경우는 거의 없다. 네트워크 파티션 상황에서 코디네이터가 다운되면 모든 참여자가 Lock을 영구 보유한 채 대기하는 Deadlock이 발생하기 때문이다.
4. Choreography vs Orchestration 비교표 + 선택 기준
섹션 제목: “4. Choreography vs Orchestration 비교표 + 선택 기준”| 항목 | Choreography | Orchestration |
|---|---|---|
| 중앙 컨트롤러 | 없음 (서비스가 이벤트로 소통) | 있음 (오케스트레이터) |
| 결합도 | 느슨 (이벤트 인터페이스만 공유) | 중간 (오케스트레이터가 서비스 API 알아야 함) |
| 흐름 파악 | 어려움 (이벤트 추적 필요) | 쉬움 (오케스트레이터 코드 한 곳에서 파악) |
| 장애 추적 | 복잡 (분산 로그 수집 필요) | 용이 (Saga 상태 테이블로 추적) |
| 확장성 | 높음 (서비스 독립적 추가 가능) | 중간 (오케스트레이터가 병목 될 수 있음) |
| 적합한 규모 | 3~5개 이하 서비스 | 5개 이상 복잡한 비즈니스 로직 |
| AWS 도구 | SQS + EventBridge | AWS Step Functions |
선택 기준:
- Choreography 선택: 서비스 수가 적고(3개 이하), 팀이 분리돼 있어 중앙 조율이 어려울 때, 각 서비스 팀이 독립적으로 배포해야 할 때.
- Orchestration 선택: Saga 흐름이 복잡하고(5단계 이상), 실패 시나리오가 다양할 때, 비즈니스 프로세스를 한 곳에서 명확하게 관리해야 할 때, AWS Step Functions처럼 상태 시각화가 필요할 때.
5. 내 업무 연결 (NestJS + SQS/EventBridge + Aurora)
섹션 제목: “5. 내 업무 연결 (NestJS + SQS/EventBridge + Aurora)”현재 스택에서 Saga Pattern을 적용하는 구체적인 방법:
Choreography 구현 시:
NestJS 서비스 A Amazon SQS NestJS 서비스 B───────────── ────────── ──────────────주문 생성 완료 → @aws-sdk/client-sqs SendMessage → order-events-queue → @ssut/nestjs-sqs SqsMessageHandler → 결제 처리 → payment-events-queue → 재고 서비스...Orchestration 구현 시 (AWS Step Functions):
NestJS API Gateway → AWS Step Functions StartExecution() → State Machine: [CreateOrder Lambda/NestJS] → [ProcessPayment] → [DecreaseInventory] 실패 시: [Catch → RefundPayment → CancelOrder] → 실행 결과를 NestJS가 폴링 또는 EventBridge로 수신Aurora(PostgreSQL 호환)에서 Outbox 테이블 운영:
- RDS Aurora Serverless v2 사용 시
outbox테이블은 같은 Aurora 클러스터에 생성 - Relay는 NestJS의
@nestjs/scheduleCron Job으로 5초 간격 실행 - 트래픽이 높으면 CDC(Change Data Capture) 방식으로 전환 (Debezium + MSK)
6. 학습 체크리스트
섹션 제목: “6. 학습 체크리스트”- 2PC와 Saga의 차이를 Lock 관점에서 설명할 수 있다
- Choreography Saga에서 이벤트 흐름을 화이트보드에 그릴 수 있다
- Orchestration Saga에서 오케스트레이터 코드를 작성할 수 있다
- 보상 트랜잭션에서 멱등성이 왜 필요한지 설명할 수 있다
- Outbox Pattern이 없을 때 발생하는 문제를 구체적으로 설명할 수 있다
- NestJS + SQS 환경에서 Choreography Saga를 구현할 수 있다
- Saga 상태를 DB 테이블로 추적하는 코드를 작성할 수 있다
6.5 트러블슈팅
섹션 제목: “6.5 트러블슈팅”트러블슈팅 1: 보상 트랜잭션 자체가 실패할 때
섹션 제목: “트러블슈팅 1: 보상 트랜잭션 자체가 실패할 때”증상: 결제 환불 API를 호출했는데 PG사 서버가 다운됐거나, 이미 처리된 주문이라고 에러가 반환된다.
원인: 보상 트랜잭션도 네트워크 오류, 외부 서비스 장애로 실패할 수 있다. 이를 “Saga 재앙(Saga Disaster)“이라고 한다.
해결:
// Dead Letter Queue + 수동 처리 플로우async compensate(sagaId: string, paymentId: string) { const MAX_RETRIES = 3; let attempt = 0;
while (attempt < MAX_RETRIES) { try { await this.paymentService.refund(paymentId); await this.sagaStateRepo.update(sagaId, { compensationStatus: 'COMPLETED' }); return; } catch (err) { attempt++; this.logger.error(`보상 트랜잭션 실패 (${attempt}/${MAX_RETRIES}): ${err.message}`); await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // 지수 백오프 } }
// MAX_RETRIES 초과 시 SQS DLQ로 보내고 알람 발송 await this.sqsClient.send(new SendMessageCommand({ QueueUrl: process.env.COMPENSATION_DLQ_URL, MessageBody: JSON.stringify({ sagaId, paymentId, reason: '보상 트랜잭션 최대 재시도 초과' }), }));
// Slack/PagerDuty 알림 → 수동 처리 필요 await this.alertService.notify(`[긴급] Saga ${sagaId} 보상 트랜잭션 수동 처리 필요`); await this.sagaStateRepo.update(sagaId, { compensationStatus: 'MANUAL_REQUIRED' });}체크리스트:
- SQS Dead Letter Queue 설정 (maxReceiveCount: 3)
saga_state테이블에compensation_status컬럼 추가- 수동 처리 대시보드 or Slack 알람 연동
트러블슈팅 2: 이벤트 중복 처리 (At-Least-Once 보장 → 중복 메시지)
섹션 제목: “트러블슈팅 2: 이벤트 중복 처리 (At-Least-Once 보장 → 중복 메시지)”증상: 결제 서비스가 OrderCreated 이벤트를 두 번 처리해서 결제가 두 번 청구됐다.
원인: SQS는 At-Least-Once 전달을 보장한다. 네트워크 문제로 ACK가 누락되면 같은 메시지가 재전달된다.
SQS → PaymentService: OrderCreated (messageId: msg-001)PaymentService: 결제 처리 완료, 응답 보냄SQS: ACK 수신 실패 (네트워크 순단)SQS → PaymentService: OrderCreated (messageId: msg-001) ← 재전달!PaymentService: 다시 결제 처리 → 이중 결제 발생해결: messageId 기반 중복 처리 방지
async handleOrderCreated(messageId: string, orderId: string, amount: number) { // messageId를 처리된 이벤트 테이블에서 확인 (멱등성 키) const exists = await this.processedEventRepo.findOneBy({ messageId }); if (exists) { this.logger.warn(`[중복] messageId ${messageId} 이미 처리됨 - 무시`); return; }
await this.dataSource.transaction(async (manager) => { // 결제 처리 await this.charge(orderId, amount, manager); // 처리된 이벤트 기록 (같은 트랜잭션) await manager.save(ProcessedEvent, { messageId, processedAt: new Date() }); });}-- processed_events 테이블CREATE TABLE processed_events ( message_id VARCHAR(255) PRIMARY KEY, processed_at TIMESTAMPTZ DEFAULT NOW());-- TTL을 위해 오래된 레코드 주기적 삭제 (예: 7일 이상)DELETE FROM processed_events WHERE processed_at < NOW() - INTERVAL '7 days';SQS FIFO 큐 사용 시: MessageDeduplicationId를 설정하면 SQS 레벨에서 5분 이내 중복 메시지를 자동 제거한다.
트러블슈팅 3: Saga 상태 추적이 안 될 때 (어느 단계에서 멈췄는지 모름)
섹션 제목: “트러블슈팅 3: Saga 상태 추적이 안 될 때 (어느 단계에서 멈췄는지 모름)”증상: 주문이 결제됐는데 재고는 차감되지 않았다. 어떤 Saga가 어느 단계에서 멈췄는지 파악하기 어렵다.
원인: Choreography에서는 각 서비스 로그가 분산돼 있어 전체 흐름을 추적하기 어렵다. Orchestration에서도 saga_state 업데이트가 누락되면 현재 단계를 알 수 없다.
해결 1: Saga Correlation ID 도입
// 모든 이벤트에 sagaId(correlationId) 포함const sagaId = uuidv4();
// 주문 생성 이벤트await sqs.send({ MessageBody: JSON.stringify({ sagaId, eventType: "OrderCreated", orderId }),});
// 결제 서비스 - sagaId를 로그에 포함this.logger.log(`[Saga ${sagaId}] 결제 처리 시작`);해결 2: saga_state 테이블로 현재 단계 조회
-- 24시간 내 IN_PROGRESS 상태로 멈춘 Saga 조회SELECT id, step, payload, created_at, NOW() - created_at AS elapsedFROM saga_stateWHERE status = 'IN_PROGRESS' AND created_at < NOW() - INTERVAL '10 minutes' -- 10분 이상 멈춘 것ORDER BY created_at;
-- 예상 출력:-- id | step | elapsed-- abc-123 | DECREASE_INVENTORY | 00:15:32-- def-456 | PROCESS_PAYMENT | 00:08:11해결 3: CloudWatch Logs Insights로 분산 추적
fields @timestamp, @message| filter @message like /Saga abc-123/| sort @timestamp asc예상 출력:
10:30:01 [Saga abc-123] Step: CREATE_ORDER10:30:02 [Saga abc-123] Step: PROCESS_PAYMENT10:30:03 [Saga abc-123] Step: DECREASE_INVENTORY ← 이후 로그 없음 → 여기서 중단트러블슈팅 4: Outbox Relay 지연으로 이벤트가 늦게 발행될 때
섹션 제목: “트러블슈팅 4: Outbox Relay 지연으로 이벤트가 늦게 발행될 때”증상: 주문이 생성됐는데 결제 서비스가 이벤트를 받는 데 30초 이상 걸린다.
원인: Outbox Relay Cron Job이 5초 간격이지만, 트래픽이 몰릴 때 PENDING 레코드가 쌓이면서 처리 지연이 발생한다.
해결:
// 배치 크기 증가 + 병렬 처리@Cron(CronExpression.EVERY_5_SECONDS)async relay() { const pending = await this.dataSource.query( // FOR UPDATE SKIP LOCKED: 다른 Relay 인스턴스와 충돌 방지 (다중 인스턴스 시) `SELECT * FROM outbox WHERE status = 'PENDING' ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED`, );
// 병렬 처리 (Promise.allSettled로 일부 실패해도 계속 진행) const results = await Promise.allSettled( pending.map(row => this.sendAndMark(row)) );
const failed = results.filter(r => r.status === 'rejected'); if (failed.length > 0) { this.logger.error(`Relay 실패: ${failed.length}건`); }}7. 다음 학습 단계
섹션 제목: “7. 다음 학습 단계”- Event Sourcing: Saga와 결합하면 모든 상태 변화를 이벤트로 저장하고 언제든 재현 가능 → L8 Event Sourcing 토픽
- AWS Step Functions: Orchestration Saga를 서버리스로 구현. 시각적 워크플로우 + 자동 재시도 + 에러 캐치 기능 내장
- CQRS (Command Query Responsibility Segregation): Saga와 함께 쓰면 쓰기 모델과 읽기 모델을 분리하여 성능 최적화
- Temporal.io: Saga Orchestration을 코드로 표현하는 오픈소스 워크플로우 엔진. Step Functions의 오픈소스 대안
8. 추천 리소스
섹션 제목: “8. 추천 리소스”-
microservices.io - Saga Pattern — Chris Richardson이 정리한 Saga 패턴 원문. Choreography/Orchestration 예시와 함께 보상 트랜잭션 개념을 명확하게 설명. Saga를 처음 이해할 때 필수 레퍼런스.
-
Microsoft Azure Architecture - Saga Design Pattern — 마이크로소프트가 실무 관점에서 정리한 Saga 패턴. 언제 쓰고 언제 쓰지 말아야 하는지 명확한 기준 제시.
-
InfoQ - Saga Orchestration for Microservices Using the Outbox Pattern — Orchestration Saga와 Outbox Pattern을 결합한 실전 구현. Debezium CDC 방식으로 Outbox를 구현하는 고급 패턴까지 다룸.
-
Temporal.io - Mastering Saga Patterns for Distributed Transactions — Saga 구현 시 자주 마주치는 엣지 케이스(보상 트랜잭션 실패, 멱등성 등)를 실전 코드로 설명.
-
AWS Prescriptive Guidance - Saga Choreography Pattern — AWS SQS/EventBridge 환경에서 Choreography Saga를 구현하는 공식 가이드. 현재 스택(NestJS + SQS)에 바로 적용 가능.
9. 예상 출력 — Saga 실행 로그 및 DB 상태 변화
섹션 제목: “9. 예상 출력 — Saga 실행 로그 및 DB 상태 변화”정상 처리 시:
[OrderSaga abc-123] Step: CREATE_ORDERINSERT INTO orders (id, user_id, amount, status) VALUES ('order-001', 'user-001', 50000, 'PENDING')INSERT INTO outbox (aggregate_id, event_type, payload) VALUES ('order-001', 'OrderCreated', {...})
[OutboxRelay] OrderCreated 발행 → SQS order-events-queue
[PaymentService] OrderCreated 수신 → 결제 처리UPDATE accounts SET balance = balance - 50000 WHERE user_id = 'user-001'[OutboxRelay] PaymentCompleted 발행
[InventoryService] PaymentCompleted 수신 → 재고 차감UPDATE inventory SET stock = stock - 1 WHERE product_id = 'prod-001'
[OrderSaga abc-123] Step: COMPLETED
-- saga_state 테이블 최종 상태 --id | step | status | updated_atabc-123 | COMPLETED | COMPLETED | 2026-04-09T10:30:05재고 부족으로 실패 + 보상 트랜잭션 실행 시:
[OrderSaga abc-123] Step: CREATE_ORDER → 완료[OrderSaga abc-123] Step: PROCESS_PAYMENT → 완료[OrderSaga abc-123] Step: DECREASE_INVENTORY → 실패 (stock: 0)[OrderSaga abc-123] 보상 트랜잭션 시작
[보상] REFUND_PAYMENTUPDATE accounts SET balance = balance + 50000 WHERE user_id = 'user-001'-- 이미 환불된 경우 (중복 처리 방지): "paymentId P-001 이미 환불됨 - 무시"
[보상] CANCEL_ORDERUPDATE orders SET status = 'CANCELLED' WHERE id = 'order-001'
[OrderSaga abc-123] FAILED
-- 최종 DB 상태 --orders: id=order-001, status=CANCELLEDaccounts: balance=원래값 (환불 완료)inventory: stock=0 (변경 없음)saga_state: status=FAILED, step=CANCEL_ORDER10. 요약
섹션 제목: “10. 요약”| 항목 | 내용 |
|---|---|
| 문제 | MSA에서 여러 서비스에 걸친 데이터 일관성 유지가 불가능 (2PC의 한계) |
| 해결책 | Saga: 로컬 트랜잭션 + 보상 트랜잭션으로 분산 트랜잭션 대체 |
| Choreography | 이벤트 기반, 느슨한 결합, 서비스 수 적을 때 적합 |
| Orchestration | 중앙 오케스트레이터, 흐름 파악 용이, 복잡한 비즈니스 로직에 적합 |
| Outbox Pattern | DB 저장 + 이벤트 발행을 원자적으로 처리하여 메시지 유실 방지 |
| 핵심 원칙 | 보상 트랜잭션은 멱등성 + 재시도 가능 해야 한다 |
| 현재 스택 | NestJS + SQS(Choreography) 또는 AWS Step Functions(Orchestration) |