Published on

Saga 패턴 보상 트랜잭션 중복 실행 방지법

Authors

마이크로서비스에서 Saga 패턴을 쓰면 분산 트랜잭션을 “단계별 로컬 트랜잭션 + 실패 시 보상(Compensation)”으로 풀어낼 수 있습니다. 문제는 보상 트랜잭션이 중복 실행되기 쉬운 구조라는 점입니다. 네트워크 타임아웃, 메시지 재전송, 컨슈머 재시작, 오케스트레이터 재시도 같은 정상적인 운영 이벤트가 곧바로 “보상 두 번 실행”으로 이어질 수 있습니다.

보상 중복은 단순 버그가 아니라 정합성 붕괴입니다. 예를 들어 결제 취소(환불) 보상이 두 번 실행되면 이중 환불이 되고, 재고 복구 보상이 두 번 실행되면 재고가 부풀려집니다. 이 글에서는 “왜 중복이 발생하는지”를 먼저 구조적으로 설명하고, 그 다음 중복 실행을 원천 차단하는 설계/구현 패턴을 단계별로 제시합니다.

참고로 Saga 실패가 중복 결제로 번지는 케이스와 Outbox로 막는 큰 그림은 아래 글도 함께 보면 좋습니다.

보상 트랜잭션이 중복 실행되는 대표 시나리오

1) at-least-once 메시징의 기본 특성

Kafka/RabbitMQ/SQS 등 대부분의 운영 친화적 구성은 at-least-once 전달을 기본으로 둡니다. 즉 “한 번 이상” 전달되며, 컨슈머 장애/리밸런싱/ACK 타이밍에 따라 같은 이벤트가 재전달될 수 있습니다.

  • 오케스트레이터가 CompensateRequested 이벤트를 발행
  • 컨슈머가 처리 중 죽음 → ACK 못함
  • 브로커가 재전달 → 보상 핸들러가 다시 실행

2) 오케스트레이터/코레오그래피 재시도

오케스트레이터는 안정성을 위해 재시도를 넣습니다. 하지만 “보상 요청 발행” 자체가 중복되면, 보상도 중복됩니다.

  • 상태 저장이 애매하거나(트랜잭션 경계 불명확)
  • 타임아웃 기반으로 “실패로 간주하고 보상”을 발행했다가
  • 실제로는 원 트랜잭션이 성공했던 경우

3) 분산 환경의 부분 실패

  • DB는 커밋됐는데 이벤트 발행이 실패
  • 이벤트는 발행됐는데 컨슈머 반영이 실패
  • 응답 타임아웃으로 클라이언트가 재시도

이런 “부분 실패”는 필연적이므로, 보상 설계는 중복에 안전(idempotent) 해야 합니다.

목표: ‘중복 실행’이 와도 결과가 한 번만 반영되게

보상 중복 방지는 크게 3가지 층으로 나눌 수 있습니다.

  1. 요청(메시지) 중복 제거: 같은 보상 요청을 한 번만 처리
  2. 도메인 상태 전이 보호: 상태머신으로 불가능한 전이를 차단
  3. 부작용(side effect) idempotency: 외부 API/지급/환불 같은 부작용이 중복돼도 결과가 동일

실무에서는 1~3을 같이 씁니다. “한 가지 만능”은 거의 없습니다.

핵심 1: 보상 요청에 ‘고유한 키’를 설계하라

보상은 반드시 식별 가능한 단위여야 합니다.

  • sagaId: 사가 인스턴스 식별자
  • step: 단계 이름(예: reserve-inventory, charge-payment)
  • action: COMPENSATE
  • attempt: 재시도 횟수(로그용)

그리고 보상 요청의 dedupeKey를 명확히 정의합니다.

예시:

  • dedupeKey = sagaId + ":" + step + ":COMPENSATE"

이 키가 같으면 “동일 보상”으로 간주하고 한 번만 실행되도록 합니다.

핵심 2: Inbox(또는 Processed Messages) 테이블로 컨슈머 멱등성 확보

컨슈머가 메시지를 받을 때마다 DB에 “이 메시지를 처리했는지”를 기록하고, 중복이면 즉시 무시합니다. 이를 보통 Inbox 패턴(또는 Processed Messages 패턴)이라고 부릅니다.

테이블 설계 (PostgreSQL 예시)

CREATE TABLE saga_inbox (
  dedupe_key TEXT PRIMARY KEY,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at TIMESTAMPTZ,
  status TEXT NOT NULL, -- RECEIVED | PROCESSED | FAILED
  payload JSONB
);

핵심은 dedupe_key유니크 제약(Primary Key) 을 걸어 중복 삽입을 DB가 차단하게 만드는 것입니다.

