- Published on
Saga 보상트랜잭션 설계 - 중복·순서꼬임 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
마이크로서비스에서 분산 트랜잭션을 피할 수 없다면 Saga는 사실상 표준 해법입니다. 하지만 운영에서 진짜 골치 아픈 건 “보상 로직 자체”보다, 메시지 중복 전달과 이벤트 순서 꼬임으로 인해 보상이 두 번 실행되거나(과보상), 아직 커밋되지 않은 단계가 보상되는(역전) 상황입니다.
이 글은 Saga 보상트랜잭션을 설계할 때 중복과 순서꼬임을 시스템적으로 막는 방법을 다룹니다. 특히 오케스트레이션(중앙 조정)과 코레오그래피(이벤트 기반) 모두에 공통으로 적용되는 멱등성, 상태 머신, 버전/시퀀스, Outbox/Inbox, 락/조건부 업데이트 패턴을 코드와 함께 정리합니다.
관련해서 “정확히 한 번”을 맹신하다가 중복 이벤트로 장애가 나는 케이스는 정말 흔합니다. 아래 글도 함께 보면 맥락이 이어집니다.
Saga에서 중복·순서꼬임이 발생하는 이유
분산 환경에서 다음은 “버그”가 아니라 “기본 전제”에 가깝습니다.
- At-least-once 전달: 브로커/네트워크/컨슈머 재시도 때문에 동일 메시지가 2번 이상 도착할 수 있음
- Out-of-order 전달: 파티션 키가 다르거나, 재시도/지연으로 인해 나중 이벤트가 먼저 도착 가능
- 중간 실패: 로컬 DB 커밋은 됐는데 이벤트 발행이 실패(또는 반대)하는 이중화 불일치
- 컨슈머 재밸런싱: 같은 메시지가 다른 인스턴스로 재할당되며 중복 처리 가능
결론은 단순합니다. Saga는 “정확히 한 번”을 기대하면 깨지고, 중복/순서꼬임을 허용한 채로도 결과가 올바르게 수렴하도록 설계해야 합니다.
목표: 보상은 ‘안전하게’ 늦게 실행되고, 절대 두 번 실행되지 않게
보상 트랜잭션의 안전성 목표를 3가지로 정리하면 설계가 쉬워집니다.
- 멱등성: 같은 보상 요청이 여러 번 와도 결과는 1번 실행과 동일
- 선행조건 검증: 아직 실행되지 않은 단계는 보상하지 않음(역전 방지)
- 단조 상태 전이: 상태는 되돌아가지 않고 한 방향으로만 진행(또는 버전 증가)
이 3가지를 만족하면 “중복/순서꼬임”은 운영 이슈가 아니라 설계 범위로 들어옵니다.
핵심 패턴 1: Saga 상태 머신을 DB에 저장하고 조건부 업데이트로 전이
가장 먼저 할 일은 Saga 인스턴스(주문 1건 등)의 진행 상태를 명시적으로 저장하는 것입니다.
예: 주문 생성 Saga
INITPAYMENT_RESERVEDINVENTORY_ALLOCATEDCOMPLETEDCOMPENSATINGCOMPENSATEDFAILED
여기서 중요한 건 상태 변경을 단순 UPDATE로 하지 말고, 현재 상태를 조건으로 건 CAS(Compare-And-Set) 스타일 업데이트를 하는 것입니다.
-- 현재 상태가 PAYMENT_RESERVED일 때만 INVENTORY_ALLOCATED로 전이
UPDATE saga_instance
SET state = 'INVENTORY_ALLOCATED', version = version + 1, updated_at = NOW()
WHERE saga_id = $1
AND state = 'PAYMENT_RESERVED';
-- rowcount가 0이면 이미 다른 상태로 전이되었거나(중복), 순서가 어긋난 것
이 한 줄로 다음을 동시에 얻습니다.
- 중복 이벤트가 와도 이미 전이된 상태면 무시 가능
- 순서가 꼬여도 “기대 상태가 아니면 전이 실패”로 방어 가능
순서꼬임을 “대기”로 바꾸는 전략
순서가 꼬인 이벤트를 무조건 실패 처리하면 재시도 폭탄이 됩니다. 대신 “보류” 테이블(또는 지연 큐)로 보내고, 선행 이벤트가 처리된 뒤 재평가하는 방식이 운영 친화적입니다.
INSERT INTO saga_pending_events(saga_id, event_id, event_type, payload, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (event_id) DO NOTHING;
이때 event_id는 반드시 전역 유니크(예: UUID)여야 합니다.
핵심 패턴 2: Inbox로 컨슈머 멱등성 보장(중복 이벤트 제거)
브로커는 중복을 만들 수 있으니, 컨슈머는 “이미 처리한 이벤트인지”를 로컬 DB로 판단해야 합니다. 이게 Inbox 패턴입니다.
Inbox 테이블 예시
event_id(PK)consumer(서비스명)processed_at
처리 흐름은 다음과 같습니다.
- 트랜잭션 시작
inbox에event_id삽입 시도- 이미 있으면 중복이므로 ACK하고 종료
- 없으면 비즈니스 로직 수행
- 커밋
// Node.js + PostgreSQL 예시 (의사 코드)
import { Pool } from 'pg'
const pool = new Pool()
async function handleEvent(msg: { eventId: string; sagaId: string; type: string; payload: any }) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const ins = await client.query(
`INSERT INTO inbox(event_id, consumer, processed_at)
VALUES ($1, $2, NOW())
ON CONFLICT (event_id) DO NOTHING`,
[msg.eventId, 'inventory-service']
)
if (ins.rowCount === 0) {
await client.query('COMMIT')
return // duplicate
}
// 선행조건/상태 머신 업데이트
const updated = await client.query(
`UPDATE saga_instance
SET state = 'INVENTORY_ALLOCATED', version = version + 1
WHERE saga_id = $1 AND state = 'PAYMENT_RESERVED'`,
[msg.sagaId]
)
if (updated.rowCount === 0) {
// out-of-order: pending으로 보내거나, 재시도 정책 적용
await client.query(
`INSERT INTO saga_pending_events(saga_id, event_id, event_type, payload, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (event_id) DO NOTHING`,
[msg.sagaId, msg.eventId, msg.type, JSON.stringify(msg.payload)]
)
}
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
}
이 방식은 “중복 메시지”를 처리 시작 지점에서 제거하므로, 보상 로직이 두 번 타는 걸 크게 줄입니다.
핵심 패턴 3: Outbox로 로컬 커밋과 이벤트 발행의 원자성 맞추기
보상 설계에서 자주 터지는 케이스:
- 결제 취소(보상) DB 업데이트는 성공
- 그런데 보상 완료 이벤트 발행 실패
- 오케스트레이터는 보상 완료를 모르고 재시도
- 결과적으로 취소 API가 또 호출(중복 보상)
이를 줄이는 대표 해법이 Outbox입니다. 비즈니스 트랜잭션 안에서 “이벤트를 브로커에 바로 발행”하지 말고, outbox 테이블에 기록합니다. 별도 릴레이 프로세스가 outbox를 읽어 발행합니다.
BEGIN;
-- 1) 비즈니스 상태 변경
UPDATE payment
SET status = 'CANCELED', canceled_at = NOW()
WHERE payment_id = $1 AND status = 'RESERVED';
-- 2) outbox 기록
INSERT INTO outbox(event_id, aggregate_id, event_type, payload, created_at)
VALUES ($2, $1, 'PaymentCanceled', $3, NOW());
COMMIT;
릴레이는 다음처럼 동작합니다.
- outbox에서 미발행 레코드를 가져옴
- 브로커 발행
- 성공하면 outbox에
published_at기록
이때도 중복 발행이 가능하므로 event_id를 고정하고, 컨슈머는 Inbox로 멱등 처리합니다(Outbox와 Inbox는 세트로 보는 게 안전합니다).
핵심 패턴 4: 보상 트랜잭션 자체를 멱등하게 만들기
Inbox/상태머신이 있어도, 외부 API 호출(결제사 취소, 배송 취소 등)은 네트워크 재시도 때문에 중복 호출될 수 있습니다. 따라서 보상 API 자체가 멱등해야 합니다.
멱등 키(Idempotency Key) 설계
- 키는 “보상 단계 + sagaId”로 결정
- 예:
cancel-payment:{sagaId} - 외부 결제사에 멱등 키를 전달할 수 있으면 최선
- 불가능하면 우리 DB에 “보상 실행 로그”를 남겨 중복을 차단
-- 보상 실행 로그(멱등 키)로 중복 차단
INSERT INTO compensation_log(idempotency_key, saga_id, step, created_at)
VALUES ($1, $2, 'CANCEL_PAYMENT', NOW())
ON CONFLICT (idempotency_key) DO NOTHING;
async function cancelPaymentCompensation(sagaId: string) {
const key = `cancel-payment:${sagaId}`
await db.tx(async (tx) => {
const ins = await tx.query(
`INSERT INTO compensation_log(idempotency_key, saga_id, step, created_at)
VALUES ($1, $2, 'CANCEL_PAYMENT', NOW())
ON CONFLICT (idempotency_key) DO NOTHING`,
[key, sagaId]
)
if (ins.rowCount === 0) return // 이미 보상 실행됨
// 외부 결제 취소 호출 (재시도는 가능하나, key가 있으면 더 안전)
await paymentGateway.cancel({ sagaId, idempotencyKey: key })
await tx.query(
`UPDATE saga_instance
SET state = 'COMPENSATED', version = version + 1
WHERE saga_id = $1 AND state IN ('COMPENSATING','INVENTORY_ALLOCATED','PAYMENT_RESERVED')`,
[sagaId]
)
})
}
핵심은 “보상 호출을 했는지”를 네트워크 바깥(우리 DB)에서 판정하게 만드는 것입니다.
핵심 패턴 5: 단계별 시퀀스(버전)로 순서꼬임을 정량적으로 차단
상태 문자열만으로도 방어가 되지만, 이벤트가 다단계로 많아지면 정수 시퀀스가 더 단단합니다.
- 각 이벤트에
step(0,1,2…) 또는version을 포함 - 소비자는 “현재 saga 버전보다 큰 것만” 적용
- 동일 버전은 중복으로 간주
-- 현재 version이 3일 때, 4 이벤트만 적용
UPDATE saga_instance
SET version = $2, state = $3
WHERE saga_id = $1 AND version = $2 - 1;
이 방식은 “이벤트가 뒤늦게 도착”해도 과거 버전이면 자연스럽게 무시됩니다.
오케스트레이션 vs 코레오그래피: 중복/순서꼬임 관점의 차이
오케스트레이션(중앙 Saga 오케스트레이터)
장점
- 상태 머신이 한 곳에 모여 있어 순서 제어가 쉬움
- 타임아웃, 재시도, 보상 트리거가 중앙에서 일관됨
주의점
- 오케스트레이터 자체가 SPOF가 되지 않게(HA)
- 커맨드 중복 전송에 대비해 각 참여자 서비스는 반드시 멱등 처리
코레오그래피(이벤트 기반)
장점
- 결합도가 낮고 확장성이 좋음
주의점
- 순서 보장이 어려워 Inbox + 상태머신 + 시퀀스가 사실상 필수
- “어떤 이벤트가 보상을 트리거하는가”가 분산되어 추적이 어려움(관측성 중요)
운영에서 자주 보는 실패 시나리오와 대응
1) 보상이 먼저 실행되는 역전
원인
- 실패 이벤트가 빨리 도착하고 성공 이벤트가 지연
대응
- 상태 전이를 조건부 업데이트로 제한
- 선행 상태가 아니면 pending으로 보관 후 재처리
2) 보상이 두 번 실행(과보상)
원인
- 동일 실패 이벤트 중복
- 오케스트레이터 재시도
- 컨슈머 크래시 후 재처리
대응
- Inbox로 이벤트 멱등
- 보상 단계별 멱등 키 로그
- 외부 API에 idempotency key 전달
3) 보상 완료 이벤트 유실로 무한 재시도
원인
- 로컬 DB 업데이트와 이벤트 발행이 분리
대응
- Outbox 도입
- 릴레이는 최소 1회 발행을 전제로 하고, 컨슈머는 Inbox로 중복 제거
이 흐름은 “정확히 한 번”이 아니라 “최소 한 번 + 멱등성”으로 안정화하는 전형적인 접근입니다. 중복 이벤트 진단 관점은 앞서 링크한 글과 연결됩니다.
체크리스트: 보상트랜잭션 설계 리뷰 항목
- 이벤트에
event_id가 있는가(전역 유니크) - 컨슈머가 Inbox로
event_id중복을 제거하는가 - Saga 상태가 DB에 저장되고, 전이가 조건부 업데이트로 보호되는가
- 이벤트에
version또는step이 있고, 과거 이벤트를 무시하는가 - 보상 단계가 멱등 키로 2회 실행을 차단하는가
- 로컬 커밋과 이벤트 발행 불일치를 Outbox로 줄였는가
- 순서가 어긋난 이벤트를 “실패”가 아니라 “보류 후 재평가”로 다룰 수 있는가
- 관측성(로그/트레이싱)에서
saga_id,event_id,version이 항상 찍히는가
마무리: 보상 로직은 ‘정확성’보다 ‘수렴성’이 핵심
Saga에서 중요한 건 한 번에 완벽히 처리하는 것이 아니라, 중복과 지연이 있어도 시스템이 올바른 상태로 수렴하도록 만드는 것입니다. 이를 위해서는
- Inbox로 중복을 제거하고
- 상태 머신을 조건부 업데이트로 보호하며
- Outbox로 발행 유실을 줄이고
- 보상 자체를 멱등하게 만드는
4종 세트를 기본값으로 가져가는 것이 가장 안전합니다.
이미 운영 중인 시스템이라면, 먼저 “어떤 키로 멱등성을 보장하고 있는지”부터 점검해 보세요. 대부분의 중복·순서꼬임 장애는 키 설계와 상태 전이 규칙을 정리하는 것만으로도 급격히 줄어듭니다.