- Published on
MSA Saga 보상 트랜잭션 꼬임 디버깅 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 서비스가 각자 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_events에 event_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가지
운영 사례에서 반복되는 실수들을 체크리스트로 정리합니다.
보상 커맨드에 멱등키가 없다
refund(orderId)같은 API는 재시도 시 2회 환불 위험- 해결:
refund(orderId, commandId)+ commandId 저장
보상은 성공했는데 Saga 오케스트레이터가 실패로 기록
- 타임아웃이 원인인 경우가 많음
- 해결: 상태 전이를 “요청/확정”으로 분리(예: REFUND_REQUESTED, REFUND_CONFIRMED)
이벤트 스키마에 step/intent가 없다
- “PAYMENT_UPDATED” 같은 범용 이벤트는 해석이 갈림
- 해결:
PAYMENT_CAPTURED,PAYMENT_COMPENSATION_SUCCEEDED처럼 의도가 드러나게
동일 sagaId가 여러 파티션으로 흩어진다
- ordering 보장 붕괴
- 해결: 파티션 키를 sagaId로 고정
DLQ를 쌓아두고 안 본다
- 보상 미실행이 누적
- 해결: DLQ 알람 + 재처리 도구 + 사유(스키마 불일치/비즈니스 거부) 분류
컨슈머 장애로 backlog가 폭증한다
- 지연 보상으로 현재 흐름과 충돌
- 해결: HPA/리소스 상향 + 장애 원인 제거. 컨테이너가 반복 재시작이면 CrashLoop 원인부터 제거해야 합니다(Kubernetes CrashLoopBackOff·OOMKilled 원인과 해결).
7) 실제 디버깅 절차: “한 건”을 끝까지 추적하는 10단계
운영에서 신고가 들어오면, 아래 순서대로 보면 감으로 때려맞추는 시간을 줄일 수 있습니다.
- 대표 케이스 sagaId 1개를 고른다(가장 최근/재현 쉬운 것)
- 오케스트레이터/코레오그래피 참여 서비스들의 로그를
sagaId로 필터 - 타임라인을 만든다(언제 어떤 step이 성공/실패/보상됐는지)
- 같은
eventId/commandId가 2번 이상 처리됐는지 확인(중복 여부) - 보상 이벤트가 “발행은 됐는데 소비가 안 됐는지” 확인
- outbox에 남아있나?
- 브로커 토픽에 적체가 있나?
- 컨슈머 그룹 lag이 증가했나?
- ordering 의심 시, 해당 메시지의 파티션/키를 확인(sagaId로 묶였는지)
- 상태 테이블(saga_state)에서 전이 이력을 확인(버전 점프/롤백 흔적)
- 보상 API가 외부 결제/배송 같은 3rd party라면, 그쪽의 idempotency 키 지원 여부 확인
- 재처리 전략 결정
- “재발행”이 안전한가? (멱등이 보장될 때)
- “수동 정정”이 필요한가? (이미 돈이 움직였을 때)
- 사후 방지: 멱등키/상태머신/아웃박스/관측성 중 무엇이 빠졌는지 체크리스트로 반영
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 기준으로 타임라인을 재구성할 수 있는 로그/트레이싱부터 갖추는 것이 가장 빠른 투자 대비 효과를 줍니다.