콘텐츠로 이동

Saga Pattern

분류: Layer 8 - 데이터베이스 심화

Saga Pattern은 여러 마이크로서비스에 걸친 분산 트랜잭션을 “단계별 로컬 트랜잭션 + 실패 시 보상 트랜잭션”으로 나눠 처리하여 데이터 일관성을 유지하는 설계 패턴이다.


“주문 서비스 → 결제 서비스 → 재고 서비스”처럼 여러 서비스에 걸친 작업을 처리할 때, 결제는 성공했는데 재고 차감이 실패하면 돈만 빠져나간 채로 주문이 완료되지 않는다. 단일 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() ← 보상 트랜잭션 C2

Redux에서 액션 실패 시 rollback 액션을 dispatch하듯, Saga는 실패 단계 이후부터 역순으로 보상 트랜잭션을 실행한다.


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 오케스트레이터 코드 예시:

saga-orchestrator.service.ts
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_at
abc-123 | CANCEL_ORDER | FAILED | 2026-04-09T10:32:11

3.4 보상 트랜잭션(Compensating Transaction) — 롤백 대신 역연산

섹션 제목: “3.4 보상 트랜잭션(Compensating Transaction) — 롤백 대신 역연산”

RDBMS의 ROLLBACK은 커밋하지 않은 변경을 되돌린다. 반면 Saga의 각 단계는 이미 커밋된 로컬 트랜잭션이다. 따라서 되돌리려면 **역연산(보상 트랜잭션)**을 새로 실행해야 한다.

원본 트랜잭션 보상 트랜잭션 (역연산)
───────────────────────── ──────────────────────────────
결제 $100 차감 결제 $100 환불 (credit)
재고 -5개 차감 재고 +5개 복구
주문 상태 PENDING 주문 상태 CANCELLED

