Published on

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

Authors

서로 다른 서비스가 각자 DB를 가진 MSA에서 Saga 패턴은 사실상 표준에 가깝습니다. 문제는 보상 트랜잭션이 "실패를 되돌리는 안전장치"가 아니라, 운영 환경에서는 "중복 실행과 레이스 컨디션을 유발하는 또 하나의 쓰기 트랜잭션"이 되기 쉽다는 점입니다.

특히 다음 조건이 겹치면 보상은 거의 필연적으로 중복 호출됩니다.

  • 메시지 브로커의 at-least-once 전달(중복 메시지)
  • 워커 재시작, 네트워크 타임아웃으로 인한 재시도
  • 오케스트레이터의 상태 저장 실패와 재구동
  • 보상 실행 도중 부분 실패(일부만 되돌려짐)

이 글에서는 "보상 트랜잭션 중복 실행"을 단순히 "코드에서 체크하면 되지" 수준이 아니라, 데이터 모델과 메시징 경계까지 포함해 재현 가능한 방식으로 막는 방법을 정리합니다.

관련해서 비슷한 성격의 중복 실행 이슈(워커 재시도, 가시성 타임아웃 충돌)는 Redis 기반 Celery 유령 작업 근절하기 무한 재시도와 중복 실행을 부르는 acks_late prefetch_multiplier visibility_timeout 충돌 디버깅 체크리스트도 참고할 만합니다.

1) 보상 중복 실행이 발생하는 대표 시나리오

1-1. 타임아웃은 실패가 아니다

오케스트레이터가 결제 취소 API를 호출했는데, 네트워크 타임아웃으로 응답을 못 받으면 오케스트레이터는 "실패"로 판단하고 재시도합니다. 하지만 실제로는 첫 호출이 서버에서 처리됐을 수 있습니다.

즉, 타임아웃은 "결과를 모른다"이지 "실패"가 아닙니다. 그래서 보상 호출은 반드시 멱등해야 합니다.

1-2. 메시지는 중복 전달된다

Kafka, SQS, RabbitMQ 등은 설계상 중복 전달이 발생할 수 있습니다. 소비자 측에서 중복을 제거하지 않으면 같은 보상 이벤트가 여러 번 처리됩니다.

1-3. 오케스트레이터 재시작과 상태 복구

오케스트레이터가 "보상 시작" 상태로 DB에 기록했지만, 실제 메시지 발행 전에 죽었다면 재시작 후 다시 발행합니다. 반대로 메시지는 발행됐지만 상태 기록 전에 죽어도 재시작 후 다시 발행합니다.

이 문제는 전형적인 "상태 업데이트"와 "메시지 발행"의 원자성 문제이며, 아웃박스 패턴이 실전 해법입니다.

2) 방지 전략의 큰 그림: 멱등성 + 중복 제거 + 상태 머신

보상 중복 실행 방지는 한 가지 기법으로 끝나지 않습니다. 보통 아래 3가지를 함께 가져가야 합니다.

  1. 보상 API 자체를 멱등하게 만든다
  2. 메시지 소비 측에서 중복 이벤트를 제거한다(인박스)
  3. Saga 오케스트레이터는 상태 머신으로 "한 번만" 전이되도록 강제한다

핵심은 "어차피 중복은 온다"를 전제로, 중복이 와도 결과가 안정적으로 동일해지게 만드는 것입니다.

3) 보상 API를 멱등하게 만드는 키 설계

3-1. 멱등 키는 무엇을 대표해야 하나

보상은 보통 "원 트랜잭션을 취소"하는 의미입니다. 따라서 멱등 키는 다음 중 하나를 대표해야 합니다.

  • 원 트랜잭션의 식별자(예: paymentId, reservationId)
  • Saga 실행의 단계 식별자(예: sagaId + step)
  • 보상 커맨드의 고유 ID(예: commandId)

실무에서는 다음이 가장 안전합니다.

  • sagaId + stepName + attemptedActionId

이 조합은 "같은 Saga의 같은 단계 보상"을 정확히 한 번만 적용하도록 합니다.

3-2. DB 유니크 제약으로 멱등성 강제

애플리케이션 코드 if 문으로 막는 것은 레이스 컨디션에 취약합니다. 가장 강한 방법은 DB에 유니크 인덱스를 걸어 "중복 요청은 저장 단계에서" 막는 것입니다.

예: 결제 취소를 기록하는 테이블

CREATE TABLE payment_compensation (
  id BIGSERIAL PRIMARY KEY,
  saga_id VARCHAR(64) NOT NULL,
  step_name VARCHAR(64) NOT NULL,
  payment_id VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (saga_id, step_name)
);

이제 결제 서비스는 보상 요청이 오면 먼저 saga_idstep_name으로 "이미 처리했는지"를 DB 레벨에서 판정할 수 있습니다.

4) 인박스 패턴으로 "중복 메시지"를 흡수하기

보상은 흔히 이벤트로 전달됩니다. 이때 소비자는 반드시 "이 이벤트를 처리했는가"를 저장해야 합니다.

