Published on

MSA SAGA 보상 트랜잭션 중복 실행 방지법

Authors

서로 다른 마이크로서비스가 각자 DB를 가진 환경에서 사가(Saga)를 운영하다 보면, "보상 트랜잭션이 두 번 실행"되는 순간이 가장 위험합니다. 정상 트랜잭션의 중복은 대개 멱등 처리로 흡수되지만, 보상은 돈을 다시 환불하거나 재고를 다시 복구하는 등 "역방향 상태 변경"이라서 중복 실행 시 피해가 더 큽니다.

이 글은 보상 중복이 왜 발생하는지, 그리고 이를 설계적으로 막는 방법을 "실제로 운영에서 먹히는" 조합으로 정리합니다. 사가의 중복 처리 전반은 아래 글도 함께 보면 좋습니다.

보상 트랜잭션이 중복 실행되는 대표 원인

1) 메시지 브로커의 at-least-once 전달

Kafka, SQS, RabbitMQ 등 대부분의 실전 구성은 기본적으로 at-least-once에 가깝습니다. 컨슈머가 처리 후 커밋 전에 죽거나, 네트워크가 끊기거나, 리밸런싱이 일어나면 같은 메시지가 다시 전달될 수 있습니다.

2) 오케스트레이터 재시도와 타임아웃

오케스트레이션 기반 사가에서 오케스트레이터가 "보상 호출"을 HTTP로 보낼 때, 타임아웃이 발생하면 실제로는 서버가 처리했는데도 클라이언트는 실패로 판단하고 재시도합니다. 이때 보상이 중복 실행됩니다.

네트워크 타임아웃/리셋은 애플리케이션 버그가 아니라 "정상적인 운영 이벤트"로 보고 설계해야 합니다. Node.js에서 이런 케이스를 다룬 글은 아래가 참고가 됩니다.

3) 중복 이벤트 발행(프로듀서 측)

프로듀서가 DB 업데이트는 성공했지만 이벤트 발행 전에 죽으면, 재기동 후 같은 이벤트를 다시 발행할 수 있습니다. 또는 outbox 없이 "DB 커밋 후 이벤트 발행"을 분리하면, 둘 사이의 실패 구간에서 중복/유실이 발생합니다.

4) 컨슈머 측 처리 중 크래시

보상 로직이 DB 업데이트를 끝낸 뒤 ACK 전에 죽으면, 메시지가 재전달되어 보상이 또 실행됩니다.

목표: "보상은 정확히 한 번"이 아니라 "중복되어도 안전"

분산 환경에서 "정확히 한 번"을 끝까지 보장하려면 비용이 큽니다. 실무에서는 아래 두 가지를 함께 달성하는 쪽이 현실적입니다.

  1. 보상 요청/이벤트가 중복으로 들어와도 보상 로직은 멱등(idempotent)하게 설계
  2. 그래도 중복 실행 자체를 최대한 줄이기 위해 인박스(inbox)·락·상태 머신을 결합

즉 "중복 방지"는 단일 기법이 아니라 여러 레이어의 방어선입니다.

핵심 패턴 1: 보상에 멱등 키를 부여하고 상태 머신으로 제어

보상 중복을 막는 가장 중요한 전제는 "보상 시도"를 고유하게 식별하는 키입니다.

  • sagaId: 사가 인스턴스 ID
  • step: 단계명(예: reserveInventory)
  • compensationId: 보상 시도 ID(대개 sagaId + step로 충분)

그리고 각 서비스는 보상 수행 여부를 자체 DB에 기록하고, 보상 핸들러는 "이미 처리됨"이면 즉시 성공으로 응답해야 합니다.

테이블 예시

