Published on

MSA Saga 패턴 - 보상 트랜잭션 실패 디버깅

Authors

서로 다른 마이크로서비스가 각자 로컬 트랜잭션을 커밋하는 MSA에서, 분산 트랜잭션의 대안으로 가장 많이 언급되는 것이 Saga 패턴입니다. 하지만 운영에서 진짜 어려운 지점은 성공 경로가 아니라 실패 경로, 특히 보상 트랜잭션(compensation) 이 실패하는 순간입니다. 보상이 실패하면 사용자는 환불이 안 되거나 재고가 복구되지 않거나, 예약이 취소되지 않는 등 “되돌릴 수 없는 상태”가 남습니다.

이 글은 보상 트랜잭션 실패를 디버깅할 때 어디부터 확인해야 하는지, 어떤 로그와 메트릭이 필요하며, 어떤 설계 결함이 실패를 반복시키는지 실전 관점에서 정리합니다. 더 확장된 체크리스트가 필요하다면 MSA Saga 보상 트랜잭션 실패 디버깅 가이드도 함께 참고하세요.

Saga에서 보상이 실패하는 전형적인 시나리오

Saga는 크게 오케스트레이션과 코레오그래피로 나뉘지만, 보상 실패의 형태는 비슷합니다.

1) 보상 자체가 실패한다

  • 네트워크 타임아웃, 5xx, DNS 문제
  • 다운스트림 서비스의 DB 커넥션 고갈, 스레드 풀 고갈
  • 권한 문제, 서명 만료, 토큰 만료

2) 보상은 성공했는데 “성공으로 기록되지 않는다”

  • 메시지 브로커 ack 이전에 프로세스가 죽음
  • outbox 이벤트 발행 실패
  • 중복 소비로 인해 보상이 여러 번 호출되어 부작용 발생

3) 보상이 논리적으로 불가능해진다

  • 재고 복구는 가능한데 결제 취소는 이미 정산됨
  • 예약 취소는 가능하지만 숙소 정책상 취소 불가 시점이 지남
  • “보상”이 아니라 “대체 조치”가 필요한데 단순 롤백으로 처리하려 함

이 중 2번이 특히 까다롭습니다. 실제로는 보상이 수행됐는데, 시스템은 실패로 간주해 재시도를 반복하고, 그 재시도가 또 다른 장애를 만듭니다.

디버깅의 출발점: Saga 인스턴스 단위로 재현 가능한 타임라인 만들기

보상 실패를 디버깅할 때 가장 먼저 해야 할 일은 “한 건의 Saga 인스턴스”를 골라 타임라인을 복원하는 것입니다.

필수 식별자

  • sagaId: Saga 인스턴스 식별자
  • step: 현재 단계 또는 액션 이름
  • commandId 또는 messageId: 명령 메시지 식별자
  • correlationId / traceId: 분산 추적 연결 키

권장 로그 포맷 예시(구조화 로그)

{
  "ts": "2026-02-24T11:02:13.120Z",
  "level": "ERROR",
  "service": "order-orchestrator",
  "sagaId": "saga_8f3b...",
  "step": "PAYMENT_COMPENSATE",
  "messageId": "msg_19c1...",
  "traceId": "trace_7aa1...",
  "error": {
    "type": "TimeoutError",
    "message": "payment-service compensate timed out",
    "retry": 3
  }
}

여기서 중요한 점은 sagaIdtraceId 가 모든 서비스 로그에 같이 찍혀야 한다는 것입니다. 하나라도 빠지면 “보상 호출은 어디서 시작됐고 어디서 끊겼는지”를 한 화면에서 연결하기가 어렵습니다.

실패 유형별 디버깅 체크리스트

A. 네트워크/타임아웃/일시 장애로 보상이 실패하는 경우

증상

  • 오케스트레이터 로그에 timeout 또는 ECONNRESET
  • 보상 API는 간헐적으로 5xx
  • 재시도 폭주로 장애가 확대

확인 순서

  1. 분산 추적에서 보상 호출 span을 찾고, 지연 구간이 클라이언트인지 서버인지 확인
  2. 보상 대상 서비스의 p95/p99 latency, 에러율, 동시성, DB 커넥션 사용률 확인
  3. 재시도 정책이 “동기 재시도”로 묶여 있지 않은지 확인

대응 포인트

  • 보상은 사용자 요청 경로에서 분리해 비동기로 처리하는 것이 안전합니다.
  • 재시도는 지수 백오프와 지터를 적용하고, 서킷 브레이커를 반드시 둡니다.

