Published on

MSA SAGA 보상 트랜잭션 중복 실행 막는 법

Authors

MSA에서 SAGA를 도입하면 분산 트랜잭션을 피해가면서도 “전체적으로는 일관성 있게” 비즈니스를 완결할 수 있습니다. 문제는 보상 트랜잭션(compensation)이 생각보다 자주 중복 실행된다는 점입니다.

  • 메시지 브로커의 at-least-once 전달
  • 컨슈머 재시작/리밸런스
  • 네트워크 타임아웃으로 인한 재시도
  • 오케스트레이터 장애 후 재구동

이런 환경에서 보상 트랜잭션이 2번 이상 실행되면, 환불이 중복되거나 재고가 음수로 내려가거나, 이미 취소된 주문이 또 취소되면서 장애로 이어집니다.

이 글에서는 “보상은 반드시 한 번만 실행되어야 한다”는 기대를 버리고, 중복 실행되어도 안전하도록 설계하면서 동시에 중복 실행 자체를 최대한 차단하는 방법을 단계적으로 정리합니다.

참고로 메시지 기반 처리에서 정확히 한 번을 보장하기 어려울 때 Outbox로 현실적인 해법을 구성하는 방식은 아래 글과 결이 같습니다.

왜 보상 트랜잭션이 중복 실행되는가

SAGA는 크게 오케스트레이션과 코레오그래피로 나뉘지만, 어떤 방식이든 보상은 결국 “어딘가에서 이벤트/커맨드를 받아 수행”하는 형태가 됩니다. 이때 중복이 생기는 대표 원인은 다음과 같습니다.

1) at-least-once 전달의 본질

대부분의 브로커(Kafka 포함)는 기본적으로 at-least-once를 현실적인 기본값으로 둡니다. 컨슈머가 처리 후 커밋하기 전에 장애가 나면 같은 메시지를 다시 받습니다.

2) 타임아웃과 재시도

오케스트레이터가 결제 취소 API를 호출했는데 응답이 늦어 타임아웃이 나면, “실패로 간주하고 재시도”합니다. 하지만 실제로는 결제 취소가 이미 성공했을 수 있습니다.

3) 분산 환경의 경합

동일한 보상 커맨드가 두 개의 워커에 의해 거의 동시에 처리되는 경우도 있습니다(리밸런스/파티션 이동/락 부재 등).

결론은 간단합니다.

  • 보상 트랜잭션은 중복 실행을 전제로 설계해야 합니다.
  • 동시에, 중복 실행을 DB 제약과 상태머신으로 강하게 차단해야 합니다.

목표 정의: “중복 보상 방지”를 3층으로 나누기

현장에서 가장 견고한 접근은 보통 아래 3층을 함께 적용하는 것입니다.

  1. 입력 중복 제거(Inbox / Dedup store): 같은 커맨드/이벤트를 두 번 처리하지 않기
  2. 도메인 레벨 멱등성: 설령 두 번 들어와도 결과가 한 번과 같게 만들기
  3. 상태 전이 제약(상태머신 + 조건부 업데이트): 잘못된 타이밍의 보상을 막기

이 중 하나만으로는 구멍이 생기기 쉽고, 2개 이상을 조합해야 “운영에서 버티는” 수준이 됩니다.

핵심 1: 보상 커맨드에 멱등성 키를 박아라

보상 트랜잭션을 막연히 “주문ID로 취소” 같은 형태로 만들면, 중복 여부를 판단하기가 어렵습니다. 보상에는 반드시 유일하게 식별 가능한 키가 있어야 합니다.

권장 키 구성 예시:

  • sagaId (한 SAGA 인스턴스 식별)
  • stepName 또는 action (예: RESERVE_INVENTORY, CHARGE_PAYMENT)
  • commandId (각 커맨드의 유일 ID, UUID)

보상 커맨드 예시(JSON):

{
  "sagaId": "a3d1c9b0-4b8c-4b3b-9e2d-2dcb3f0f2e3a",
  "commandId": "c7a0f6c4-0e5b-4b8d-9a3c-9d2d0a9b1e10",
  "type": "CANCEL_PAYMENT",
  "orderId": "ORD-20240224-0001",
  "reason": "INVENTORY_RESERVATION_FAILED"
}

중요 포인트는 orderId만으로는 부족하다는 점입니다. 같은 주문에서도 여러 단계의 보상이 있을 수 있고, 동일 단계라도 재시도/부분 실패가 생깁니다. 따라서 “이 보상 요청 자체”를 대표하는 commandId가 필요합니다.

핵심 2: Inbox 테이블로 1차 중복 제거(유니크 제약)

메시지 소비 측(보상 수행 서비스)에서 가장 강력하고 단순한 방어는 Inbox 테이블 + 유니크 키입니다.

테이블 예시

CREATE TABLE inbox_message (
  id BIGSERIAL PRIMARY KEY,
  command_id UUID NOT NULL,
  saga_id UUID NOT NULL,
  message_type VARCHAR(64) NOT NULL,
  received_at TIMESTAMP NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMP NULL,
  status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED',
  payload JSONB NOT NULL,
  UNIQUE (command_id)
);