보상 트랜잭션의 필수 조건:

  1. 멱등성(Idempotency): 같은 보상 트랜잭션을 여러 번 실행해도 결과가 동일해야 한다. SQS At-Least-Once 특성상 중복 메시지가 올 수 있기 때문이다.
  2. 재시도 가능(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 UPDATE
const order = await manager.findOne(Order, {
where: { id: orderId },
lock: { mode: "pessimistic_write" }, // SELECT ... FOR UPDATE
});

⚠️ Pessimistic Lock은 처리량을 크게 낮춥니다. Saga를 도입한 이유(분산 환경 성능)와 상충하므로 정말 필요한 단일 단계에만 적용합니다.

Saga vs 2PC — 언제 무엇을 선택하는가

기준Saga2PC
서비스 수3개 이상 마이크로서비스동일 DB 또는 2개 이하
처리량높음 (비동기)낮음 (동기 잠금)
Isolation❌ 포기✅ 보장
장애 내성✅ 높음❌ 코디네이터 단일 장애점
구현 복잡도높음 (보상 트랜잭션 설계)중간
대표 사용 사례주문·결제·배송 MSA 흐름내부 계좌 이체, 배치 집계 등

판단 기준: “모든 서비스가 같은 DB를 쓰고, 트랜잭션 처리량이 낮다면” → 2PC. “서비스별 DB를 가지며, 높은 처리량이 필요하다면” → Saga + Semantic Lock.

실무에서 MSA 환경에서 2PC를 쓰는 경우는 거의 없다. 네트워크 파티션 상황에서 코디네이터가 다운되면 모든 참여자가 Lock을 영구 보유한 채 대기하는 Deadlock이 발생하기 때문이다.


4. Choreography vs Orchestration 비교표 + 선택 기준

섹션 제목: “4. Choreography vs Orchestration 비교표 + 선택 기준”
항목ChoreographyOrchestration
중앙 컨트롤러없음 (서비스가 이벤트로 소통)있음 (오케스트레이터)
결합도느슨 (이벤트 인터페이스만 공유)중간 (오케스트레이터가 서비스 API 알아야 함)
흐름 파악어려움 (이벤트 추적 필요)쉬움 (오케스트레이터 코드 한 곳에서 파악)
장애 추적복잡 (분산 로그 수집 필요)용이 (Saga 상태 테이블로 추적)
확장성높음 (서비스 독립적 추가 가능)중간 (오케스트레이터가 병목 될 수 있음)
적합한 규모3~5개 이하 서비스5개 이상 복잡한 비즈니스 로직
AWS 도구SQS + EventBridgeAWS 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/schedule Cron Job으로 5초 간격 실행
  • 트래픽이 높으면 CDC(Change Data Capture) 방식으로 전환 (Debezium + MSK)

  • 2PC와 Saga의 차이를 Lock 관점에서 설명할 수 있다
  • Choreography Saga에서 이벤트 흐름을 화이트보드에 그릴 수 있다
  • Orchestration Saga에서 오케스트레이터 코드를 작성할 수 있다
  • 보상 트랜잭션에서 멱등성이 왜 필요한지 설명할 수 있다
  • Outbox Pattern이 없을 때 발생하는 문제를 구체적으로 설명할 수 있다
  • NestJS + SQS 환경에서 Choreography Saga를 구현할 수 있다
  • Saga 상태를 DB 테이블로 추적하는 코드를 작성할 수 있다

트러블슈팅 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 기반 중복 처리 방지

payment.service.ts
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 elapsed
FROM saga_state
WHERE 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_ORDER
10:30:02 [Saga abc-123] Step: PROCESS_PAYMENT
10: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}`);
}
}

  • Event Sourcing: Saga와 결합하면 모든 상태 변화를 이벤트로 저장하고 언제든 재현 가능 → L8 Event Sourcing 토픽
  • AWS Step Functions: Orchestration Saga를 서버리스로 구현. 시각적 워크플로우 + 자동 재시도 + 에러 캐치 기능 내장
  • CQRS (Command Query Responsibility Segregation): Saga와 함께 쓰면 쓰기 모델과 읽기 모델을 분리하여 성능 최적화
  • Temporal.io: Saga Orchestration을 코드로 표현하는 오픈소스 워크플로우 엔진. Step Functions의 오픈소스 대안


9. 예상 출력 — Saga 실행 로그 및 DB 상태 변화

섹션 제목: “9. 예상 출력 — Saga 실행 로그 및 DB 상태 변화”

정상 처리 시:

[OrderSaga abc-123] Step: CREATE_ORDER
INSERT 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_at
abc-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_PAYMENT
UPDATE accounts SET balance = balance + 50000 WHERE user_id = 'user-001'
-- 이미 환불된 경우 (중복 처리 방지): "paymentId P-001 이미 환불됨 - 무시"
[보상] CANCEL_ORDER
UPDATE orders SET status = 'CANCELLED' WHERE id = 'order-001'
[OrderSaga abc-123] FAILED
-- 최종 DB 상태 --
orders: id=order-001, status=CANCELLED
accounts: balance=원래값 (환불 완료)
inventory: stock=0 (변경 없음)
saga_state: status=FAILED, step=CANCEL_ORDER

항목내용
문제MSA에서 여러 서비스에 걸친 데이터 일관성 유지가 불가능 (2PC의 한계)
해결책Saga: 로컬 트랜잭션 + 보상 트랜잭션으로 분산 트랜잭션 대체
Choreography이벤트 기반, 느슨한 결합, 서비스 수 적을 때 적합
Orchestration중앙 오케스트레이터, 흐름 파악 용이, 복잡한 비즈니스 로직에 적합
Outbox PatternDB 저장 + 이벤트 발행을 원자적으로 처리하여 메시지 유실 방지
핵심 원칙보상 트랜잭션은 멱등성 + 재시도 가능 해야 한다
현재 스택NestJS + SQS(Choreography) 또는 AWS Step Functions(Orchestration)