Published on

MSA Saga 보상 트랜잭션 꼬임 디버깅 실전

Authors

서로 다른 서비스가 각자 DB를 갖는 MSA에서 **Saga 보상 트랜잭션이 ‘꼬였다’**는 말은 보통 다음 중 하나를 의미합니다.

  • 보상이 실행되지 않아 데이터가 남는다(환불 누락, 재고 미복구).
  • 보상이 두 번 이상 실행되어 과보상된다(환불 2회, 재고 +2).
  • 보상이 역순으로 실행되어 불변식이 깨진다(배송 취소 전에 결제 취소가 되어 정산 시스템 오류).
  • 보상 이벤트가 늦게 도착해 이미 다른 흐름과 충돌한다(재주문 후 늦은 보상으로 재고가 음수).

이 글은 “왜 꼬였는지”를 감이 아니라 증거 기반으로 좁혀가는 디버깅 루틴을 제시합니다. 특히 운영에서 자주 겪는 중복 실행·보상 처리 버그는 아래 글에서 더 넓게 다뤘으니 함께 보면 좋습니다: MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결

1) 먼저 ‘꼬임’의 형태를 분류하라

디버깅을 시작하기 전에, 장애 티켓을 아래 4가지 중 하나로 분류하면 원인 탐색이 빨라집니다.

A. 보상 미실행(Compensation Missing)

  • 증상: 주문 실패했는데 결제는 성공 상태로 남음
  • 흔한 원인: 이벤트 발행 실패(트랜잭션 밖 publish), 컨슈머 장애/재시도 한계, DLQ 미처리

B. 보상 중복(Compensation Duplicate)

  • 증상: 환불 2회, 재고 2회 복구
  • 흔한 원인: at-least-once 메시징 + 멱등성 부재, 타임아웃 후 재시도, 컨슈머 리밸런스 중 중복 처리

C. 보상 역순(Out-of-Order)

  • 증상: “A 보상”보다 “B 보상”이 먼저 실행되어 불변식 파괴
  • 흔한 원인: 파티션 키 설계 오류(동일 saga가 다른 파티션으로 분산), 비동기 처리에서 ordering 미보장

D. 지연 보상(Late Compensation)

  • 증상: 이미 성공한 새 주문에 과거 보상이 늦게 적용
  • 흔한 원인: 지연 재시도, 메시지 적체, 컨슈머 장애(예: CrashLoop)로 backlog 증가

운영에서 D는 인프라 이슈와 결합되는 경우가 많습니다. 컨슈머가 CrashLoop로 계속 죽으면 보상 이벤트 처리 지연이 누적됩니다. 이때는 애플리케이션 로직만 보지 말고 쿠버네티스 상태부터 15분 내로 확인하는 루틴이 필요합니다: Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단

2) “Saga 인스턴스 단위”로 관측 가능하게 만들기

보상 꼬임 디버깅의 핵심은 하나의 Saga 인스턴스(예: orderId=123) 흐름을 끝까지 재구성하는 것입니다.

필수 상관관계 키:

  • sagaId (또는 orderId를 sagaId로 사용)
  • step / state (예: RESERVE_STOCK, CAPTURE_PAYMENT)
  • commandId / eventId (멱등키)
  • causationId, correlationId (OpenTelemetry 권장)
  • version (상태 머신 낙관적 잠금용)

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

{
  "ts": "2026-02-23T10:12:11.120Z",
  "service": "payment",
  "level": "INFO",
  "sagaId": "order-123",
  "step": "CAPTURE_PAYMENT",
  "eventId": "evt-9b1...",
  "commandId": "cmd-77a...",
  "state": "SUCCEEDED",
  "msg": "payment captured"
}

이렇게 찍히면, Kibana/Cloud Logging에서 sagaId=order-123로 필터링해 정상/비정상 순서를 바로 비교할 수 있습니다.

3) 재현 가능한 최소 시나리오를 만든다(타임아웃/중복/역순)

운영에서만 보이는 꼬임은 대부분 타이밍 버그입니다. 로컬/스테이징에서 아래 3가지를 인위적으로 만들어 보세요.

  • 컨슈머 강제 재시작(중복 처리 유도)
  • 네트워크 지연/패킷 드랍(타임아웃 후 재시도 유도)
  • 파티션/큐 라우팅 변경(ordering 깨짐 유도)

Docker Compose로 “중복 이벤트” 재현(간단 예)

아래는 컨슈머가 처리 중 죽었다가 재시작하며 같은 메시지를 다시 처리하는 상황을 흉내 내기 위한 패턴입니다.

