- Published on
MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스/쿠버네티스 환경에서 MSA를 운영하다 보면, 분산 트랜잭션을 피하기 위해 Saga(사가) 패턴을 도입하는 경우가 많습니다. 그런데 막상 적용해도 중복 실행(duplicate execution), 보상처리 중복/누락(compensation bug), 순서 뒤집힘(out-of-order) 같은 장애가 계속 발생합니다.
핵심은 “Saga 패턴을 썼다”가 아니라, 메시지 전달의 특성(at-least-once) 과 서비스의 재시도/타임아웃/크래시를 전제로 한 구현 규율을 지켰는지입니다. 이 글은 “왜 중복이 생기는가”를 재현 가능한 형태로 쪼개고, 실제 코드/스키마 수준에서 버그를 제거하는 방법을 다룹니다.
> 참고: 워커/큐 기반 비동기 처리에서 중복 실행이 폭발하는 케이스는 Saga와 결합되면 더 치명적입니다. Celery/Redis 조합에서 유령 작업과 무한 재시도가 중복 실행을 부르는 원인은 아래 글과 결이 같습니다. Redis 기반 Celery 유령 작업 근절하기
Saga에서 “중복 실행”이 기본값인 이유
대부분의 실전 MSA는 이벤트/메시지 기반이며, 브로커(Kafka, SQS, RabbitMQ 등) 또는 HTTP 재시도(클라이언트/게이트웨이)가 개입합니다. 이때 전달 보장은 종종 at-least-once입니다.
- 소비자가 처리 중 크래시 → 브로커가 재전달
- 네트워크 타임아웃 → 생산자가 “실패로 간주”하고 재발행
- 오케스트레이터가 상태 저장에 실패했지만 커맨드는 이미 발행됨
- 쿠버네티스 재시작/스케일링으로 동일 이벤트가 여러 인스턴스에 분산 처리
즉, “중복 이벤트/커맨드”는 예외가 아니라 정상 상태입니다. Saga에서 버그가 되는 지점은 다음 두 가지입니다.
- 로컬 트랜잭션이 멱등하지 않다 (같은 단계가 두 번 적용됨)
- 보상도 멱등하지 않다 (취소가 두 번 적용되거나, 취소가 누락됨)
가장 흔한 실패 시나리오 5가지
아래는 현장에서 자주 나오는 “사가가 있는데도 터지는” 케이스입니다.
1) Step 실행 성공 후 ACK/상태 기록 전에 크래시
- 결제 승인 API 호출 성공
- 그런데 소비자가 ACK를 못하고 죽음
- 브로커가 동일 메시지를 재전달
- 결제가 2번 승인
2) 오케스트레이터의 상태 저장과 커맨드 발행이 원자적이지 않음
- 오케스트레이터가 DB에
STEP=PAYMENT_REQUESTED저장 - 그 다음 커맨드 발행 중 네트워크 오류
- 재시도 로직이 “다시 발행”
- 소비자 입장에선 동일 커맨드가 2번
3) 보상 이벤트가 늦게 도착해 “이미 성공한 흐름”을 되돌림
- 재고 예약 실패로 보상(결제 취소) 발행
- 그런데 잠시 후 재고 예약 성공 이벤트가 늦게 도착
- 오케스트레이터가 상태기계 없이 처리하면 성공과 취소가 교차
4) Out-of-order(순서 뒤집힘)
PaymentApproved가OrderCreated보다 먼저 도착- 단순 소비 로직이면 “주문이 없는데 결제 승인됨” 상태 발생
5) 타임아웃 기반 보상과 실제 성공이 경합
- 오케스트레이터가 30초 내 응답 없으면 보상 처리
- 31초에 실제 성공 이벤트 도착
- 보상과 성공이 동시에 적용되며 데이터 불일치
해결 전략의 큰 그림: 멱등성 + 상태기계 + 원자적 발행
Saga 구현에서 중복/보상 버그를 제거하려면 다음 3가지를 세트로 가져가야 합니다.
- 모든 Step/Compensation을 멱등하게
- Saga 인스턴스별 상태기계(State Machine)로 유효 전이만 허용
- DB 트랜잭션과 메시지 발행을 Outbox로 원자화
이 중 하나라도 빠지면, 재시도/중복 전달 환경에서 언젠가 깨집니다.
1) Step을 멱등하게 만드는 실전 패턴
멱등성은 “같은 요청이 여러 번 와도 결과가 1번만 적용”되는 성질입니다. Saga에서는 각 Step마다 idempotency key가 있어야 합니다.
권장 키 설계
saga_id: 사가 인스턴스 식별자 (주문 ID 등)step_name: PAYMENT, INVENTORY, SHIPPING 등command_id: 오케스트레이터가 생성한 커맨드 식별자(권장)
가장 안전한 방식은 command_id 단위로 “처리됨”을 기록하는 것입니다.
예시: 결제 서비스에서 중복 승인 방지 (PostgreSQL)
-- 커맨드 처리 이력(멱등성 테이블)
CREATE TABLE processed_commands (
command_id UUID PRIMARY KEY,
saga_id UUID NOT NULL,
step_name TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 결제(예시)
CREATE TABLE payments (
payment_id UUID PRIMARY KEY,
saga_id UUID NOT NULL,
amount NUMERIC NOT NULL,
status TEXT NOT NULL,
UNIQUE (saga_id) -- 주문(사가)당 1회 결제만 허용하는 모델이라면
);
# pseudo-code (Python)
def handle_payment_authorize(cmd):
# cmd: {command_id, saga_id, amount}
with db.transaction() as tx:
# 1) 이미 처리한 커맨드인지 확인 (락/유니크로 보장)
inserted = tx.execute(
"""
INSERT INTO processed_commands(command_id, saga_id, step_name)
VALUES (%s, %s, 'PAYMENT_AUTHORIZE')
ON CONFLICT (command_id) DO NOTHING
""",
[cmd.command_id, cmd.saga_id]
).rowcount
if inserted == 0:
# 중복 수신 → 안전하게 ACK
return
# 2) 로컬 트랜잭션(결제 승인/레코드 생성)
tx.execute(
"""
INSERT INTO payments(payment_id, saga_id, amount, status)
VALUES (gen_random_uuid(), %s, %s, 'AUTHORIZED')
ON CONFLICT (saga_id) DO NOTHING
""",
[cmd.saga_id, cmd.amount]
)
# 3) 이벤트 발행은 Outbox로(아래 섹션에서)
핵심은 처리 이력 테이블 또는 도메인 테이블의 유니크 제약으로 “중복 수신을 DB가 막게” 하는 것입니다. 애플리케이션 레벨 캐시/락은 장애 시 깨지기 쉽습니다.
2) 보상(Compensation)은 “취소”가 아니라 “역연산”이며, 이것도 멱등해야 함
보상은 단순히 cancel()을 호출하는 게 아닙니다. 이미 부분 성공한 단계만 되돌려야 하고, 중복 실행되어도 안전해야 합니다.
보상 멱등성 체크리스트
- 취소 대상이 존재하지 않으면 성공으로 간주(이미 취소되었거나 생성되지 않았을 수 있음)
- 취소 API는
idempotency_key를 받거나, 내부적으로command_id를 기록 - 상태 전이를 제한:
AUTHORIZED -> CANCELED만 허용
ALTER TABLE payments ADD CONSTRAINT payment_status_check
CHECK (status IN ('AUTHORIZED','CAPTURED','CANCELED'));
def handle_payment_compensate(cmd):
# cmd: {command_id, saga_id}
with db.transaction() as tx:
inserted = tx.execute(
"""
INSERT INTO processed_commands(command_id, saga_id, step_name)
VALUES (%s, %s, 'PAYMENT_COMPENSATE')
ON CONFLICT (command_id) DO NOTHING
""",
[cmd.command_id, cmd.saga_id]
).rowcount
if inserted == 0:
return
# 상태 기반 멱등 보상
tx.execute(
"""
UPDATE payments
SET status='CANCELED'
WHERE saga_id=%s
AND status IN ('AUTHORIZED')
""",
[cmd.saga_id]
)
# 이미 CANCELED/CAPTURED면 정책 결정 필요:
# - CAPTURED까지 갔다면 refund로 다른 보상 step이 필요할 수 있음
여기서 중요한 포인트: 보상은 “단계별로 정확히 무엇을 되돌릴지”가 모델링되어야 합니다. 예를 들어 결제가 CAPTURED까지 갔다면 CANCELED로 바꾸는 게 아니라 REFUND_REQUESTED/REFUNDED 같은 별도 보상 흐름이 필요합니다.
3) Saga 오케스트레이터는 상태기계로 유효 전이만 처리해야 함
오케스트레이터가 “이벤트 오면 다음 단계” 수준이면 out-of-order, 중복 이벤트, 늦은 이벤트에 취약합니다. 사가 인스턴스 상태를 DB에 저장하고, 이벤트 처리 시 현재 상태에서 허용되는 전이만 수행해야 합니다.
상태 모델 예시
STARTEDPAYMENT_REQUESTED→PAYMENT_DONEINVENTORY_REQUESTED→INVENTORY_DONECOMPLETED- 실패 시
COMPENSATING→COMPENSATED
CREATE TABLE saga_instances (
saga_id UUID PRIMARY KEY,
state TEXT NOT NULL,
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALLOWED = {
'STARTED': {'OrderCreated': 'PAYMENT_REQUESTED'},
'PAYMENT_REQUESTED': {'PaymentApproved': 'INVENTORY_REQUESTED', 'PaymentRejected': 'COMPENSATING'},
'INVENTORY_REQUESTED': {'InventoryReserved': 'COMPLETED', 'InventoryFailed': 'COMPENSATING'},
'COMPENSATING': {'CompensationDone': 'COMPENSATED'},
}
def on_event(evt):
with db.transaction() as tx:
saga = tx.query_one("SELECT state, version FROM saga_instances WHERE saga_id=%s FOR UPDATE", [evt.saga_id])
next_state = ALLOWED.get(saga.state, {}).get(evt.type)
if not next_state:
# 중복/늦은/순서 뒤집힘 이벤트 → 무시(또는 DLQ로)
return
tx.execute(
"UPDATE saga_instances SET state=%s, version=version+1, updated_at=now() WHERE saga_id=%s",
[next_state, evt.saga_id]
)
# 다음 커맨드 발행은 Outbox로
FOR UPDATE로 사가 인스턴스를 잠그면, 동일 saga_id에 대한 동시 이벤트 처리 경쟁도 줄일 수 있습니다(물론 처리량 요구가 높다면 샤딩/파티셔닝을 고려).
4) Outbox 패턴으로 “상태 저장 + 메시지 발행”을 원자화
중복 실행의 뿌리 중 하나는 DB 커밋은 됐는데 메시지 발행이 실패하거나, 그 반대가 되는 것입니다. Outbox는 이를 해결합니다.
Outbox 테이블
CREATE TABLE outbox (
outbox_id UUID PRIMARY KEY,
aggregate_id UUID NOT NULL,
topic 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;
오케스트레이터에서 상태 변경과 메시지 적재를 같은 트랜잭션으로
def transition_and_enqueue(tx, saga_id, next_state, topic, payload):
tx.execute(
"UPDATE saga_instances SET state=%s, version=version+1, updated_at=now() WHERE saga_id=%s",
[next_state, saga_id]
)
tx.execute(
"INSERT INTO outbox(outbox_id, aggregate_id, topic, payload) VALUES (gen_random_uuid(), %s, %s, %s)",
[saga_id, topic, json.dumps(payload)]
)
# 별도 퍼블리셔 프로세스/스레드가 outbox를 폴링/스트리밍하여 브로커로 발행
퍼블리셔는 published_at IS NULL 레코드를 가져와 발행 후 published_at을 찍습니다. 이때도 재시도/중복 발행이 가능하므로, 소비자는 앞서 말한 멱등성으로 방어합니다.
5) “중복 보상”과 “보상 누락”을 잡는 운영 관점 체크리스트
구현을 잘해도 운영 환경(쿠버네티스, 오토스케일링, 프록시/ALB 타임아웃)에서 재시도가 튀어나오면 중복이 늘어납니다. 다음을 함께 점검해야 합니다.
타임아웃/재시도 설계
- 오케스트레이터 타임아웃 < 서비스의 최대 처리시간이면 성공과 보상이 경합합니다.
- HTTP 재시도는 반드시 idempotency key와 함께.
- 브로커 소비 재시도는 DLQ(Dead Letter Queue)와 함께.
쿠버네티스에서 “처리 중 종료” 문제
워커/컨슈머가 SIGTERM을 받고 종료되면서 ACK를 못해 중복이 발생할 수 있습니다. 종료 그레이스/프리-스톱 훅/프로브 설정이 중요합니다.
- Kubernetes Pod가 Terminating에 멈출 때 - finalizer·grace·SIGTERM 실전 디버깅
- Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅
특히 CrashLoopBackOff로 컨슈머가 반복 재시작하면, “처리 중 죽음 → 재전달”이 상시 발생해 Saga 중복이 폭발합니다.
관측(Observability): 사가 단위 상관관계 필수
- 모든 로그/트레이스에
saga_id,command_id,step_name을 포함 - 사가 상태 전이를 이벤트로 남겨 “어디서 분기/역주행했는지” 확인
- 보상 실행 횟수, 중복 이벤트 무시 횟수 같은 지표를 메트릭화
구현 선택지: 오케스트레이션 vs 코레오그래피
- 오케스트레이션: 중앙 오케스트레이터가 상태기계를 관리. 상태/전이 통제가 쉬워 “보상 버그”를 줄이기 좋습니다.
- 코레오그래피: 서비스들이 이벤트로 반응. 결합도는 낮지만, 전역 상태기계가 분산되어 중복/순서/보상 문제가 더 난이도 높습니다.
중복 실행·보상처리 버그를 빠르게 안정화해야 한다면, 실무적으로는 오케스트레이션 + Outbox + 멱등성이 가장 예측 가능합니다.
마무리: Saga 버그를 없애는 최소 조건
중복 실행과 보상처리 버그를 “운으로” 피하는 방법은 없습니다. 다음 3가지를 최소 조건으로 보세요.
- 모든 커맨드/이벤트는 중복될 수 있다를 전제로 소비 로직을 멱등하게 설계
- 오케스트레이터는 DB 기반 상태기계로 유효 전이만 허용(늦은/중복/역순 이벤트 무시)
- 상태 저장과 메시지 발행은 Outbox로 원자화
이 3가지를 갖추면, 재시도/크래시/네트워크 흔들림이 있어도 Saga는 “언젠가 맞는 상태로 수렴”하는 시스템이 됩니다. 반대로 하나라도 빠지면, 트래픽이 늘거나 장애가 한 번만 나도 중복 결제/중복 취소 같은 치명적인 사고로 이어집니다.