Published on

MSA 분산트랜잭션 - Saga 보상 실패 재처리·중복방지

Authors

서론

MSA에서 분산 트랜잭션을 다루다 보면, 성공 경로보다 더 자주 문제를 만드는 지점이 실패 경로입니다. 특히 Saga 패턴을 적용했을 때 보상 트랜잭션(Compensation) 이 한 번 실패하면, 시스템은 곧바로 두 가지 딜레마에 빠집니다.

  • 보상을 재처리 해야 하는데, 재시도 중에 동일 보상이 중복 실행 되면 더 큰 장애가 된다
  • 중복을 피하려고 보수적으로 막으면, 결국 보상이 영원히 완료되지 않는 미해결(Orphan) 상태 가 쌓인다

이 글은 “보상 실패를 어떻게 재처리할 것인가”와 “재처리 과정에서 중복 실행을 어떻게 방지할 것인가”를 설계 레벨과 구현 레벨로 나눠 정리합니다. Saga 기본 구조는 이미 알고 있다는 전제에서, 운영에서 터지는 케이스 중심으로 접근합니다.

참고로 Saga 설계 전반은 아래 글과 연결됩니다.


보상 실패가 어려운 이유: 재시도는 필연, 중복은 기본값

보상은 대개 다음 특징을 가집니다.

  1. 외부 부작용(side effect) 을 되돌린다 (결제 취소, 재고 복원, 예약 해제 등)
  2. 보상 호출 자체도 또 다른 분산 호출이다 (네트워크, 타임아웃, 부분 실패)
  3. “되돌리기”가 항상 완전한 역연산이 아니다 (환불 수수료, 재고가 이미 다른 주문에 할당됨)

그리고 메시지 기반 처리에서 흔히 말하는 “at-least-once 전달”은, 결국 중복 이벤트 를 전제로 합니다. 즉, 보상을 재시도할수록 중복 실행 위험은 커집니다.

따라서 목표는 단순합니다.

  • 보상은 무조건 재시도 가능 해야 한다
  • 재시도 중에도 결과가 한 번만 적용 되도록 만들어야 한다 (멱등성)

핵심 설계 1: 보상도 "상태 머신"으로 모델링하라

보상 실패 재처리를 안정화하려면, "보상 이벤트를 재발행한다" 수준이 아니라 Saga 인스턴스의 상태 머신을 먼저 고정해야 합니다.

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

  • STARTED
  • STEP_N_DONE
  • FAILED_AT_STEP_N
  • COMPENSATING_FROM_STEP_N
  • COMPENSATED
  • COMPENSATION_FAILED_AT_STEP_K
  • MANUAL_INTERVENTION_REQUIRED

중요한 점은 COMPENSATION_FAILED_AT_STEP_K 같은 상태를 “장애”로만 보지 말고, 재시도 가능한 정상 상태로 취급하는 것입니다. 그래야 재처리 로직이 명확해집니다.

상태 전이를 DB에 "원자적으로" 기록

오케스트레이터(또는 코레오그래피의 각 서비스)가 다음 두 작업을 분리하면 레이스가 생깁니다.

  1. 상태 업데이트
  2. 메시지 발행

이때 흔히 쓰는 해법이 Outbox 패턴입니다.

  • 트랜잭션 안에서 saga_state 업데이트와 outbox 레코드 insert를 함께 처리
  • 별도 퍼블리셔가 outbox를 읽어 브로커로 발행

이 구조가 보상 재처리의 기반이 됩니다. “상태는 갱신됐는데 메시지가 안 나감” 또는 “메시지는 나갔는데 상태가 안 바뀜”을 줄입니다.


핵심 설계 2: 보상 작업의 멱등성 키를 표준화하라

중복 방지의 핵심은 “중복 이벤트를 막는다”가 아니라, 중복 실행돼도 결과가 동일하도록 만든다에 가깝습니다.

어떤 키로 멱등성을 잡을까