처리 흐름(트랜잭션 경계가 중요)

  1. 메시지 수신
  2. 같은 DB 트랜잭션에서 INSERT 시도
  3. 성공하면 “처리 권한 획득” → 실제 보상 로직 수행
  4. 처리 완료 후 PROCESSED로 업데이트
  5. INSERT가 유니크 충돌이면 이미 처리된 메시지 → ACK 후 종료

Node.js + TypeScript(의사 코드):

type CompensationMessage = {
  sagaId: string;
  step: string;
  action: "COMPENSATE";
  dedupeKey: string; // `${sagaId}:${step}:COMPENSATE`
  payload: Record<string, unknown>;
};

async function handleCompensation(msg: CompensationMessage) {
  await db.tx(async (tx) => {
    // 1) inbox insert로 중복 제거 (DB가 원자적으로 보장)
    const inserted = await tx.query(
      `INSERT INTO saga_inbox (dedupe_key, status, payload)
       VALUES ($1, 'RECEIVED', $2)
       ON CONFLICT (dedupe_key) DO NOTHING
       RETURNING dedupe_key`,
      [msg.dedupeKey, msg.payload]
    );

    if (inserted.rowCount === 0) {
      // 이미 처리 중이거나 처리 완료된 메시지
      return;
    }

    // 2) 보상 로직 수행 (같은 트랜잭션에서 도메인 상태 전이까지 처리)
    await runCompensation(tx, msg);

    // 3) 처리 완료 마킹
    await tx.query(
      `UPDATE saga_inbox SET status='PROCESSED', processed_at=now()
       WHERE dedupe_key=$1`,
      [msg.dedupeKey]
    );
  });
}

포인트

  • ON CONFLICT DO NOTHING + RETURNING 패턴으로 “내가 최초 처리자인지”를 쉽게 판단합니다.
  • 보상 로직과 도메인 업데이트를 같은 트랜잭션에 묶어야 중간 실패 시 재처리가 안전해집니다.

핵심 3: 보상 자체를 ‘상태머신’으로 만들고 불가능한 전이를 막아라

Inbox로 “메시지 중복”은 막아도, 다른 경로(예: 오케스트레이터가 다른 dedupeKey로 잘못 발행, 운영자 수동 재실행 등)로 보상이 다시 호출될 수 있습니다. 그래서 도메인 레벨에서도 “이미 보상된 단계는 다시 보상할 수 없다”를 강제해야 합니다.

예: 주문 사가 단계 상태

  • RESERVED(재고 예약 완료)
  • CHARGED(결제 완료)
  • 실패 시
    • 재고 단계는 RESERVED -> COMPENSATED
    • 결제 단계는 CHARGED -> COMPENSATED

DB에 단계별 상태를 두고, 업데이트는 조건부(Compare-And-Set) 로 합니다.

-- 결제 보상: CHARGED 상태일 때만 COMPENSATED로 바꾼다
UPDATE saga_step
SET status = 'COMPENSATED', updated_at = now()
WHERE saga_id = $1
  AND step = 'charge-payment'
  AND status = 'CHARGED';

애플리케이션에서는 rowCount === 1일 때만 실제 환불 API 호출을 진행하거나, 반대로 “환불 API 호출 이후 상태 변경”을 하되 그 순서를 더 강하게 보장하려면 아래의 Outbox/외부멱등성까지 결합해야 합니다.

어느 쪽이 먼저인가: 상태 변경 vs 외부 API 호출

  • 상태 먼저 변경: 외부 API 호출 실패 시 “보상은 됐다고 표시”되는 위험
  • 외부 API 먼저 호출: 호출 성공 후 DB 업데이트 실패 시 재시도 때 또 호출될 위험

따라서 외부 부작용이 있는 보상(환불/송금/포인트차감복구)은 외부 멱등성 키 + Outbox 조합이 사실상 정답에 가깝습니다.

핵심 4: 외부 시스템 호출은 반드시 Idempotency Key를 사용

Stripe 같은 결제 게이트웨이는 Idempotency-Key를 지원합니다. 내부 서비스 간 호출도 이 방식을 흉내 내는 게 좋습니다.

  • 보상 요청마다 idempotencyKey = dedupeKey
  • 외부 시스템이 같은 키로 들어온 요청은 같은 결과를 반환(또는 중복 거부)

HTTP 예시:

POST /refunds
Idempotency-Key: saga-9f1c:charge-payment:COMPENSATE
Content-Type: application/json

{ "paymentId": "pay_123", "amount": 10000 }

내부 환불 서비스도 다음과 같이 저장소를 둡니다.

  • refund_requests(idempotency_key PK, status, result)

이렇게 하면 네트워크 타임아웃으로 클라이언트가 재시도해도, 환불 서비스는 동일 키면 같은 환불 건으로 처리합니다.

