Published on

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

Authors

분산 트랜잭션을 Saga로 풀면 “성공 경로”보다 “실패 경로”가 더 어렵습니다. 특히 보상 트랜잭션은 재시도중복 이벤트가 일상적으로 발생하기 때문에, 한 번만 실행돼야 하는 보상이 두 번 실행되면 데이터가 더 망가집니다. 예를 들어 결제 취소가 두 번 호출되어 환불이 중복 처리되거나, 재고 복원이 두 번 일어나서 재고가 뻥튀기되는 식입니다.

이 글에서는 보상 트랜잭션이 중복 실행되는 전형적인 원인을 짚고, 중복 실행을 구조적으로 차단하는 패턴을 DB·메시징·애플리케이션 레벨에서 어떻게 조합하는지 정리합니다.

관련 Saga 설계 전반은 Kafka SAGA 보상 트랜잭션 설계 실전 7패턴도 함께 참고하면 맥락이 더 잘 이어집니다.

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

보상 중복은 “버그”라기보다 분산 시스템의 정상 동작에서 발생합니다.

1) At-least-once 전달

Kafka/큐/이벤트버스에서 소비자 장애, 리밸런싱, 커밋 타이밍 문제로 동일 메시지가 재전달될 수 있습니다. 즉, 소비자는 기본적으로 중복을 전제로 설계해야 합니다.

2) 오케스트레이터 재시도

Saga 오케스트레이터가 타임아웃을 실패로 간주하고 보상을 발행했는데, 실제로는 원 트랜잭션이 늦게 성공했거나(지연), 보상 발행 자체가 재시도되면서 중복이 발생합니다.

3) 네트워크 타임아웃으로 인한 “성공했지만 실패로 보이는” 호출

HTTP 호출에서 클라이언트는 타임아웃을 보고 재시도하지만, 서버는 이미 처리 완료했을 수 있습니다.

4) 보상 로직의 비멱등성

보상은 종종 “되돌리기”라서 멱등하다고 착각하기 쉽지만, 실제로는 상태 기반으로 설계하지 않으면 비멱등입니다.

  • 환불 API를 단순히 “취소 요청”으로 구현하면 중복 환불 위험
  • 재고 복원을 단순히 “+1”로 구현하면 중복 복원 위험

목표: “중복 이벤트가 와도 보상은 한 번만”

현실적인 목표는 아래 둘 중 하나입니다.

  1. Exactly-once 보상 실행에 가깝게 만들기(강한 중복 방지)
  2. 보상이 여러 번 실행돼도 결과가 같게 만들기(멱등 보상)

실무에서는 1과 2를 섞습니다. 메시지 레벨에서는 at-least-once를 받아들이고, 업무 처리 레벨에서 멱등성 + 중복 차단 키로 방어하는 방식이 가장 일반적입니다.

패턴 1) 보상 요청에 “Dedup Key”를 설계한다

중복 방지의 시작은 키입니다. 보상 요청에는 최소한 아래가 필요합니다.

  • sagaId: Saga 인스턴스 식별자
  • step: 어떤 단계의 보상인지
  • attempt(선택): 재시도 횟수(중복 방지에는 보통 불필요)
  • commandId 또는 eventId: 메시지 자체의 고유 ID

가장 흔한 실수는 eventId만으로 중복을 막는 것입니다. 재발행 시 eventId가 바뀌면 중복 방지가 깨집니다. 보상 실행의 “업무적 동일성” 기준으로 키를 잡아야 합니다.

권장 Dedup Key 예시:

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

이 키는 같은 Saga의 같은 단계 보상을 논리적으로 한 번만 실행하도록 만듭니다.

패턴 2) Dedup 테이블(또는 인박스)로 “먼저 기록하고 처리”한다

가장 강력하고 단순한 방법은 DB에 처리 이력을 남기고, 유니크 제약으로 중복을 차단하는 것입니다.

스키마 예시

CREATE TABLE compensation_dedup (
  dedup_key        VARCHAR(200) PRIMARY KEY,
  saga_id          VARCHAR(64)  NOT NULL,
  step            VARCHAR(64)  NOT NULL,
  first_seen_at    TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  processed_at     TIMESTAMP    NULL,
  status           VARCHAR(32)  NOT NULL
);

-- status 예: RECEIVED, PROCESSING, DONE, FAILED

처리 흐름(핵심)

  1. 메시지 수신
  2. 트랜잭션 시작
  3. dedup_keyINSERT
    • 성공하면 “내가 최초 처리자”
    • 실패(중복 키)면 이미 처리 중이거나 완료된 것
  4. 보상 실행 및 결과 업데이트
  5. 커밋

예시 코드(의사 코드)

def handle_compensation(msg):
    dedup_key = f"{msg.saga_id}:{msg.step}:COMPENSATE"

    with db.transaction():
        inserted = db.execute(
            "INSERT INTO compensation_dedup(dedup_key, saga_id, step, status) VALUES(?, ?, ?, 'PROCESSING')",
            [dedup_key, msg.saga_id, msg.step]
        )

        # 유니크 충돌이면 중복
        # 구현체에 따라 예외 캐치 또는 rowcount 확인

        do_compensate(msg)  # 외부 API 호출 또는 상태 변경

        db.execute(
            "UPDATE compensation_dedup SET status='DONE', processed_at=NOW() WHERE dedup_key=?",
            [dedup_key]
        )