# 1) 결제 캡처 이벤트 발행
curl -X POST http://localhost:8080/test/publish \
  -H 'Content-Type: application/json' \
  -d '{"sagaId":"order-123","eventId":"evt-1","type":"PAYMENT_CAPTURED"}'

# 2) 컨슈머 강제 종료(처리 중단)
docker compose restart payment-consumer

# 3) 동일 이벤트 재발행(또는 브로커 redelivery를 기다림)
curl -X POST http://localhost:8080/test/publish \
  -H 'Content-Type: application/json' \
  -d '{"sagaId":"order-123","eventId":"evt-1","type":"PAYMENT_CAPTURED"}'

재현이 되면 이제 “왜 중복이 보상을 꼬이게 만드는지”를 코드/DB 레벨에서 잡을 수 있습니다.

4) 보상 꼬임의 80%: 멱등성 결여 + 상태머신 불명확

보상은 ‘취소 API’가 아니라 상태 전이입니다. 따라서 다음 두 가지가 없으면 거의 반드시 꼬입니다.

  • 멱등성(Idempotency): 같은 이벤트/커맨드를 두 번 처리해도 결과가 1번과 동일해야 함
  • 상태 머신(전이 규칙): 어떤 상태에서 어떤 커맨드만 허용되는지 명시

(1) DB 기반 멱등 처리 테이블

이벤트를 처리하기 전에 processed_eventsevent_id를 insert하고, 이미 있으면 스킵합니다.

CREATE TABLE processed_events (
  consumer_name text NOT NULL,
  event_id text NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_name, event_id)
);
function handleEvent(consumerName, event):
  begin tx
    inserted = INSERT INTO processed_events(consumer_name, event_id)
               VALUES(consumerName, event.eventId)
               ON CONFLICT DO NOTHING
    if inserted == 0:
      commit
      return  # duplicate

    applyBusinessLogic(event)  # may update saga_state, payment, stock...
  commit

핵심은 멱등 기록과 비즈니스 업데이트가 같은 트랜잭션에 있어야 한다는 점입니다.

(2) Saga 상태 테이블 + 버전(낙관적 잠금)

보상 역순/중복이 섞일 때는 상태 경쟁(race)이 발생합니다. version으로 전이를 직렬화하세요.

CREATE TABLE saga_state (
  saga_id text PRIMARY KEY,
  state text NOT NULL,
  version bigint NOT NULL DEFAULT 0,
  updated_at timestamptz NOT NULL DEFAULT now()
);
-- 예: CAPTURED -> COMPENSATING 으로 바꾸는 전이
UPDATE saga_state
SET state = 'COMPENSATING', version = version + 1, updated_at = now()
WHERE saga_id = $1 AND state = 'CAPTURED' AND version = $2;

업데이트 결과 rowcount가 0이면, 이미 다른 워커가 전이했거나 상태가 바뀐 것입니다. 이때는 “재시도”가 아니라 현재 상태를 읽고 noop 처리하는 게 안전합니다.

5) Outbox/Inbox로 ‘발행-저장’ 원자성 보장

보상 미실행의 대표 원인은 “DB 커밋은 됐는데 이벤트 발행이 실패” 혹은 그 반대입니다. 해결책은 고전적이지만 강력합니다.

  • Outbox: 비즈니스 DB 트랜잭션 안에서 이벤트를 outbox 테이블에 저장
  • Relay(Poller/CDC): outbox를 읽어 브로커로 발행
  • Inbox: 컨슈머는 inbox(=processed_events)로 중복 방지

Outbox 테이블 예시

CREATE TABLE outbox (
  id bigserial PRIMARY KEY,
  aggregate_id text NOT NULL,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz
);

CREATE INDEX outbox_unpublished_idx ON outbox(published_at) WHERE published_at IS NULL;

Relay(폴링) 의사코드

loop:
  rows = SELECT * FROM outbox
         WHERE published_at IS NULL
         ORDER BY id
         LIMIT 100
         FOR UPDATE SKIP LOCKED

  for row in rows:
    publish(row.event_type, row.payload, key=row.aggregate_id)
    UPDATE outbox SET published_at = now() WHERE id = row.id

  sleep(200ms)

여기서 key=row.aggregate_id는 ordering 문제를 줄이는 데 중요합니다. 동일 sagaId는 같은 파티션으로 가도록 설계하면 역순 실행이 크게 줄어듭니다.