보상은 보통 “원 트랜잭션의 반대 동작”이므로, 다음 조합이 실무에서 잘 동작합니다.

  • sagaId (Saga 인스턴스)
  • stepName 또는 stepIndex
  • action (예: COMPENSATE)

즉 멱등성 키는 sagaId:stepName:COMPENSATE 같은 형태가 됩니다.

멱등성 저장소는 어디에 둘까

선택지는 크게 3가지입니다.

  1. 각 서비스 로컬 DBidempotency 테이블
  2. 비즈니스 테이블에 유니크 제약으로 흡수 (예: 환불 테이블에 saga_id 유니크)
  3. Redis 같은 외부 저장소

운영 안정성 기준으로는 1 또는 2가 가장 단단합니다. Redis는 TTL, 장애 시 유실, 재처리 시점에 키가 사라지는 문제를 반드시 고려해야 합니다.


구현 예시: PostgreSQL 기반 멱등 보상 처리

아래는 보상 컨슈머(예: 결제 취소)가 중복 메시지를 받아도 한 번만 적용되도록 만드는 전형적인 패턴입니다.

테이블 예시

CREATE TABLE compensation_dedup (
  dedup_key text PRIMARY KEY,
  saga_id text NOT NULL,
  step_name text NOT NULL,
  status text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

-- status 예: STARTED, DONE, FAILED

처리 로직(의사 코드)

// dedupKey 예: `${sagaId}:${stepName}:COMPENSATE`
async function handleCompensation(msg) {
  const { sagaId, stepName, payload } = msg
  const dedupKey = `${sagaId}:${stepName}:COMPENSATE`

  await db.tx(async (tx) => {
    // 1) 멱등성 레코드 선점
    const inserted = await tx.query(
      `INSERT INTO compensation_dedup (dedup_key, saga_id, step_name, status)
       VALUES ($1, $2, $3, 'STARTED')
       ON CONFLICT (dedup_key) DO NOTHING`,
      [dedupKey, sagaId, stepName]
    )

    // 이미 처리중/처리완료면 조용히 종료
    if (inserted.rowCount === 0) {
      return
    }

    // 2) 실제 보상 실행 (외부 API, 내부 업데이트 등)
    await cancelPayment(payload)

    // 3) 완료 마킹
    await tx.query(
      `UPDATE compensation_dedup
       SET status = 'DONE', updated_at = now()
       WHERE dedup_key = $1`,
      [dedupKey]
    )
  })
}

이 패턴의 장점은 명확합니다.

  • 메시지가 중복으로 들어와도 INSERT ... ON CONFLICT DO NOTHING에서 걸러짐
  • “처리 중” 상태를 남기므로, 장애 시 재처리 판단 근거가 생김

주의할 점도 있습니다.

  • STARTED로 선점 후 프로세스가 죽으면 레코드가 영원히 STARTED로 남을 수 있음
  • 따라서 “오래된 STARTED를 재시도 대상으로 돌리는” 운영 로직이 필요

핵심 설계 3: 재처리 전략은 "즉시 재시도"와 "지연 재시도"를 분리

보상 실패는 대부분 일시적인 네트워크/다운스트림 장애로 발생하지만, 일부는 영구 실패입니다.

  • 즉시 재시도: 짧은 간격, 적은 횟수 (예: 3회)
  • 지연 재시도: 지수 백오프, 최대 지연, 상한선 (예: 5분, 30분, 2시간)
  • 최종 실패: MANUAL_INTERVENTION_REQUIRED로 전환

지수 백오프 설계 팁

  • 백오프는 “서비스 보호”가 목적이므로 지터(jitter) 를 섞어야 합니다
  • 예: delay = base * 2^n + random(0..base)

브로커가 재시도 기능을 제공하면 활용하고, 없다면 retry_at 컬럼을 둔 재시도 큐 테이블을 운용하는 방식도 흔합니다.


핵심 설계 4: 보상은 "정확히 한 번"이 아니라 "최종적으로 한 번"을 목표로

많은 팀이 분산 환경에서 “exactly-once”를 목표로 삼다가 설계가 과도하게 복잡해집니다. 실무적으로는 다음 조합이 더 현실적입니다.

  • 전달은 at-least-once
  • 처리는 멱등성으로 흡수
  • 상태는 상태 머신으로 추적
  • 관측 가능성으로 운영에서 수습

즉, 최종적으로 한 번만 반영되는 효과(effectively-once) 를 목표로 두는 편이 안전합니다.


운영에서 자주 터지는 케이스와 대응

1) 보상 순서가 꼬이는 문제