운영 팁

  • PROCESSING 상태가 오래 남는 경우(컨슈머 다운) 대비가 필요합니다.
    • 일정 시간 이상이면 재처리 허용(락 타임아웃) 또는 운영 알람
  • 테이블이 무한히 커지므로 TTL/파티셔닝/아카이빙이 필요합니다.

패턴 3) 보상 자체를 “상태 전이 기반”으로 멱등하게 만든다

Dedup 테이블은 강력하지만, 외부 시스템 호출이나 DB 업데이트가 섞이면 여전히 구멍이 생길 수 있습니다. 예를 들어 보상 호출이 외부 결제사에 성공했는데, 로컬 DB 업데이트 전에 장애가 나면 재시도 시 또 호출될 수 있습니다.

그래서 보상 로직은 가능하면 상태 전이로 멱등하게 만듭니다.

나쁜 예: 증분 업데이트

UPDATE inventory SET quantity = quantity + 1 WHERE sku = ?;

중복 실행되면 수량이 계속 증가합니다.

좋은 예: “예약 레코드”를 기준으로 복원

  • 주문 시점에 inventory_reservation을 만들고
  • 보상은 “해당 예약을 해제”하는 상태 전이로 처리
-- 1) 예약을 CANCELLED로 바꾸는 것이 보상
UPDATE inventory_reservation
SET status = 'CANCELLED'
WHERE reservation_id = ?
  AND status = 'RESERVED';

-- 2) 실제 재고 반영은 status 전이를 기준으로 1회만

핵심은 이미 CANCELLED면 아무 것도 하지 않는 것입니다. 이 방식은 Dedup이 없더라도 어느 정도 중복에 안전합니다.

패턴 4) Outbox + “보상 완료 이벤트”로 오케스트레이터 중복을 줄인다

오케스트레이터가 보상을 재발행하는 흔한 이유는 “완료 신호를 못 받았기 때문”입니다. 따라서 보상 서비스는 보상 실행 후 완료 이벤트를 확실히 발행해야 하고, 이 발행이 로컬 트랜잭션과 원자적으로 묶여야 합니다.

이를 위해 Outbox 패턴을 씁니다.

Outbox 테이블 예시

CREATE TABLE outbox (
  id            BIGINT PRIMARY KEY AUTO_INCREMENT,
  aggregate_id  VARCHAR(64) NOT NULL,
  event_type    VARCHAR(64) NOT NULL,
  payload       TEXT        NOT NULL,
  created_at    TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
  published_at  TIMESTAMP   NULL
);

처리 흐름

  • 보상 처리 트랜잭션 안에서
    • Dedup 기록
    • 업무 상태 변경
    • Outbox에 CompensationCompleted 이벤트 저장
  • 별도 퍼블리셔가 Outbox를 읽어 Kafka로 발행

이렇게 하면 “보상은 됐는데 완료 이벤트가 유실”되는 상황을 크게 줄일 수 있습니다.

패턴 5) 분산 락은 최후의 수단으로, 범위를 아주 좁게

Redis 락이나 DB advisory lock으로 “동시에 두 컨슈머가 같은 보상을 처리”하는 것을 막을 수 있습니다. 하지만 락은 운영 복잡도를 올리고, 장애 시 데드락/락 유실/스플릿 브레인 이슈가 생길 수 있습니다.

권장 순서:

  1. 유니크 키 기반 Dedup(가장 단순, 강함)
  2. 상태 전이 기반 멱등 보상(업무적으로 안전)
  3. Outbox로 완료 이벤트 신뢰성 강화
  4. 그래도 필요하면 락(범위 최소화)

락을 쓴다면 반드시 다음을 지키세요.

  • 락 키는 dedupKey와 동일하게
  • TTL을 짧게
  • 락 획득 실패 시 바로 리턴(재시도는 메시지 시스템에 맡김)

패턴 6) “외부 API 보상”은 Idempotency-Key를 강제한다

결제 취소/환불, 포인트 회수, 쿠폰 복구처럼 외부 시스템 호출이 보상에 포함되면, 내부 Dedup만으로는 충분하지 않습니다. 외부 API도 중복 호출을 받아들일 수 있어야 합니다.

가능하면 외부 API에 다음을 적용합니다.

  • Idempotency-Key 헤더 지원
  • 동일 키 요청은 같은 결과를 반환

예시(HTTP 요청):

curl -X POST https://pay.example.com/refunds \
  -H 'Idempotency-Key: order-123:payment:refund' \
  -H 'Content-Type: application/json' \
  -d '{"orderId":"order-123","amount":1000}'

외부가 이를 지원하지 않으면, 내부에서 “외부 호출 결과”를 저장하고 재시도 시 재사용하는 캐시/레코드가 필요합니다.