6) “보상 꼬임”을 만드는 흔한 설계 실수 6가지

운영 사례에서 반복되는 실수들을 체크리스트로 정리합니다.

  1. 보상 커맨드에 멱등키가 없다

    • refund(orderId) 같은 API는 재시도 시 2회 환불 위험
    • 해결: refund(orderId, commandId) + commandId 저장
  2. 보상은 성공했는데 Saga 오케스트레이터가 실패로 기록

    • 타임아웃이 원인인 경우가 많음
    • 해결: 상태 전이를 “요청/확정”으로 분리(예: REFUND_REQUESTED, REFUND_CONFIRMED)
  3. 이벤트 스키마에 step/intent가 없다

    • “PAYMENT_UPDATED” 같은 범용 이벤트는 해석이 갈림
    • 해결: PAYMENT_CAPTURED, PAYMENT_COMPENSATION_SUCCEEDED처럼 의도가 드러나게
  4. 동일 sagaId가 여러 파티션으로 흩어진다

    • ordering 보장 붕괴
    • 해결: 파티션 키를 sagaId로 고정
  5. DLQ를 쌓아두고 안 본다

    • 보상 미실행이 누적
    • 해결: DLQ 알람 + 재처리 도구 + 사유(스키마 불일치/비즈니스 거부) 분류
  6. 컨슈머 장애로 backlog가 폭증한다

7) 실제 디버깅 절차: “한 건”을 끝까지 추적하는 10단계

운영에서 신고가 들어오면, 아래 순서대로 보면 감으로 때려맞추는 시간을 줄일 수 있습니다.

  1. 대표 케이스 sagaId 1개를 고른다(가장 최근/재현 쉬운 것)
  2. 오케스트레이터/코레오그래피 참여 서비스들의 로그를 sagaId로 필터
  3. 타임라인을 만든다(언제 어떤 step이 성공/실패/보상됐는지)
  4. 같은 eventId/commandId가 2번 이상 처리됐는지 확인(중복 여부)
  5. 보상 이벤트가 “발행은 됐는데 소비가 안 됐는지” 확인
    • outbox에 남아있나?
    • 브로커 토픽에 적체가 있나?
    • 컨슈머 그룹 lag이 증가했나?
  6. ordering 의심 시, 해당 메시지의 파티션/키를 확인(sagaId로 묶였는지)
  7. 상태 테이블(saga_state)에서 전이 이력을 확인(버전 점프/롤백 흔적)
  8. 보상 API가 외부 결제/배송 같은 3rd party라면, 그쪽의 idempotency 키 지원 여부 확인
  9. 재처리 전략 결정
    • “재발행”이 안전한가? (멱등이 보장될 때)
    • “수동 정정”이 필요한가? (이미 돈이 움직였을 때)
  10. 사후 방지: 멱등키/상태머신/아웃박스/관측성 중 무엇이 빠졌는지 체크리스트로 반영

8) 보상 트랜잭션 꼬임을 줄이는 코드 레벨 가드레일

마지막으로, 구현 단계에서 효과가 큰 가드레일을 짧게 정리합니다.

  • **보상은 ‘반대 작업’이 아니라 ‘안전한 상태로의 전이’**로 모델링
  • 모든 커맨드/이벤트에 고유 ID(commandId/eventId) 부여
  • 컨슈머는 항상 Inbox(중복 방지) + 상태 전이 조건을 함께 사용
  • 오케스트레이터는 타임아웃 시 “실패 확정”이 아니라 조회/확인(Confirm) 단계로 수렴
  • 브로커 파티션 키는 sagaId 고정
  • DLQ는 “버리는 곳”이 아니라 재처리 파이프라인의 일부로 운영

결론

Saga 보상 트랜잭션이 꼬이는 문제는 대개 “메시징은 최소 1회(at-least-once)인데, 애플리케이션은 정확히 1회(exactly-once)처럼 가정”하면서 시작합니다. 해결은 거창한 프레임워크보다도 (1) Saga 인스턴스 단위 관측성, (2) Inbox/Outbox로 원자성 확보, (3) 멱등성 + 상태머신으로 전이 통제라는 기본기를 얼마나 엄격히 지키느냐에 달려 있습니다.

운영에서 한 번이라도 ‘환불 2번’ 같은 사고를 겪었다면, 지금 당장 commandId/eventId와 inbox/outbox를 점검하고, sagaId 기준으로 타임라인을 재구성할 수 있는 로그/트레이싱부터 갖추는 것이 가장 빠른 투자 대비 효과를 줍니다.