- Published on
Saga 패턴에서 보상 트랜잭션 중복 실행 막는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
분산 트랜잭션을 Saga로 풀면 "실패 시 되돌리기"가 가능해지지만, 운영에서 더 무서운 문제는 보상 트랜잭션이 중복 실행되는 상황입니다. 메시지 재전송, 타임아웃에 의한 재시도, 컨슈머 크래시 후 재처리, 오케스트레이터의 중복 명령 등으로 동일한 보상 로직이 두 번 실행되면 환불이 두 번 나가거나 재고가 과도하게 복구되는 식의 금전/정합성 사고가 납니다.
이 글은 Saga에서 보상 트랜잭션 중복 실행을 막기 위한 실전 설계 포인트를 데이터 모델, 메시징, 코드 레벨로 나눠 정리합니다. 기본 Saga 설계 패턴은 MSA에서 Saga 보상트랜잭션 설계 7패턴에서 먼저 잡고, 여기서는 "중복 실행 방지"에만 집중합니다.
왜 보상 트랜잭션이 중복 실행되는가
중복 보상은 대개 아래 조합에서 발생합니다.
- At-least-once 메시징: Kafka, SQS, RabbitMQ 등 대부분의 실전 구성은 기본적으로 중복 전달 가능성을 내포합니다.
- 타임아웃 기반 재시도: 오케스트레이터가 "응답이 늦다"고 판단해 같은 보상 명령을 재발행합니다.
- 컨슈머 크래시/리밸런싱: 처리 완료 커밋 전에 죽으면 같은 메시지를 다시 받습니다.
- 네트워크 분할: 실제로는 성공했는데 성공 응답이 유실되어 재시도합니다.
결론적으로, "중복 실행이 절대 안 일어난다"는 가정은 버려야 합니다. 설계 목표는 다음 중 하나입니다.
- 보상 로직을 멱등(idempotent) 하게 만들어 중복 실행돼도 결과가 동일하게 유지되게 한다.
- 중복 실행 자체를 검출하고 차단한다(상태/락/유니크 제약).
실무에서는 둘을 같이 씁니다.
핵심 원칙 1: 보상도 "커맨드"로 보고 멱등성을 강제한다
보상은 단순한 함수 호출이 아니라 "보상 커맨드"입니다.
CompensateReserveInventoryCompensateCreatePaymentCompensateIssueCoupon
이 커맨드에 멱등성 키(idempotency key) 를 반드시 넣습니다.
sagaIdstepNameattempt(옵션)commandId(UUID)
가장 흔한 실수는 "정방향 트랜잭션에는 멱등키가 있는데 보상에는 없다"입니다. 보상도 똑같이 외부 부작용을 만들기 때문에 동일한 수준의 방어가 필요합니다.
멱등성 키 설계 예시
- 멱등성 키:
sagaId + stepName + action조합 - 예:
order-123:ReserveInventory:COMPENSATE
이 키를 보상 실행 로그 테이블에 유니크로 박아두면, 동일 키의 보상은 DB가 차단합니다.
핵심 원칙 2: 보상 실행 로그(인박스/디듀프 테이블)를 DB 유니크로 막는다
중복 실행 방지의 가장 강력한 무기는 애플리케이션 코드가 아니라 DB의 유니크 제약입니다.
테이블 모델 예시
compensation_executionsid(PK)idempotency_key(UNIQUE)saga_idstepstatus(STARTED,SUCCEEDED,FAILED)created_at,updated_at
처리 흐름은 다음과 같습니다.
- 보상 커맨드를 받으면 먼저
idempotency_key로INSERT를 시도 - 유니크 충돌이면 "이미 처리됨"으로 간주하고 ACK
INSERT가 성공하면 실제 보상 로직 수행- 성공 시
SUCCEEDED로 업데이트
이 패턴은 메시지 중복뿐 아니라 "오케스트레이터 중복 발행"까지 함께 막습니다.
PostgreSQL 예시 (유니크 기반 선점)
CREATE TABLE compensation_executions (
id BIGSERIAL PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
saga_id TEXT NOT NULL,
step TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 선점: 이미 있으면 아무 것도 하지 않음
INSERT INTO compensation_executions (idempotency_key, saga_id, step, status)
VALUES ('order-123:ReserveInventory:COMPENSATE', 'order-123', 'ReserveInventory', 'STARTED')
ON CONFLICT (idempotency_key) DO NOTHING;
ON CONFLICT DO NOTHING은 "중복이면 빠져나오기"에 좋고, "이미 성공했는지/실패했는지"를 확인하려면 DO UPDATE로 상태를 읽거나 별도 SELECT를 합니다.
핵심 원칙 3: 상태 머신으로 "한 번만" 전이되게 만든다
보상 중복을 막는 또 다른 축은 Saga step 상태 전이를 엄격히 제한하는 것입니다.
- 정방향 step 상태:
PENDING→DONE - 보상 step 상태:
COMPENSATION_PENDING→COMPENSATED
중복 보상은 보통 "이미 COMPENSATED인데 또 보상" 같은 전이가 허용될 때 발생합니다. 따라서 상태 업데이트를 다음처럼 조건부 업데이트로 만듭니다.
조건부 업데이트 예시
-- 아직 보상 전일 때만 COMPENSATION_PENDING으로 전이
UPDATE saga_steps
SET status = 'COMPENSATION_PENDING', updated_at = NOW()
WHERE saga_id = 'order-123'
AND step = 'ReserveInventory'
AND status IN ('DONE', 'FAILED');
-- 보상 성공 처리도 한 번만
UPDATE saga_steps
SET status = 'COMPENSATED', updated_at = NOW()
WHERE saga_id = 'order-123'
AND step = 'ReserveInventory'
AND status = 'COMPENSATION_PENDING';
이렇게 하면 애플리케이션이 실수로 두 번 호출해도 두 번째 호출은 업데이트 행 수가 0이 되어 "이미 처리됨"으로 자연스럽게 떨어집니다.
핵심 원칙 4: Outbox + Inbox 조합으로 "발행"과 "소비" 모두 중복 내성을 만든다
보상 중복은 소비 측에서만 막아도 되지만, 발행 측(오케스트레이터)에서도 중복을 줄이면 전체 시스템 노이즈가 크게 감소합니다.
- Outbox: 로컬 트랜잭션으로 "상태 변경"과 "메시지 기록"을 함께 커밋하고, 별도 퍼블리셔가 메시지 브로커로 전송
- Inbox(또는 Dedup): 컨슈머가 메시지
messageId를 저장하고 중복 처리 차단
Outbox 테이블 예시
outbox_eventsevent_id(UNIQUE)aggregate_id(예: sagaId)typepayloadpublished_at(NULL 가능)
오케스트레이터가 보상 커맨드를 발행할 때도 outbox를 쓰면 "DB에는 보상 상태가 기록됐는데 메시지는 유실" 같은 상황에서 재발행이 안전해집니다.
구현 예시: Node.js(TypeScript) + Postgres로 보상 중복 차단
아래 예시는 "보상 커맨드 컨슈머"가 중복 메시지를 받더라도 보상 로직이 한 번만 실행되도록 만드는 전형적인 형태입니다.
주의: 본문에서 제네릭 표기나 화살표 기호는 MDX 빌드 에러를 피하기 위해 모두 코드로 감쌉니다.
1) 메시지 스키마
type CompensateCommand = {
commandId: string; // 메시지 자체의 고유 ID
sagaId: string;
step: 'ReserveInventory' | 'ChargePayment' | 'CreateShipment';
reason: string;
};
function makeIdempotencyKey(cmd: CompensateCommand): string {
return `${cmd.sagaId}:${cmd.step}:COMPENSATE`;
}
2) 중복 선점 후 실행
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function handleCompensate(cmd: CompensateCommand): Promise<void> {
const client = await pool.connect();
const idempotencyKey = makeIdempotencyKey(cmd);
try {
await client.query('BEGIN');
// 1) 유니크 키로 선점
const insert = await client.query(
`INSERT INTO compensation_executions (idempotency_key, saga_id, step, status)
VALUES ($1, $2, $3, 'STARTED')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;`,
[idempotencyKey, cmd.sagaId, cmd.step]
);
if (insert.rowCount === 0) {
// 이미 누군가 처리했거나 처리 중
await client.query('COMMIT');
return;
}
// 2) (선택) Saga step 상태를 조건부로 전이
const stepUpdate = await client.query(
`UPDATE saga_steps
SET status = 'COMPENSATION_PENDING', updated_at = NOW()
WHERE saga_id = $1 AND step = $2 AND status IN ('DONE', 'FAILED')`,
[cmd.sagaId, cmd.step]
);
// stepUpdate.rowCount가 0이면 이미 보상 진행/완료일 수 있음
// 그래도 안전하게 보상 로직을 "멱등"하게 만들거나, 여기서 조기 종료 정책을 택할 수 있음
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
// 3) 실제 보상 로직은 트랜잭션 밖에서 실행(외부 I/O 포함 가능)
// 단, 이 로직 자체도 멱등하게 설계되어야 함
await runCompensation(cmd);
// 4) 결과 기록
const client2 = await pool.connect();
try {
await client2.query(
`UPDATE compensation_executions
SET status = 'SUCCEEDED', updated_at = NOW()
WHERE idempotency_key = $1`,
[idempotencyKey]
);
await client2.query(
`UPDATE saga_steps
SET status = 'COMPENSATED', updated_at = NOW()
WHERE saga_id = $1 AND step = $2 AND status = 'COMPENSATION_PENDING'`,
[cmd.sagaId, cmd.step]
);
} finally {
client2.release();
}
}
async function runCompensation(cmd: CompensateCommand): Promise<void> {
// 예: 결제 취소, 재고 복구, 쿠폰 회수 등
// 반드시 "같은 cmd"가 여러 번 호출돼도 최종 상태가 같게 설계
}
포인트는 다음 두 가지입니다.
- 유니크 키로 선점 후에만 보상 로직을 실행한다.
- 상태 전이는 조건부 업데이트로 한 번만 진행되게 만든다.
보상 로직 자체를 멱등하게 만드는 테크닉
유니크 선점만으로도 대부분 막을 수 있지만, 아래 케이스에서는 보상 로직 자체가 멱등해야 합니다.
- 보상 실행 로그를
STARTED로 찍고 나서 프로세스가 죽어 "실제 보상"이 수행되지 않았는데, 재처리 시 유니크 때문에 영원히 막히는 경우 - 외부 결제사/외부 재고 시스템처럼 "우리 DB 트랜잭션"으로 감싸지 못하는 경우
이때는 STARTED 레코드만 보고 끝내지 말고 다음을 추가합니다.
1) 상태에 TTL 또는 재시도 가능한 락 개념 도입
STARTED가 일정 시간 이상 지속되면 "처리자 사망"으로 보고 재시도 허용- 구현은
locked_until같은 컬럼으로 가능
2) 외부 시스템에도 멱등 키 전달
가능한 API라면 반드시 idempotencyKey를 헤더나 파라미터로 전달합니다.
- 예:
Idempotency-Key: order-123:ChargePayment:COMPENSATE
외부가 이를 지원하지 않으면, 우리 쪽에서 "외부 요청 로그"를 남기고 중복 호출을 막는 방식으로 보완합니다.
3) 보상은 "반대 연산"이 아니라 "목표 상태"로 모델링
예를 들어 재고 보상은 "+1" 같은 증분보다 "reserved = false" 같은 목표 상태가 멱등 설계에 유리합니다.
- 나쁜 예:
stock = stock + 1 - 좋은 예:
reservation.status = CANCELED
오케스트레이터 관점: 중복 보상 커맨드 발행을 줄이는 방법
보상 중복은 소비자가 막더라도, 발행이 중복되면 시스템이 불필요하게 흔들립니다.
- Saga 오케스트레이터가 step 타임아웃을 짧게 잡고 무한 재시도
- 동일 Saga에 대해 여러 인스턴스가 동시에 컨트롤(리더 선출 실패)
대응책:
- 오케스트레이터도
sagaId기준으로 리더 락 또는 샤딩을 둔다 - 보상 커맨드도 outbox에 기록하고
event_id유니크로 "한 번만 발행"되게 한다 - step 타임아웃은 "p99 + 여유" 기준으로 잡고, 재시도는 지수 백오프를 준다
운영에서 자주 놓치는 체크리스트
- 보상 커맨드에
commandId가 있는가 - 보상 커맨드에
idempotencyKey를 안정적으로 만들 수 있는 필드(sagaId,step)가 있는가 - 컨슈머가 메시지 중복을 받았을 때 ACK 정책이 명확한가
STARTED에서 죽은 케이스를 어떻게 회복하는가(재시도/TTL/수동 조치)- 보상 로직이 증분 업데이트가 아니라 목표 상태 업데이트로 설계되어 있는가
테스트 전략: "중복"을 재현하는 자동화
중복은 운영에서만 터지는 경우가 많아, 테스트에서 강제로 만들어야 합니다.
- 같은 보상 커맨드를 10번 동시에 처리하도록 부하 테스트
- 컨슈머가 DB 커밋 직후 크래시하는 상황을 시뮬레이션
- 네트워크 지연으로 타임아웃 재시도가 발생하는 상황을 시뮬레이션
이때 관측해야 할 지표:
compensation_executions에서 유니크 충돌 발생 횟수STARTED가 장시간 남아있는 레코드 수- 보상 성공/실패 비율과 평균 처리 시간
마무리
Saga에서 보상 트랜잭션 중복 실행을 막는 가장 현실적인 해법은 "중복이 온다"를 전제로 DB 유니크 제약 기반의 선점 + 상태 머신 전이 제한 + outbox/inbox + 보상 로직 멱등화를 함께 적용하는 것입니다.
특히 금전/재고처럼 한 번의 중복이 치명적인 도메인이라면, 애플리케이션 레벨 if 체크가 아니라 유니크 인덱스와 조건부 업데이트로 "구조적으로" 막아야 재현 불가능한 장애를 줄일 수 있습니다.