재시도·서킷 브레이커 의사코드 예시(TypeScript)

type Result<T> = { ok: true; value: T } | { ok: false; error: Error };

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  opts: { maxAttempts: number; baseMs: number }
): Promise<Result<T>> {
  for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
    try {
      const value = await fn();
      return { ok: true, value };
    } catch (e) {
      const err = e as Error;
      if (attempt === opts.maxAttempts) return { ok: false, error: err };
      const jitter = Math.floor(Math.random() * 100);
      const sleepMs = opts.baseMs * 2 ** (attempt - 1) + jitter;
      await new Promise((r) => setTimeout(r, sleepMs));
    }
  }
  return { ok: false, error: new Error("unreachable") };
}

외부 API 장애 대응 패턴은 LLM API 같은 외부 의존성에서도 동일하게 적용됩니다. 재시도·폴백·서킷 브레이커 실전 예시는 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커도 참고할 만합니다.

B. 멱등성(idempotency) 부재로 보상이 “실패처럼 보이는” 경우

증상

  • 보상 요청을 재시도할수록 상태가 더 꼬임
  • 409 Conflict, Already canceled 같은 에러가 보상 실패로 기록됨
  • 같은 sagaId 로 보상 이벤트가 여러 번 처리됨

핵심 원인

  • 보상은 거의 항상 “중복 실행될 수 있다”는 가정 위에서 설계해야 합니다.
  • 따라서 보상 API는 멱등 키(idempotency key) 를 받아서, 같은 키로 들어온 요청은 같은 결과를 반환해야 합니다.

멱등성 테이블 기반 예시(SQL)

