- Published on
Saga 패턴 보상트랜잭션 설계와 중복처리 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 하나의 비즈니스 흐름(주문→결제→재고→배송)을 구성할 때, 분산 트랜잭션(2PC)을 쓰지 않고도 일관성을 맞추는 대표 해법이 Saga 패턴입니다. 하지만 Saga를 “그냥 실패하면 보상 호출” 정도로 구현하면 운영에서 곧바로 문제가 터집니다.
- 네트워크 타임아웃으로 같은 단계가 여러 번 실행됨(중복 결제/중복 예약)
- 보상도 재시도로 여러 번 실행됨(과도 환불/재고 과복구)
- 이벤트가 지연/역순 도착하여 이미 끝난 Saga를 다시 건드림
이 글에서는 보상 트랜잭션을 정확히 무엇으로 모델링해야 하는지, 그리고 중복처리를 어디에서 어떤 키로 막아야 하는지를 설계 관점에서 정리합니다. 결제 같은 민감 도메인을 염두에 두고 설명하며, Outbox/Idempotency/사가 상태머신을 함께 엮어 “실제로 안전한” 구현으로 수렴시키는 것이 목표입니다.
관련해서 Saga 실패로 중복 결제가 발생하는 케이스와 Outbox로 막는 방법은 아래 글도 함께 보면 맥락이 잘 이어집니다: MSA 사가 실패로 중복결제 터질 때 Outbox로 막기
Saga 패턴에서 보상 트랜잭션의 본질
Saga는 전체 트랜잭션을 여러 로컬 트랜잭션으로 쪼개고, 중간에 실패하면 이미 완료된 로컬 트랜잭션을 보상(Compensation) 으로 되돌리는 패턴입니다.
여기서 중요한 전제는 다음과 같습니다.
- 보상은 ‘진짜 롤백’이 아니다
DB 롤백처럼 과거 상태를 완벽히 되돌리는 게 아니라, “비즈니스적으로 수용 가능한 반대 동작”입니다. - 보상은 항상 성공하지 않는다
외부 결제 PG 환불 실패, 배송이 이미 출고됨 등으로 보상이 불가능하거나 지연될 수 있습니다. - 보상은 중복 실행될 수 있다
네트워크 재시도, 메시지 중복 전달(at-least-once), 오케스트레이터 리트라이로 동일 보상 요청이 여러 번 올 수 있습니다.
따라서 보상 트랜잭션은 처음부터 다음 속성을 만족하도록 설계해야 합니다.
- Idempotent(멱등): 같은 요청이 여러 번 와도 결과가 같아야 함
- Commutative(가능하면 교환 가능): 순서가 바뀌어도 안전하도록(현실적으로는 어려워서 상태머신으로 제어)
- Auditable(감사 가능): 어떤 이유로 어떤 보상이 실행되었는지 추적 가능
보상 설계의 3가지 레벨: 취소, 무효화, 상쇄
보상은 “DELETE” 같은 단순 반대 연산으로 끝나지 않습니다. 도메인에 따라 보상 방식이 달라집니다.
1) 취소(Cancel)
아직 외부로 확정되지 않은 상태(예약/홀드)라면 취소가 가장 깔끔합니다.
- 재고:
reserveInventory→cancelReservation - 결제:
authorize(승인 전 홀드) →voidAuthorization
2) 무효화(Invalidate)
이미 확정된 레코드를 지우지 않고 상태를 무효로 바꿉니다.
- 주문:
CONFIRMED→CANCELLED - 쿠폰 사용:
USED→REVOKED
운영에서 데이터 추적이 중요하면 무효화가 기본값인 경우가 많습니다.
3) 상쇄(Offset / Compensating Action)
완전한 취소가 불가능하면 “반대 방향의 거래”로 상쇄합니다.
- 결제 캡처 완료 후:
capturePayment→refundPayment - 포인트 적립 후:
earnPoints→deductPoints
상쇄는 금액/수량이 정확히 맞아야 하며, 부분 환불/부분 차감 같은 복잡도가 따라옵니다.
중복처리가 터지는 지점: 실행 단계 vs 메시지 단계
Saga에서 중복은 크게 두 층에서 발생합니다.
- 명령(Command) 중복: 오케스트레이터가 같은 step을 재시도하면서 동일 명령을 여러 번 보냄
- 이벤트(Event) 중복: 브로커가 at-least-once로 전달하거나 컨슈머 재처리로 동일 이벤트를 여러 번 소비
결론부터 말하면, “오케스트레이터에서 한 번만 보내면 되지 않나?”는 운영에서 성립하지 않습니다. 네트워크/타임아웃/프로세스 크래시가 있는 한 중복을 가정하고 시스템을 닫아야 합니다.
gRPC 기반 MSA라면 타임아웃과 재시도 설계가 중복 실행을 더 자주 유발합니다. 연쇄 장애 관점의 타임아웃 설계는 이 글이 도움이 됩니다: gRPC MSA에서 DEADLINE_EXCEEDED 연쇄 장애 차단
핵심 원칙: “멱등 키”를 도메인에 박아 넣기
중복처리의 1차 방어선은 Idempotency Key입니다. 그리고 이 키는 HTTP 헤더 수준에서 끝나면 안 되고, 도메인 저장소에 영속화되어야 합니다.
추천 키 설계:
saga_id: 하나의 비즈니스 흐름(주문 1건)을 대표step: saga 내 단계(예: PAYMENT_AUTHORIZE, INVENTORY_RESERVE)attempt: 재시도 횟수(로깅/관찰용)idempotency_key: 보통saga_id + step또는saga_id + step + business_key
예를 들어 결제 승인 단계는 다음처럼 모델링할 수 있습니다.
- 비즈니스 키:
order_id - 결제 트랜잭션 키:
payment_intent_id또는payment_request_id - 멱등 키:
saga_id:PAYMENT_AUTHORIZE
DB 유니크 제약으로 멱등을 “강제”하기
애플리케이션에서 if문으로 “이미 처리했는지 확인”은 레이스 컨디션에 취약합니다. 가장 강한 방법은 유니크 인덱스로 중복을 물리적으로 막는 것입니다.
-- 결제 승인 요청의 멱등성 보장
CREATE TABLE payment_authorizations (
id BIGSERIAL PRIMARY KEY,
saga_id UUID NOT NULL,
order_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL, -- AUTHORIZED, VOIDED, FAILED
pg_auth_id VARCHAR(128),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_payment_auth_idem
ON payment_authorizations(idempotency_key);
이제 동일 idempotency_key로 승인 로직이 두 번 들어오면, 둘 중 하나는 유니크 충돌로 실패합니다. 실패한 쪽은 “이미 처리됨”으로 간주하고 기존 결과를 조회해 응답하면 됩니다.
보상 트랜잭션도 멱등해야 한다
보상은 더 위험합니다. 실패한 Saga를 되돌리는 과정에서 보상 요청이 중복되면 “환불 2번” 같은 치명적 사고가 납니다.
보상 멱등 설계 체크리스트:
- 보상도 idempotency_key를 가진다(예:
saga_id:PAYMENT_REFUND) - 보상 결과(환불 id, 취소 id)를 영속 저장한다
- “이미 보상 완료” 상태라면 같은 결과를 반환한다
예: 환불 테이블
CREATE TABLE payment_refunds (
id BIGSERIAL PRIMARY KEY,
saga_id UUID NOT NULL,
order_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL, -- REQUESTED, REFUNDED, FAILED
pg_refund_id VARCHAR(128),
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_payment_refund_idem
ON payment_refunds(idempotency_key);
이 구조를 잡아두면, “환불 요청 메시지가 3번 들어왔다”는 사건은 단순히 동일 row를 3번 조회하는 사건으로 바뀝니다.
오케스트레이션 기반 Saga: 상태머신으로 중복/역순을 제어
오케스트레이터는 각 step의 상태를 저장하고, 상태 전이가 유효할 때만 다음 액션을 수행해야 합니다.
권장 상태 모델(예시):
- Saga 상태:
RUNNING,COMPENSATING,COMPLETED,FAILED - Step 상태:
PENDING,DONE,COMPENSATED,FAILED
중복 이벤트가 들어와도 step이 이미 DONE이면 무시하고, 이미 COMPENSATED면 보상 재실행을 막습니다.
간단한 상태머신 예제 코드 (Python)
아래는 “주문 Saga”에서 결제 승인 후 재고 예약 단계로 진행하다 실패 시 보상으로 전환하는 매우 단순화된 예시입니다.
from enum import Enum
class Step(str, Enum):
PAYMENT_AUTH = "PAYMENT_AUTH"
INVENTORY_RESERVE = "INVENTORY_RESERVE"
class StepStatus(str, Enum):
PENDING = "PENDING"
DONE = "DONE"
COMPENSATED = "COMPENSATED"
class SagaStatus(str, Enum):
RUNNING = "RUNNING"
COMPENSATING = "COMPENSATING"
COMPLETED = "COMPLETED"
class SagaState:
def __init__(self, saga_id: str):
self.saga_id = saga_id
self.status = SagaStatus.RUNNING
self.steps = {
Step.PAYMENT_AUTH: StepStatus.PENDING,
Step.INVENTORY_RESERVE: StepStatus.PENDING,
}
def mark_done(self, step: Step):
# 중복 완료 이벤트 방어
if self.steps[step] == StepStatus.DONE:
return
self.steps[step] = StepStatus.DONE
def start_compensation(self):
if self.status == SagaStatus.COMPENSATING:
return
if self.status == SagaStatus.COMPLETED:
return
self.status = SagaStatus.COMPENSATING
def can_compensate(self, step: Step) -> bool:
return self.status == SagaStatus.COMPENSATING and self.steps[step] == StepStatus.DONE
def mark_compensated(self, step: Step):
# 보상 중복 실행 방어
if self.steps[step] == StepStatus.COMPENSATED:
return
self.steps[step] = StepStatus.COMPENSATED
# 사용 예
saga = SagaState("saga-123")
# 결제 승인 완료 이벤트가 두 번 와도 안전
saga.mark_done(Step.PAYMENT_AUTH)
saga.mark_done(Step.PAYMENT_AUTH)
# 재고 예약 실패 -> 보상 시작
saga.start_compensation()
if saga.can_compensate(Step.PAYMENT_AUTH):
# refundPayment(idempotency_key="saga-123:PAYMENT_REFUND")
saga.mark_compensated(Step.PAYMENT_AUTH)
포인트는 “이벤트를 믿고 바로 실행”이 아니라, 저장된 상태 + 전이 규칙을 통과한 경우에만 side-effect(결제/환불/예약/취소)를 수행한다는 점입니다.
이벤트 기반(코레오그래피) Saga에서의 중복처리: 컨슈머 멱등 + 인박스
코레오그래피는 서비스들이 이벤트를 구독해 다음 작업을 수행합니다. 이때 중복은 거의 필연입니다.
- 브로커 재전송
- 컨슈머 재시작 후 동일 오프셋 재처리
- DLQ에서 복구 시 재처리
따라서 컨슈머는 반드시 “이 이벤트를 처리한 적이 있는가?”를 저장소로 확인해야 합니다. 흔히 Inbox(또는 Processed Messages) 테이블을 둡니다.
CREATE TABLE inbox_messages (
id BIGSERIAL PRIMARY KEY,
message_id VARCHAR(128) NOT NULL,
consumer VARCHAR(64) NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX ux_inbox_message
ON inbox_messages(message_id, consumer);
컨슈머 처리 흐름:
- 트랜잭션 시작
inbox_messages에(message_id, consumer)insert 시도- 유니크 충돌이면 이미 처리됨 → ack 후 종료
- 충돌이 아니면 비즈니스 로직 수행 + 상태 저장
- 커밋 후 ack
이 패턴은 Outbox와 짝을 이룹니다(발행 측은 Outbox, 소비 측은 Inbox). 중복처리 문제를 “메시징의 자연스러운 특성”으로 받아들이고, 데이터 모델로 흡수합니다.
Outbox로 “보상 이벤트”까지 원자적으로 발행하기
보상 트랜잭션 자체가 성공했는데 “보상 완료 이벤트” 발행이 실패하면, 오케스트레이터/다른 서비스가 보상이 끝난 줄 모르고 재시도하여 중복을 유발합니다.
따라서 로컬 트랜잭션(보상 처리)과 이벤트 발행 의도를 원자적으로 묶는 Outbox가 필요합니다.
- 환불 row 업데이트(REFUNDED)
- outbox에
PaymentRefunded이벤트 insert
이 두 작업을 같은 DB 트랜잭션으로 커밋하면 “환불은 됐는데 이벤트는 유실” 같은 반쪽 상태를 줄일 수 있습니다.
보상 설계에서 자주 놓치는 디테일 6가지
1) 보상은 역순으로만 실행된다는 가정을 버리기
현실에서는 이벤트 지연/재처리로 보상이 먼저 도착하는 것처럼 보일 수 있습니다. 따라서 보상 API는
- 대상 step이 아직 실행되지 않았다면: noop(아무 것도 안 함) 으로 성공 처리
- 이미 보상 완료라면: 기존 결과 반환
처럼 설계하는 게 안전합니다.
2) “부분 성공”을 1급 상태로 만들기
예: 결제는 환불됐는데 재고 복구는 실패. 이런 상태를 숨기지 말고 COMPENSATION_PARTIAL 같은 운영 가능한 상태로 드러내야 합니다.
3) 보상은 동기 호출보다 비동기가 운영에 유리한 경우가 많다
환불/취소는 외부 시스템 지연이 흔합니다. 동기 RPC로 묶으면 상위 서비스 타임아웃→재시도→중복 호출이 늘어납니다. 비동기 + 상태 조회(폴링/웹훅)로 바꾸면 중복 압력이 줄어듭니다.
4) 멱등 키의 스코프를 잘못 잡으면 “다른 주문”까지 막는다
예를 들어 user_id만 키로 잡으면 사용자가 동시에 두 주문을 만들 때 충돌합니다. 멱등 키는 보통 order_id 또는 saga_id 스코프가 적절합니다.
5) 보상 금액/수량 계산은 “이력 기반”으로
원본 거래의 스냅샷(결제 금액, 적용 쿠폰, 환율 등)을 남기지 않으면, 보상 시점의 데이터 변경으로 환불 금액이 달라지는 문제가 생깁니다. 보상은 현재 상태가 아니라 원거래 이력을 기준으로 계산해야 합니다.
6) 관측성: saga_id를 모든 로그/트레이스/메트릭에 전파
중복처리 이슈는 재현이 어렵습니다. saga_id, step, idempotency_key, message_id를 공통 필드로 박아두면, “왜 두 번 결제됐는지”가 로그에서 연결됩니다.
추천 아키텍처 조합(현업에서 가장 안전한 형태)
정리하면, 운영에서 중복과 보상을 안전하게 만들기 위한 조합은 다음이 가장 현실적입니다.
- Saga 오케스트레이터(상태머신) + 각 step 상태 저장
- 각 서비스의 Command/Compensation API는 멱등 키 + 유니크 제약으로 중복 실행 방어
- 발행 측은 Outbox, 소비 측은 Inbox(Processed Messages)
- 보상은 가능하면 “취소/무효화” 우선, 불가하면 “상쇄”
- 부분 실패/지연 보상을 1급 상태로 모델링 + 운영 도구 제공
이 조합을 쓰면, 메시지 중복/타임아웃/재시작이 있어도 시스템은 “중복 실행”이 아니라 “중복 관측” 수준에서 수렴합니다.
마무리: 보상은 로직이 아니라 ‘데이터 모델’이다
Saga 보상 트랜잭션을 안전하게 만드는 핵심은 “보상 API를 잘 짜는 것”보다, 멱등성과 상태 전이를 데이터로 고정하는 것입니다.
- 멱등 키를 도메인 테이블에 저장하고 유니크 제약으로 강제
- 보상도 동일하게 멱등화
- Outbox/Inbox로 메시지 중복을 흡수
- 오케스트레이터는 상태머신으로 역순/중복/지연을 제어
이 네 가지를 갖추면, Saga는 더 이상 ‘운영 공포’가 아니라, 장애를 전제로도 예측 가능한 일관성 모델이 됩니다.