-- 보상 실행 이력(또는 인박스) 테이블
CREATE TABLE saga_compensation_log (
  compensation_id VARCHAR(100) PRIMARY KEY,
  saga_id VARCHAR(50) NOT NULL,
  step VARCHAR(50) NOT NULL,
  status VARCHAR(20) NOT NULL, -- STARTED | DONE | FAILED
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

처리 흐름

  1. 보상 요청 수신
  2. compensation_id로 로그를 "선점"(insert or ignore)
  3. 선점 성공한 경우에만 실제 보상 수행
  4. 성공 시 DONE으로 업데이트
  5. 선점 실패(이미 존재)면 "이미 처리됨"으로 간주하고 200/ACK

이때 중요한 포인트는 "선점"이 원자적이어야 한다는 점입니다. RDB면 유니크 키로 해결하는 경우가 많습니다.

Node.js 예시(개념 코드)

import { Pool } from "pg";

const pool = new Pool();

type CompensationStatus = "STARTED" | "DONE" | "FAILED";

async function tryAcquireCompensation(compensationId: string, sagaId: string, step: string) {
  const client = await pool.connect();
  try {
    const r = await client.query(
      `INSERT INTO saga_compensation_log (compensation_id, saga_id, step, status)
       VALUES ($1, $2, $3, 'STARTED')
       ON CONFLICT (compensation_id) DO NOTHING
       RETURNING compensation_id`,
      [compensationId, sagaId, step]
    );
    return r.rowCount === 1; // true면 내가 최초 실행자
  } finally {
    client.release();
  }
}

async function markStatus(compensationId: string, status: CompensationStatus) {
  await pool.query(
    `UPDATE saga_compensation_log
     SET status = $2, updated_at = NOW()
     WHERE compensation_id = $1`,
    [compensationId, status]
  );
}

export async function handleCompensation(req: any) {
  const { sagaId, step } = req.body;
  const compensationId = `${sagaId}:${step}`;

  const acquired = await tryAcquireCompensation(compensationId, sagaId, step);
  if (!acquired) {
    // 이미 처리했거나 처리 중인 보상 요청
    return { ok: true, dedup: true };
  }

  try {
    // 실제 보상 로직: 예) 재고 복구, 결제 취소 등
    await doCompensateBusinessLogic(req.body);

    await markStatus(compensationId, "DONE");
    return { ok: true };
  } catch (e) {
    await markStatus(compensationId, "FAILED");
    throw e;
  }
}

상태 머신 설계 팁

  • STARTED를 오래 유지하는 경우(프로세스 크래시)도 고려해야 합니다.
  • 일정 시간 이상 STARTED면 "재시도 가능"으로 보고 오케스트레이터가 다시 보내도, 컨슈머가 "락 만료" 후 다시 선점할 수 있게 해야 합니다.
  • 이를 위해 lease_expires_at 같은 컬럼을 두고 "만료된 STARTED는 재선점"을 허용하는 설계를 사용합니다.

핵심 패턴 2: 인박스(Inbox)로 "메시지 중복"을 흡수

이벤트 기반 사가(코레오그래피)에서는 보상 트리거가 이벤트로 전달됩니다. 이때는 "보상 이벤트" 자체를 인박스 테이블로 중복 제거하는 것이 강력합니다.

  • 메시지의 messageId(또는 Kafka key+offset 조합 등)를 유니크 키로 저장
  • 이미 처리한 메시지는 ACK만 하고 스킵

인박스 테이블 예시

CREATE TABLE inbox (
  message_id VARCHAR(100) PRIMARY KEY,
  received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  processed_at TIMESTAMP NULL
);

인박스 선점 후 처리

INSERT INTO inbox (message_id) VALUES ($1)
ON CONFLICT (message_id) DO NOTHING;

rowCount가 0이면 이미 처리된 메시지로 보고 바로 ACK합니다.

인박스는 "보상"뿐 아니라 "정방향 처리"에도 동일하게 적용됩니다. 결과적으로 사가 전체가 중복에 강해집니다.

핵심 패턴 3: 아웃박스(Outbox)로 "중복 발행"과 "유실"을 줄이기

보상 이벤트가 중복으로 발행되는 근본 원인이 "DB 커밋"과 "이벤트 발행"의 분리라면, outbox 패턴이 정석입니다.

  • 비즈니스 DB 트랜잭션 안에서 outbox 테이블에 이벤트를 함께 기록
  • 별도 퍼블리셔가 outbox를 읽어 브로커에 발행
  • 발행 완료 마킹

이렇게 하면 "DB는 성공했는데 이벤트는 유실" 같은 구간이 크게 줄고, 프로듀서 재시도로 인한 중복 발행도 통제하기 쉬워집니다(물론 완전 제거는 아니며, 컨슈머 멱등은 여전히 필요).

outbox 테이블 예시

CREATE TABLE outbox (
  event_id VARCHAR(100) PRIMARY KEY,
  aggregate_id VARCHAR(100) NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  payload JSONB NOT NULL,
  published_at TIMESTAMP NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

핵심 패턴 4: 보상 자체를 "가역 연산"이 아니라 "수량 기반"으로 만들기

보상이 "상태를 되돌린다"라는 표현 때문에 CANCEL 같은 명령형 업데이트를 떠올리기 쉽지만, 중복에 안전하려면 가능하면 "목표 상태" 또는 "차분(Delta)" 기반으로 설계하는 게 좋습니다.

예를 들어 재고 복구를 다음처럼 구현하면 위험합니다.

  • 위험한 방식: stock = stock + 1 (보상 두 번이면 +2)

더 안전한 방식은 다음 중 하나입니다.

  • 예약 레코드를 만들고, 보상은 예약 레코드를 CANCELLED로 바꾸는 방식
  • 재고를 직접 더하지 말고, reservation 합계를 기반으로 가용 재고를 계산
  • 반드시 더해야 한다면 "보상 로그"와 함께 "한 번만 더하기"를 보장

예약 기반 예시(개념 SQL)

-- 예약 레코드가 존재하고 상태가 ACTIVE일 때만 취소
UPDATE inventory_reservation
SET status = 'CANCELLED'
WHERE reservation_id = $1
  AND status = 'ACTIVE';

-- rowCount가 0이면 이미 취소되었거나 존재하지 않음

이 패턴은 "보상 명령이 중복으로 들어와도 두 번째는 rowCount 0"이 되어 자연스럽게 멱등이 됩니다.

핵심 패턴 5: 분산 락은 "보조 수단"으로만 사용

Redis 분산 락(예: Redlock)을 걸어 보상 중복 실행을 막는 접근이 있지만, 락만으로 "정확한 한 번"을 만들기는 어렵습니다.

  • 락 획득 후 처리 중 프로세스가 죽으면 락 만료 타이밍에 따라 중복 가능
  • 네트워크 파티션에서 락의 신뢰성이 흔들릴 수 있음

따라서 권장 조합은 아래입니다.

  • 1차 방어: DB 유니크 키 기반 선점(인박스/보상 로그)
  • 2차 방어: 필요 시 짧은 TTL의 락으로 동시성만 완화

즉 락은 "동시 실행 감소" 용도이고, "정합성 보장"은 DB 원자성으로 가져가는 편이 안전합니다.

오케스트레이터 설계: 재시도 정책과 응답 규격을 멱등 친화적으로

보상 호출을 HTTP로 하는 경우, 오케스트레이터는 아래를 지키는 것이 좋습니다.

  • 보상 API는 compensationId를 필수로 받는다
  • 동일 compensationId에 대해 항상 같은 의미의 응답을 준다
  • 타임아웃 시 재시도하되, 백오프와 최대 횟수를 둔다
  • 409 같은 애매한 에러보다, "이미 처리됨"을 200으로 돌려 오케스트레이터의 상태 머신을 단순화한다

보상 API 요청/응답 예시

{
  "sagaId": "S20250224-0001",
  "step": "reserveInventory",
  "compensationId": "S20250224-0001:reserveInventory",
  "reason": "paymentFailed"
}

응답은 다음처럼 단순화합니다.

{ "ok": true, "dedup": true }

dedup는 관측성을 위해 유용하지만, 오케스트레이터 로직은 ok만으로 진행 가능하게 만드는 편이 장애 시나리오에서 강합니다.

운영 관측 포인트: "중복이 발생했다"를 빠르게 알아채기

중복 방지는 100%가 아니라 "피해 최소화"가 목표인 경우가 많습니다. 따라서 탐지가 중요합니다.

  • compensation_log에서 같은 sagaId가 여러 번 STARTED로 쌓이는지
  • dedup=true 응답 비율이 급증하는지
  • 특정 스텝에서 FAILED가 반복되는지
  • 메시지 브로커의 리밸런싱/레이트 리밋/재시도 폭증과 상관관계가 있는지

메트릭 예시

  • compensation_requests_total{step=...}
  • compensation_dedup_total{step=...}
  • compensation_failed_total{step=...}

실전 권장 조합(체크리스트)

아래 조합이면 "보상 중복 실행" 문제는 대부분 제어 가능합니다.

  1. 보상 요청에 compensationId를 포함(사가 ID + 스텝)
  2. 각 서비스는 DB 유니크 키로 보상 선점 테이블을 운용(인박스 또는 보상 로그)
  3. 보상 로직은 가능하면 "상태 전이" 또는 "예약 취소"처럼 멱등이 자연스럽게 되게 모델링
  4. 이벤트 발행은 outbox로 유실/중복을 감소
  5. 오케스트레이터는 타임아웃 재시도를 하되, 보상 API는 "중복이어도 성공"으로 응답
  6. STARTED 장기 체류를 위한 임대(lease) 또는 만료/재처리 정책 추가
  7. dedup/failed 지표로 중복 징후를 상시 감시

마무리

사가에서 보상 트랜잭션의 중복 실행은 피하기 어려운 "분산 시스템의 기본값"입니다. 해결의 핵심은 보상을 한 번만 실행시키려 애쓰기보다,

  • 중복 요청을 식별할 키를 만들고
  • DB 원자성으로 선점하며
  • 비즈니스 모델을 멱등 친화적으로 바꾸고
  • outbox/inbox로 이벤트 흐름의 중복과 유실을 줄이는 것

입니다.

보상 중복/사가 설계를 더 넓게 다루는 실전 관점은 아래 글에서 확장해 볼 수 있습니다.