처리 흐름

  1. 메시지 수신
  2. 트랜잭션 시작
  3. inbox_messagecommand_id로 insert 시도
  • 성공하면 “처리 권한 획득”
  • 유니크 충돌이면 이미 처리(또는 처리 중)이므로 ack 하고 종료
  1. 도메인 로직 수행
  2. processed_at, status=PROCESSED 업데이트
  3. 커밋

의사 코드(예: Spring 스타일)

@Transactional
public void handleCancelPayment(CancelPaymentCommand cmd) {
  boolean inserted = inboxRepository.tryInsert(cmd.commandId(), cmd.sagaId(), "CANCEL_PAYMENT", cmd);
  if (!inserted) {
    // 중복 메시지: 이미 처리했거나 누군가 처리 중
    return;
  }

  paymentService.cancel(cmd.orderId(), cmd.reason());

  inboxRepository.markProcessed(cmd.commandId());
}

tryInsert는 DB에서 유니크 제약을 이용해 원자적으로 중복을 걸러야 합니다. 애플리케이션 메모리 캐시로 dedup을 하면 재시작 시 무용지물이 됩니다.

핵심 3: 도메인 작업 자체를 “멱등하게” 만들어라

Inbox로 대부분 막을 수 있지만, 다음 상황이 남습니다.

  • insert는 성공했는데 처리 도중 장애 발생
  • paymentService.cancel은 성공했는데 markProcessed 전에 장애 발생

이 경우 재처리되면 command_id가 이미 inbox에 있으니 “중복으로 건너뛴다”로 끝낼 수도 있지만, 문제는 처리 상태가 PROCESSING에서 멈춘 메시지를 어떻게 회복하느냐입니다.

현실적으로는 “PROCESSING이 일정 시간 이상이면 재처리” 같은 리커버리 정책이 들어가고, 그 순간 보상 로직이 다시 실행될 수 있습니다. 따라서 보상 API/도메인 변경은 멱등해야 합니다.

결제 취소를 멱등하게 만드는 방법

  • 결제사에 idempotencyKey를 전달(가능한 경우)
  • 내부 DB에 refund 레코드를 command_id로 유니크하게 기록

예: refund 테이블 유니크 제약

CREATE TABLE payment_refund (
  id BIGSERIAL PRIMARY KEY,
  command_id UUID NOT NULL,
  order_id VARCHAR(64) NOT NULL,
  amount NUMERIC(18,2) NOT NULL,
  status VARCHAR(16) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (command_id)
);

환불 처리 의사 코드:

@Transactional
public void cancel(String orderId, String reason, UUID commandId) {
  boolean created = refundRepository.tryCreate(commandId, orderId, computeAmount(orderId));
  if (!created) {
    // 이미 동일 commandId로 환불이 생성됨: 멱등 처리
    return;
  }

  // 외부 결제사 호출(가능하면 idempotency key로 commandId 전달)
  pgClient.refund(orderId, commandId.toString());

  refundRepository.markSucceeded(commandId);
}

즉, “취소 요청이 중복될 수 있다”가 아니라 “중복되어도 결과가 같게” 만드는 것입니다.

핵심 4: 상태머신 + 조건부 업데이트로 잘못된 보상 자체를 차단

중복 실행뿐 아니라, 순서가 꼬인 보상도 문제입니다.

예:

  • 결제 승인 이벤트가 늦게 도착
  • 오케스트레이터가 실패로 판단해 결제 취소를 보냄
  • 실제로는 결제 승인도 성공했고, 취소도 실행되어 이상 상태

이를 줄이려면 각 단계의 상태를 명시하고, 보상은 특정 상태에서만 허용해야 합니다.

주문/결제 상태 예시

  • PAYMENT_PENDING
  • PAYMENT_CHARGED
  • PAYMENT_CANCEL_REQUESTED
  • PAYMENT_CANCELLED

보상 커맨드 처리 시 조건부 업데이트를 사용합니다.

UPDATE payment
SET status = 'PAYMENT_CANCEL_REQUESTED', updated_at = NOW()
WHERE order_id = $1
  AND status IN ('PAYMENT_CHARGED');

업데이트된 row 수가 0이면 다음 중 하나입니다.

  • 이미 취소 요청/취소 완료
  • 아직 결제 승인 전
  • 다른 흐름이 상태를 바꿈

이 경우 보상 로직은 “아무 것도 하지 않음” 또는 “추가 확인 후 종료”로 가야 합니다. 핵심은 상태 전이의 단방향성과 조건을 DB가 강제하게 만드는 것입니다.

핵심 5: Outbox/Inbox 조합으로 “보상 이벤트 발행 중복”도 막기

보상 실행을 막는 것만큼, 보상 결과 이벤트(예: PaymentCancelled) 발행의 중복도 흔한 장애 원인입니다.

  • 보상은 한 번만 됐는데 이벤트가 두 번 나가서 다운스트림이 두 번 처리

이때는 Outbox 패턴이 정석입니다.

  • 보상 처리 트랜잭션 안에서 outbox_event에 insert
  • 별도 퍼블리셔가 outbox를 읽어 브로커로 발행
  • 발행 성공 시 outbox 상태 업데이트