-- payment_compensation_dedup
-- idempotency_key: sagaId + step 조합
CREATE TABLE payment_compensation_dedup (
  idempotency_key VARCHAR(128) PRIMARY KEY,
  status VARCHAR(16) NOT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

처리 로직 의사코드

async function compensatePayment(input: { sagaId: string; amount: number }) {
  const key = `${input.sagaId}:PAYMENT_COMPENSATE`;

  // 1) dedup row를 먼저 확보 (유니크 제약으로 중복 방지)
  const existing = await dedupRepo.find(key);
  if (existing?.status === "DONE") return { status: "DONE" };

  if (!existing) {
    await dedupRepo.insert({ key, status: "IN_PROGRESS" });
  }

  // 2) 외부 환불 호출 (중복 호출되면 안 되므로 키를 전달)
  await paymentGateway.refund({ idempotencyKey: key, amount: input.amount });

  // 3) 성공 기록
  await dedupRepo.update({ key, status: "DONE" });
  return { status: "DONE" };
}

디버깅 팁

  • 보상 실패 로그가 409 류라면 “실패”가 아니라 “이미 처리됨”일 수 있습니다.
  • 이 경우 오케스트레이터는 해당 에러를 성공으로 매핑하거나, 보상 상태를 조회해 최종 DONE 으로 수렴시키는 로직이 필요합니다.

C. 순서 보장 실패로 보상이 잘못된 상태에서 실행되는 경우

증상

  • 보상 이벤트가 원 액션 이벤트보다 먼저 처리됨
  • 같은 Saga의 step 이벤트가 뒤섞임

원인 후보

  • 브로커 토픽 파티셔닝 키가 sagaId 가 아님
  • consumer group 리밸런싱 동안 중복 처리
  • outbox 발행 순서와 소비 순서가 다름

대응

  • 이벤트 파티션 키를 sagaId 로 고정해 “Saga 단위 순서”를 확보
  • step별 상태 머신을 두고, 허용되지 않은 전이는 reject 또는 지연 처리

상태 머신 검증 의사코드

const allowed: Record<string, Set<string>> = {
  START: new Set(["RESERVE_STOCK_OK", "RESERVE_STOCK_FAIL"]),
  RESERVE_STOCK_OK: new Set(["PAYMENT_OK", "PAYMENT_FAIL"]),
  PAYMENT_FAIL: new Set(["STOCK_COMPENSATED"])
};

function assertTransition(current: string, next: string) {
  const ok = allowed[current]?.has(next);
  if (!ok) throw new Error(`invalid transition: ${current} -> ${next}`);
}

위 코드에서 화살표 기호는 MDX에서 오인될 수 있으니 반드시 인라인 코드로 처리했습니다.

D. 리소스 고갈로 보상이 연쇄 실패하는 경우

보상은 보통 장애 상황에서 몰립니다. 즉, 평소에는 거의 안 돌다가, 장애 시점에 트래픽이 폭증합니다. 이때 DB 커넥션 풀이나 스레드 풀이 한계에 먼저 도달합니다.

증상

  • 보상 서비스에서 DB timeout, 커넥션 획득 지연
  • 오케스트레이터가 재시도하며 더 많은 부하 유발

확인

  • DB 커넥션 풀 사용률, 대기 큐 길이, 쿼리 지연
  • 보상 작업이 “긴 트랜잭션”으로 묶여 있는지

대응

  • 보상은 가능한 한 짧은 트랜잭션으로
  • 보상 워커의 동시성 제한(토큰 버킷, 큐 기반)
  • DB 풀 튜닝 및 쿼리 최적화

Spring 계열이라면 커넥션 고갈 디버깅과 튜닝은 Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드도 같이 보면 빠르게 감을 잡을 수 있습니다.

“보상 실패”를 관측 가능하게 만드는 최소 스키마

운영에서 가장 강력한 디버깅 도구는 결국 데이터입니다. Saga 오케스트레이터 또는 워크플로 엔진에 다음 테이블(또는 문서)을 두면, 장애 시점에 원인을 좁히는 속도가 압도적으로 빨라집니다.

권장 컬럼

  • saga_id
  • current_step
  • status: RUNNING, COMPENSATING, DONE, FAILED
  • last_error_type, last_error_message
  • attempt_count
  • next_retry_at
  • updated_at

예시(SQL)

CREATE TABLE saga_instance (
  saga_id VARCHAR(64) PRIMARY KEY,
  status VARCHAR(16) NOT NULL,
  current_step VARCHAR(64) NOT NULL,
  attempt_count INT NOT NULL DEFAULT 0,
  next_retry_at TIMESTAMP NULL,
  last_error_type VARCHAR(64) NULL,
  last_error_message TEXT NULL,
  updated_at TIMESTAMP NOT NULL
);

디버깅 절차

  1. FAILED 또는 COMPENSATING 상태의 Saga를 쿼리
  2. last_error_type 기준으로 군집화
  3. 상위 1~2개 유형부터 해결

이렇게 하면 “개별 로그를 뒤져서”가 아니라 “실패를 통계로 보고” 우선순위를 잡을 수 있습니다.

보상이 불가능한 도메인: 롤백이 아니라 ‘대체 조치’를 설계해야 한다

모든 액션이 완벽히 되돌릴 수 있는 것은 아닙니다. 예를 들어 결제는 PG 정책이나 정산 상태에 따라 취소가 불가능할 수 있고, 외부 시스템은 이미 irreversible 상태일 수 있습니다.

이때 보상 트랜잭션을 단순히 cancel() 로 모델링하면, 실패가 반복되고 운영자는 수동 처리에 매몰됩니다.

대안 패턴

  • 보상 실패 시 manual_review 상태로 전환하고 운영 툴에서 처리
  • “환불” 대신 “크레딧 지급” 같은 대체 보상 제공
  • 시간 제한이 있는 보상은 next_retry_at 로 재시도 윈도우를 제한

핵심은 “보상 실패는 예외가 아니라 정상 흐름의 일부”로 모델링하는 것입니다.

실전 디버깅 루틴: 30분 안에 원인 좁히기

  1. 특정 sagaId 1건을 골라 타임라인 복원
  2. 실패가 통신 실패 인지 논리 실패 인지 중복/순서 문제인지 분류
  3. 보상 대상 서비스에서 멱등성 키 처리 여부 확인
  4. 브로커 파티션 키가 sagaId 인지 확인
  5. 재시도 정책이 지수 백오프·지터·서킷 브레이커를 갖췄는지 확인
  6. 리소스 고갈 지표(DB 풀, 스레드 풀, 큐 적체)를 확인
  7. 보상이 불가능한 케이스는 도메인 정책으로 분리하고 운영 플로우로 넘김

마무리

Saga에서 보상 트랜잭션 실패는 “운이 나빠서”가 아니라, 대개 관측성 부족, 멱등성 부재, 순서 보장 실패, 과도한 동기 재시도 같은 구조적 원인에서 반복됩니다. 디버깅은 단일 장애를 고치는 작업이 아니라, 실패가 발생해도 최종 상태가 DONE 또는 FAILED 로 수렴하도록 시스템을 설계하는 과정입니다.

운영 환경에서 바로 적용할 체크리스트와 더 많은 케이스 스터디가 필요하다면 MSA Saga 보상 트랜잭션 실패 디버깅 가이드를 함께 읽어보는 것을 권합니다.