보상은 보통 “역순”으로 실행돼야 합니다. 그런데 메시지 재전송이나 파티션 재할당으로 순서가 깨질 수 있습니다.

대응:

  • 오케스트레이터가 보상 순서를 강제하고, 다음 스텝 보상은 이전 보상이 DONE일 때만 발행
  • 코레오그래피라면 각 스텝 보상 이벤트에 compensationIndex를 두고, 컨슈머가 선행 조건을 확인

2) 보상이 비즈니스적으로 불가능해지는 문제

예: 재고 복원을 하려는데 이미 다른 프로세스가 재고 정책을 바꿔 복원이 불가능.

대응:

  • 보상은 “완전 복구”가 아니라 “대안 처리”일 수 있음을 계약에 명시
  • COMPENSATION_FAILED를 단순 재시도가 아니라 “업무적 예외”로 분류
  • 이 경우 자동 재시도는 오히려 비용만 증가하므로 빠르게 MANUAL_INTERVENTION_REQUIRED

3) 멱등성 키 설계 실수

sagaId만으로 키를 잡으면, 같은 Saga 안의 여러 보상이 서로를 막아버립니다.

대응:

  • 반드시 sagaId + step 단위로 분해
  • 가능하면 action도 포함해 확장성 확보

관측성: 재처리·중복방지는 "로그"가 아니라 "지표"로 운영해야 한다

보상 실패는 로그만으로 추적하면 늦습니다. 최소한 아래 지표는 대시보드로 올려야 합니다.

  • 보상 처리 성공/실패 카운트
  • 재시도 횟수 분포(평균, p95)
  • STARTED로 오래 머무는 멱등성 레코드 수
  • Saga 상태별 적체량(COMPENSATION_FAILED 증가 추세)

배포 과정에서 이벤트 컨슈머가 재시작되며 중복 처리가 증가하는 경우가 많으니, 무중단 배포 체계도 함께 점검하는 것이 좋습니다.


체크리스트: 보상 실패 재처리·중복방지 최소 요건

설계

  • Saga 상태 머신에 COMPENSATION_FAILED를 정상 상태로 포함
  • Outbox로 상태 업데이트와 메시지 발행의 불일치 최소화
  • 보상 단계별 멱등성 키 표준화(sagaId:step:action)

구현

  • DB 유니크 제약 기반 선점(INSERT ... ON CONFLICT DO NOTHING)
  • STARTED 장기 체류 레코드에 대한 재처리 정책(타임아웃, 재발행)
  • 백오프 + 지터 적용

운영

  • 보상 실패율, 재시도 분포, 상태 적체량을 지표화
  • 최종 실패는 빠르게 수동 개입 큐로 전환

결론

Saga에서 “보상 실패”는 예외 케이스가 아니라 정상적으로 발생하는 운영 이벤트입니다. 따라서 해법도 단발성 예외 처리보다, 상태 머신 + Outbox + 멱등성 + 재시도 정책 + 관측성의 조합으로 시스템화해야 합니다.

특히 중복 방지는 브로커 설정만으로 해결되지 않습니다. 중복은 언젠가 반드시 발생한다고 가정하고, 보상이 중복 실행돼도 결과가 한 번만 반영되도록(멱등) 만드는 것이 가장 강력한 방어선입니다.

Saga 설계를 더 넓은 관점에서 정리한 글은 아래를 함께 참고하면, 보상 재처리 흐름을 전체 아키텍처에 자연스럽게 녹일 수 있습니다.