4-1. 인박스 테이블

CREATE TABLE inbox_message (
  message_id VARCHAR(128) PRIMARY KEY,
  received_at TIMESTAMP NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMP NULL
);

메시지 소비 트랜잭션에서 다음을 원자적으로 수행합니다.

  1. inbox_messagemessage_id 삽입 시도
  2. 이미 있으면 중복이므로 무시
  3. 처음이면 비즈니스 로직 실행 후 processed_at 업데이트

4-2. Spring Boot 예시(단일 DB 트랜잭션)

@Service
public class CompensationConsumer {

  private final InboxRepository inboxRepository;
  private final PaymentService paymentService;

  @Transactional
  public void onMessage(CompensationEvent event) {
    boolean inserted = inboxRepository.insertIfAbsent(event.messageId());
    if (!inserted) {
      return; // duplicate
    }

    paymentService.compensate(event.sagaId(), event.stepName(), event.paymentId());
    inboxRepository.markProcessed(event.messageId());
  }
}

여기서 중요한 전제는 @Transactional이 실제로 적용되어야 한다는 점입니다. 프록시 경계나 self-invocation 등으로 트랜잭션이 무시되면, 인박스 insert와 보상 로직이 분리되어 중복이 다시 발생할 수 있습니다. 이 주제는 Spring Boot 3에서 @Transactional 무시되는 5가지에서 흔한 함정을 정리해 두었습니다.

5) 아웃박스 패턴으로 오케스트레이터의 "중복 발행"을 차단

오케스트레이터는 상태 전이와 메시지 발행을 함께 해야 합니다. 하지만 DB 커밋과 브로커 publish는 분산 원자 커밋이 아닙니다.

해결책은 아웃박스 테이블을 두고 다음을 보장하는 것입니다.

  • 오케스트레이터 DB 트랜잭션 안에서
    • Saga 상태 업데이트
    • 아웃박스 이벤트 insert
  • 별도 퍼블리셔가 아웃박스 테이블을 폴링 또는 CDC로 읽어 브로커에 발행

5-1. 아웃박스 테이블 예시

CREATE TABLE outbox_event (
  id BIGSERIAL PRIMARY KEY,
  aggregate_type VARCHAR(64) NOT NULL,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload JSONB NOT NULL,
  dedup_key VARCHAR(128) NOT NULL,
  published_at TIMESTAMP NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (dedup_key)
);

dedup_key는 예를 들어 sagaId:stepName:COMPENSATE처럼 구성해 "같은 보상 이벤트가 두 번 쌓이지" 않게 합니다.

6) Saga 상태 머신으로 "보상은 한 번만 전이"되게 만들기

멱등 키와 인박스/아웃박스가 있어도, 오케스트레이터가 상태를 엉성하게 관리하면 같은 보상 단계가 여러 번 스케줄될 수 있습니다.

권장 패턴은 "단계별 상태"를 명시적으로 저장하고, 상태 전이를 CAS 방식으로 제한하는 것입니다.

6-1. 단계 상태 테이블

CREATE TABLE saga_step (
  saga_id VARCHAR(64) NOT NULL,
  step_name VARCHAR(64) NOT NULL,
  state VARCHAR(32) NOT NULL,
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  PRIMARY KEY (saga_id, step_name)
);

상태 예시는 다음과 같습니다.

  • STARTED
  • ACTION_DONE
  • COMPENSATION_REQUESTED
  • COMPENSATED
  • FAILED

6-2. 조건부 업데이트로 중복 전이 차단

UPDATE saga_step
SET state = 'COMPENSATION_REQUESTED', updated_at = NOW()
WHERE saga_id = $1
  AND step_name = $2
  AND state IN ('ACTION_DONE', 'FAILED');

영향 받은 row 수가 1이면 "처음 전이"이고, 0이면 이미 전이된 것이므로 추가 보상 발행을 중단합니다.

7) 분산 락은 보조 수단으로만 사용하기

Redis 분산 락으로 "동시에 두 워커가 같은 보상"을 처리하지 못하게 막을 수는 있습니다. 다만 락은 다음 문제를 해결하지 못합니다.

  • 락 획득 전에 이미 중복 요청이 큐에 쌓임
  • 락 TTL 만료, 네트워크 분할, 워커 다운 시 락 유실
  • 락은 "한 번만 처리"를 영구적으로 증명하지 못함(재처리 방지 기록이 없음)

따라서 락은 "동시성 완화" 용도로만 두고, 최종 방어선은 DB 유니크 제약과 인박스/멱등 처리로 두는 것이 안전합니다.

Redis 락을 쓰더라도 키는 반드시 인라인 코드로 명확히 정의하세요. 예: lock:compensate:{sagaId}:{stepName} 같은 형태가 흔합니다.

8) 보상 로직을 진짜 멱등하게 만드는 구현 팁

8-1. 보상은 "상태를 되돌리는" 대신 "목표 상태로 수렴"시켜라