핵심 5: Outbox + (필요 시) Inbox를 함께 써서 ‘발행/처리’ 원자성 확보

보상 중복은 “처리”에서만 생기지 않습니다. “보상 이벤트 발행”이 중복될 수도 있습니다. 그래서 오케스트레이터(또는 단계 서비스)가 이벤트를 발행할 때는 Outbox 패턴으로 DB 커밋과 이벤트 발행 의도를 원자적으로 묶어야 합니다.

  • 로컬 트랜잭션에서
    • 사가 상태 업데이트
    • outbox에 CompensateRequested 기록
  • 별도 릴레이(폴러/CDC)가 outbox를 읽어 브로커에 발행

이 큰 흐름과 장애 케이스는 아래 글이 더 자세합니다.

실무 팁: Outbox만으로는 “컨슈머 중복 처리”를 막지 못합니다. 브로커는 여전히 at-least-once일 수 있으니, 컨슈머 쪽 Inbox까지 가야 종단 간 멱등성이 완성됩니다.

핵심 6: 분산 락은 ‘마지막 수단’, DB 유니크 키가 1순위

보상 중복을 막겠다고 Redis 분산 락을 먼저 떠올리기 쉽지만, 락은 만능이 아닙니다.

  • 락 획득/갱신/만료 타이밍 버그
  • 네트워크 분할 시 이중 실행 가능성
  • 락은 결국 “상태 저장소”가 필요

반면 DB의 유니크 키는 가장 단순하고 강력한 원자성 도구입니다.

  • saga_inbox.dedupe_key PK
  • refund_requests.idempotency_key PK
  • compensation_executions(dedupe_key) PK

락이 필요하다면 “같은 dedupeKey에 대해 동시에 보상 로직이 길게 실행되는 문제” 같은 특수 케이스에서만 보조적으로 사용하세요.

핵심 7: 보상 로직은 ‘진짜 멱등’으로 작성하라 (도메인 예시)

예: 재고 보상(예약 취소)

나쁜 보상:

  • available += reservedQty 같은 단순 가산

좋은 보상:

  • “예약 레코드”를 기준으로 한 번만 해제
  • 예약 레코드에 released_at을 두고 조건부 업데이트
-- 예약 해제는 released_at이 NULL일 때만 1회 수행
UPDATE inventory_reservation
SET released_at = now()
WHERE reservation_id = $1
  AND released_at IS NULL;

그리고 실제 재고 반영은 “예약 해제된 건만” 집계하거나, 별도 이벤트로 반영하되 동일 reservation_id 기준으로 중복 반영이 안 되게 설계합니다.

예: 포인트 보상(차감 복구)

  • point_ledger에 “원 거래(txId)”를 참조하는 복구 엔트리를 남기고
  • (original_tx_id, type='COMPENSATION') 유니크 키로 중복을 막습니다.
CREATE UNIQUE INDEX ux_point_comp
ON point_ledger(original_tx_id)
WHERE entry_type = 'COMPENSATION';

운영 관점 체크리스트 (실패를 ‘관측’ 가능하게 만들기)

중복 방지는 설계로 끝나지 않고, 운영에서 빨리 감지되어야 합니다.

  • dedupe hit 카운트: ON CONFLICT DO NOTHING 발생 횟수
  • 보상 단계별 상태 전이 로그(사가ID, step, from->to)
  • 외부 API idempotency 충돌/재사용 응답률
  • “보상 요청 발행”과 “보상 처리 완료”의 지연 시간

또한 네트워크 불안정/서버 강제 종료 같은 조건에서 카오스 테스트를 돌리면 중복 실행이 숨어 있는 지점을 빨리 찾을 수 있습니다.

정리: 보상 중복 방지의 현실적인 조합

보상 트랜잭션의 중복 실행을 실무적으로 막는 추천 조합은 다음과 같습니다.

  1. dedupeKey 표준화: sagaId:step:COMPENSATE
  2. 컨슈머 Inbox 테이블: 유니크 키로 메시지 중복 제거
  3. 단계 상태머신 + 조건부 업데이트: 이미 보상된 단계 재실행 차단
  4. 외부 호출 Idempotency-Key: 환불/지급/포인트 등 부작용 멱등화
  5. Outbox로 발행 원자성: 보상 이벤트 발행 중복/유실을 구조적으로 감소

이 다섯 가지를 갖추면, “보상 요청이 2번/10번 와도 결과는 1번만 반영”되는 수준까지 끌어올릴 수 있습니다. Saga는 결국 실패를 전제로 하는 패턴이므로, 보상도 실패와 중복을 전제로 설계해야 안정적으로 운영됩니다.