패턴 7) 재시도 정책과 데드레터는 “중복 방지”와 세트로 설계한다

중복 방지는 재시도와 결합될 때 의미가 있습니다.

  • 재시도는 반드시 필요합니다(일시 장애는 늘 존재)
  • 대신 재시도는 업무적으로 안전해야 합니다(멱등 + Dedup)

실무 체크:

  • 지수 백오프 + 지터
  • 최대 재시도 횟수 초과 시 DLQ
  • DLQ 재처리 시에도 Dedup 키가 동일하게 유지되는지

DB 락 경합/데드락으로 재시도가 잦다면, 애플리케이션 레벨 패턴과 함께 DB 관점의 재시도도 정리해두는 게 좋습니다. 예를 들어 MySQL 데드락 상황에서의 재시도 설계는 MySQL 8 Deadlock 1213 원인추적·재시도 패턴과 연결됩니다.

실전 조합 예시: Kafka 컨슈머 + Dedup + Outbox

아래는 “중복 메시지가 와도 보상은 1회만 실행”을 목표로 한 전형적인 조합입니다.

단계

  1. Kafka에서 CompensateStepRequested 수신
  2. DB 트랜잭션 시작
  3. compensation_dedupdedup_key insert
  4. 보상 실행(가능하면 상태 전이 기반)
  5. Outbox에 CompensationCompleted 저장
  6. 커밋
  7. Outbox 퍼블리셔가 Kafka로 완료 이벤트 발행

컨슈머 의사 코드

// Java 유사 의사 코드
public void onMessage(CompensateRequested msg) {
  String dedupKey = msg.sagaId() + ":" + msg.step() + ":COMPENSATE";

  try {
    tx.begin();

    dedupRepo.insertProcessing(dedupKey, msg.sagaId(), msg.step());

    // 보상은 상태 전이 기반으로 멱등하게
    compensationService.compensate(msg);

    outboxRepo.add(
      msg.sagaId(),
      "CompensationCompleted",
      toJson(Map.of("sagaId", msg.sagaId(), "step", msg.step()))
    );

    dedupRepo.markDone(dedupKey);
    tx.commit();

  } catch (UniqueConstraintViolation e) {
    tx.rollback();
    // 이미 처리된 보상. ack 처리(또는 무시)

  } catch (Exception e) {
    tx.rollback();
    // 재시도 유도(메시지 재전달)
    throw e;
  }
}

포인트는 “이미 처리됨”을 에러로 보지 않고 정상 플로우로 흡수하는 것입니다.

흔한 함정과 점검 리스트

1) Dedup 키를 잘못 잡아 중복 방지가 깨짐

  • 메시지 발행마다 바뀌는 eventId를 키로 사용
  • sagaId 없이 orderId만 사용해서 다른 Saga와 충돌

권장: sagaId + step + compensate 조합을 기본으로, 정말 필요할 때만 범위를 넓히거나 좁히세요.

2) Dedup 기록과 보상 실행이 같은 트랜잭션이 아님

  • Dedup insert 후 커밋
  • 그 다음 보상 실행

이러면 중간 장애 시 “처리됨으로 기록됐는데 실제 보상은 안 됨”이 발생합니다. Dedup, 상태 변경, Outbox는 가능한 한 같은 트랜잭션으로 묶으세요.

3) 보상 로직이 증분 업데이트로 되어 있음

  • +1, -1, “취소 횟수 증가” 같은 패턴은 중복에 취약합니다.
  • 상태 전이(RESERVED에서 CANCELLED로)로 바꾸면 안정성이 급상승합니다.

4) 오케스트레이터 타임아웃이 너무 짧음

타임아웃이 짧으면 보상 발행이 과도해지고 중복도 늘어납니다. 인프라 지연이 원인이라면 타임아웃만 늘릴 게 아니라 병목을 찾아야 합니다. 예를 들어 클러스터 네트워크/인증 문제로 외부 의존성이 느려지는 경우도 많습니다. 운영 환경에서 원인 진단이 필요하면 EKS TLS handshake timeout 원인·해결 9가지 같은 체크리스트가 도움이 됩니다.

결론

Saga 보상 트랜잭션 중복 실행 방지는 “컨슈머에서 중복 체크 한 번”으로 끝나는 문제가 아닙니다. 핵심은 다음 3가지를 함께 만족하는 것입니다.

  • Dedup Key를 업무적으로 올바르게 설계한다
  • DB 유니크 제약 기반 Dedup(인박스) 으로 중복 실행을 구조적으로 차단한다
  • 보상 로직을 상태 전이 기반 멱등성으로 만들고, Outbox로 완료 이벤트를 신뢰성 있게 발행한다

이 조합을 적용하면 at-least-once 메시징 환경에서도 보상 트랜잭션을 “사실상 한 번만 실행”되게 만들 수 있고, 장애/재시도/리밸런싱 같은 현실적인 운영 이벤트에도 흔들리지 않는 Saga를 구축할 수 있습니다.