예를 들어 결제 취소는 "취소 요청"을 여러 번 보내도 결과가 같아야 합니다.

  • 나쁜 예: "취소 금액을 -1로 감소" 같은 증분 업데이트
  • 좋은 예: "payment 상태를 CANCELLED로 설정" 같은 목표 상태 업데이트

8-2. 외부 시스템 연동은 별도 멱등 키를 전파

PG사나 외부 예약 시스템이 멱등 키를 지원한다면 반드시 전달해야 합니다.

  • HTTP 헤더 예: Idempotency-Key
  • 바디 필드 예: requestId

그리고 그 키는 내부의 sagaId와 연계되어야 운영 시 추적이 쉽습니다.

8-3. 보상 결과를 저장하고, 재요청 시 "재실행" 대신 "결과 반환"

결제 취소가 완료되면 payment_compensationCOMPENSATED와 취소 승인번호를 저장합니다. 같은 보상이 다시 오면 외부 호출을 다시 하지 말고 저장된 결과를 반환합니다.

9) 엔드투엔드 예시: 주문 Saga에서 결제 보상 중복 방지

9-1. 흐름

  • 주문 서비스(오케스트레이터)
    • 주문 생성
    • 결제 요청
    • 재고 예약
    • 실패 시 역순으로 보상

결제 보상 단계에서 필요한 장치는 다음과 같습니다.

  • 오케스트레이터: saga_step 조건부 전이 + 아웃박스
  • 결제 서비스(소비자): 인박스 + payment_compensation 유니크 제약

9-2. 오케스트레이터 아웃박스 생성(의사 코드)

@Transactional
public void requestPaymentCompensation(String sagaId, String paymentId) {
  int updated = sagaStepRepository.markCompensationRequested(sagaId, "PAYMENT");
  if (updated == 0) return;

  OutboxEvent evt = OutboxEvent.builder()
      .dedupKey(sagaId + ":PAYMENT:COMPENSATE")
      .eventType("PaymentCompensationRequested")
      .payload(Map.of(
          "messageId", UUID.randomUUID().toString(),
          "sagaId", sagaId,
          "stepName", "PAYMENT",
          "paymentId", paymentId
      ))
      .build();

  outboxRepository.insert(evt);
}

9-3. 결제 서비스 처리(인박스 + 유니크)

@Transactional
public CompensationResult compensate(String messageId, String sagaId, String stepName, String paymentId) {
  if (!inboxRepository.insertIfAbsent(messageId)) {
    return CompensationResult.duplicate();
  }

  // DB 유니크로 한 번만 기록되게 함
  boolean created = compensationRepository.createIfAbsent(sagaId, stepName, paymentId);
  if (!created) {
    return compensationRepository.loadResult(sagaId, stepName);
  }

  // 외부 PG 취소 호출(멱등 키 전달 권장)
  PgCancelResponse resp = pgClient.cancel(paymentId, /* idempotencyKey */ sagaId + ":" + stepName);

  compensationRepository.markCompensated(sagaId, stepName, resp.approvalNo());
  inboxRepository.markProcessed(messageId);
  return CompensationResult.success(resp.approvalNo());
}

이 구조에서는 다음이 동시에 만족됩니다.

  • 같은 메시지를 여러 번 받아도 인박스가 차단
  • 인박스를 우회해 같은 보상 API가 직접 여러 번 호출돼도 유니크 제약이 차단
  • 오케스트레이터가 재시작되어도 아웃박스 dedup_key와 상태 전이가 차단

10) 운영 체크리스트

  • 보상 요청/응답에 sagaId, stepName, messageId, idempotencyKey를 모두 로깅하는가
  • 보상 테이블에 유니크 제약이 있는가(애플리케이션 체크만 하지 않는가)
  • 소비자에 인박스가 있는가(또는 Kafka 트랜잭션 등 동등한 중복 제거가 있는가)
  • 오케스트레이터가 상태 머신으로 "단계 전이"를 통제하는가
  • 타임아웃을 실패로 간주해 무한 재시도하지 않는가(서킷 브레이커, 재시도 정책 포함)

마무리

Saga 패턴에서 보상 트랜잭션 중복 실행은 "가끔 생기는 버그"가 아니라, 분산 시스템의 기본 조건(중복 전달, 불확실한 실패, 재시도)에서 자연스럽게 발생합니다. 따라서 해결도 "재시도 줄이기"가 아니라, 중복이 와도 안전한 아키텍처로 만드는 쪽이 정답입니다.

정리하면 우선순위는 다음과 같습니다.

  • 1순위: 보상 API 멱등성(DB 유니크 제약 + 결과 저장)
  • 2순위: 인박스/아웃박스로 메시징 경계의 중복 흡수
  • 3순위: Saga 상태 머신과 조건부 전이로 오케스트레이션 중복 차단
  • 보조: Redis 락 등으로 동시성 완화

이 조합을 갖추면 "보상이 두 번 실행됐다"가 아니라 "두 번 요청됐지만 한 번만 적용됐다"로 시스템의 성격이 바뀝니다.