이 구조를 쓰면 “DB 반영은 됐는데 이벤트 발행이 안 됨” 또는 “이벤트는 나갔는데 DB 반영이 안 됨” 같은 분리 실패를 줄일 수 있습니다.

관련 구현 감각은 아래 글이 참고됩니다.

운영에서 자주 놓치는 디테일

1) Dedup 저장소의 TTL을 섣불리 짧게 잡지 말기

command_id 중복 제거를 7일만 유지하면, 8일 뒤 늦게 재전달된 메시지가 다시 처리될 수 있습니다. “이벤트가 얼마나 늦게 도착할 수 있는가”와 “재처리 윈도우”를 기준으로 TTL을 잡아야 합니다.

  • 결제/정산처럼 돈이 걸리면 TTL을 매우 길게(또는 영구) 가져가는 경우가 많습니다.

2) PROCESSING stuck 메시지의 회복 전략

Inbox에 status를 두는 이유는 stuck를 다루기 위해서입니다.

  • RECEIVED 또는 PROCESSING이 N분 이상이면 재시도 대상으로 전환
  • 단, 재시도는 결국 중복 실행 가능성을 올리므로 도메인 멱등성이 필수

3) 컨슈머 동시성에서의 경합

여러 스레드/인스턴스가 동시에 같은 command_id를 insert하려고 할 때, 유니크 제약이 최종 방어선입니다. 애플리케이션 락은 보조 수단일 뿐입니다.

4) 재시도 정책과 백오프

보상 호출이 외부 API에 걸리면 재시도 설계가 중요합니다. 무한 재시도는 중복/부하/정합성 문제를 키웁니다.

  • 지수 백오프
  • 최대 재시도 횟수
  • 재시도 가능한 오류/불가능한 오류 분리

재시도 설계 자체는 아래 글의 접근(429/과부하 대응)이 분산 시스템에서 그대로 응용됩니다.

실전 조합 레시피(권장 아키텍처)

보상 트랜잭션 중복 실행을 “현실적으로” 막는 조합을 정리하면 다음이 가장 많이 통합니다.

  1. 보상 커맨드에 command_id를 부여하고 전 구간 전달
  2. 소비 서비스는 Inbox 테이블에 UNIQUE(command_id)로 1차 중복 제거
  3. 보상 도메인 로직은 command_id 기반 유니크 제약으로 멱등 처리(환불/재고복구 등)
  4. 상태머신 + 조건부 업데이트로 “가능한 상태에서만 보상” 수행
  5. 보상 결과 이벤트는 Outbox로 발행하여 중복/누락을 최소화

이렇게 하면 “중복 메시지”와 “재시도”와 “부분 실패”가 동시에 있어도, 시스템이 망가지지 않고 수렴합니다.

간단 예시: 재고 예약 보상(재고 복구) 흐름

재고 서비스에서 RESTORE_INVENTORY 보상을 처리한다고 가정합니다.

1) Inbox로 중복 제거

INSERT INTO inbox_message(command_id, saga_id, message_type, payload)
VALUES ($1, $2, 'RESTORE_INVENTORY', $3)
ON CONFLICT (command_id) DO NOTHING;

2) 재고 복구를 멱등하게

재고 복구도 “한 번만 증가”해야 하므로, inventory_adjustment 로그를 command_id로 유니크하게 남깁니다.

CREATE TABLE inventory_adjustment (
  id BIGSERIAL PRIMARY KEY,
  command_id UUID NOT NULL,
  sku VARCHAR(64) NOT NULL,
  qty INT NOT NULL,
  type VARCHAR(16) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE(command_id)
);

처리 트랜잭션:

-- 1) adjustment 생성(중복이면 0 row)
INSERT INTO inventory_adjustment(command_id, sku, qty, type)
VALUES ($1, $2, $3, 'RESTORE')
ON CONFLICT (command_id) DO NOTHING;

-- 2) 실제 재고 증가(단, 위 insert가 성공했을 때만 수행되게 애플리케이션에서 분기)
UPDATE inventory
SET available = available + $3
WHERE sku = $2;

애플리케이션에서는 adjustment insert 성공 여부를 보고 재고 업데이트를 수행합니다. 이로써 동일 command_id의 보상이 두 번 들어와도 재고는 한 번만 복구됩니다.

마무리: “중복을 막는” 게 아니라 “중복에도 안전한” 구조로

MSA SAGA에서 보상 트랜잭션 중복 실행을 완벽히 0으로 만드는 것은 어렵습니다. 대신 다음 원칙을 지키면 운영에서 사고 확률이 급격히 내려갑니다.

  • 보상 커맨드에 멱등성 키(command_id)를 설계에 포함
  • Inbox(유니크 제약)로 입력 중복 제거
  • 도메인 변경도 command_id 기반 유니크 제약으로 멱등화
  • 상태머신 + 조건부 업데이트로 잘못된 보상 실행 차단
  • Outbox로 결과 이벤트 중복/누락을 최소화

이 조합은 “장애가 나도 결국 수렴하는 시스템”을 만드는 가장 실용적인 방법입니다.