- Published on
Temporal Saga 보상트랜잭션 중복 실행 막는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스나 마이크로서비스 환경에서 Saga 패턴을 쓰다 보면, “보상 트랜잭션이 두 번 실행됐다”는 사고가 종종 터집니다. 결제 취소가 중복으로 나가거나, 재고 복구가 두 번 적용되거나, 외부 시스템에 동일한 환불 요청이 여러 번 전송되는 식입니다. Temporal은 워크플로 리플레이와 재시도 모델이 강력한 대신, 보상 로직을 아무 생각 없이 작성하면 중복 실행을 스스로 초대할 수 있습니다.
이 글에서는 Temporal Saga에서 보상(Compensation) 중복 실행이 왜 일어나는지부터, “중복 실행을 허용하되 결과는 한 번만 반영”하는 멱등성 설계, 그리고 “아예 실행 자체를 한 번만 하도록” 워크플로 상태와 조건부 로직으로 가드하는 패턴까지 단계적으로 정리합니다.
참고로, 재시도/백오프 설계 자체가 중복 실행을 유발하는 경우도 많습니다. 재시도 전략을 더 견고하게 만들고 싶다면 OpenAI 429·insufficient_quota 재시도와 백오프 설계도 같이 읽어보면 도움이 됩니다.
Temporal에서 보상 트랜잭션이 중복 실행되는 대표 원인
Temporal 워크플로는 “코드를 실행한다”기보다 “이벤트 히스토리를 기반으로 결정(Decision)을 재구성한다”는 모델입니다. 이 특성 때문에 아래 상황에서 보상이 중복으로 보일 수 있습니다.
1) Activity 재시도에 의한 중복 호출
보상 로직을 Activity로 구현했다면, 네트워크 타임아웃이나 워커 크래시로 Activity 결과가 워크플로에 보고되지 못할 수 있습니다. 이때 Temporal은 Activity를 재시도하고, 외부 시스템에는 동일한 보상 요청이 다시 날아갈 수 있습니다.
2) 워크플로 코드 변경/리플레이로 인한 “보상 등록” 중복
Saga 구현에서 흔히 “보상 함수를 리스트에 쌓아두고 실패 시 역순 실행”을 합니다. 그런데 리플레이 중에 동일한 보상 핸들러를 또 등록해버리면, 실패 시 보상이 두 번 실행될 수 있습니다.
Temporal에서 워크플로 코드는 리플레이 가능해야 하므로, “실행 중 한 번만 수행되어야 하는 로직”을 워크플로 코드에서 직접 수행하거나, 비결정적 조건(현재 시간, 랜덤, 외부 I/O)을 섞으면 위험합니다.
3) 워크플로 재시도/Continue-As-New/Signal 처리 레이스
워크플로 자체에 재시도 정책을 걸거나, ContinueAsNew로 히스토리를 잘라 운영하는 경우, 보상 단계가 여러 실행 경로에서 트리거될 수 있습니다. Signal로 상태를 갱신하는 중에 실패가 겹치면 “보상을 해야 하는 상태”가 중복으로 관측되는 레이스도 생깁니다.
핵심 원칙: 보상은 반드시 멱등해야 한다
Temporal에서 “중복 실행이 절대 일어나지 않는다”를 보장하려고 하면 설계가 과도하게 복잡해집니다. 현실적인 목표는 다음 중 하나입니다.
- 목표 A: 보상 Activity가 여러 번 호출돼도 외부 효과는 한 번만 반영(멱등)
- 목표 B: 워크플로 레벨에서 보상 실행 자체를 1회로 가드(상태 기반)
대부분은 A와 B를 함께 적용합니다. 즉, 워크플로에서 최대한 중복 실행을 막고, 그래도 발생 가능한 중복 호출은 멱등성으로 흡수합니다.
패턴 1: 보상 Activity에 멱등 키를 강제하기
가장 강력하고 실전적인 방법입니다. 보상 요청에 “멱등 키(idempotency key)”를 넣고, 외부 시스템(또는 우리 DB)에서 해당 키로 중복 요청을 차단합니다.
- 멱등 키 구성 예시
workflowId + runId + stepName- 또는 “비즈니스적으로 동일 보상”을 표현하는 키(예:
orderId + compensationType)
Temporal의 Activity는 activityId를 지정할 수 있지만, 이것만으로 외부 시스템 중복을 완전히 막을 수는 없습니다. 외부 호출이 이미 나갔는데 결과만 유실된 케이스가 있기 때문입니다. 따라서 “외부 시스템이 멱등 키를 이해하도록” 만드는 것이 정석입니다.
예시: TypeScript SDK에서 보상 Activity 멱등 처리
아래 예시는 “환불 보상”을 Activity로 호출할 때 멱등 키를 함께 전달하고, 서버는 해당 키로 중복 환불을 거부하거나 기존 결과를 반환합니다.
// activities.ts
export async function refundPayment(input: {
orderId: string;
amount: number;
idempotencyKey: string;
}) {
const res = await fetch('https://pay.example.com/refunds', {
method: 'POST',
headers: {
'content-type': 'application/json',
'idempotency-key': input.idempotencyKey,
},
body: JSON.stringify({ orderId: input.orderId, amount: input.amount }),
});
if (!res.ok) {
throw new Error(`refund failed: ${res.status}`);
}
return await res.json();
}
워크플로에서는 멱등 키를 결정적으로 만들어야 합니다. Date.now() 같은 값을 쓰면 리플레이 시 달라져서 위험합니다.
// workflow.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';
const { refundPayment } = proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
maximumAttempts: 8,
initialInterval: '1s',
},
});
export async function orderWorkflow(input: { orderId: string; amount: number }) {
const idempotencyKey = `refund:${input.orderId}`;
// ... 결제/재고 등 진행
// 실패 시 보상
await refundPayment({
orderId: input.orderId,
amount: input.amount,
idempotencyKey,
});
}
이렇게 하면 Activity가 재시도되거나 워커가 죽었다 살아나도 “외부 효과는 한 번만” 반영됩니다.
패턴 2: 워크플로 상태로 “보상 실행 여부”를 기록하고 조건부 실행
멱등성만으로 충분한 경우가 많지만, 보상 자체가 비용이 크거나(외부 API 비용, 레이트리밋), 부작용이 있는 경우 “아예 두 번째 호출을 하지 않도록” 워크플로에서 가드하는 게 좋습니다.
Temporal 워크플로는 상태를 로컬 변수로 유지할 수 있고, 그 상태는 히스토리 기반으로 리플레이 시 동일하게 재구성됩니다(결정적 코드라는 전제). 따라서 아래처럼 “보상 실행 플래그”를 두고, 보상 전에 확인합니다.
import { proxyActivities, defineSignal, setHandler } from '@temporalio/workflow';
import type * as activities from './activities';
const { refundPayment } = proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
});
const markRefunded = defineSignal('markRefunded');
export async function orderWorkflow(input: { orderId: string; amount: number }) {
let refunded = false;
setHandler(markRefunded, () => {
refunded = true;
});
try {
// ... 메인 트랜잭션
throw new Error('force fail');
} catch (e) {
if (!refunded) {
await refundPayment({
orderId: input.orderId,
amount: input.amount,
idempotencyKey: `refund:${input.orderId}`,
});
refunded = true;
}
throw e;
}
}
주의할 점은 “Activity 성공 후 플래그를 세팅하기 전에 워크플로 태스크가 실패”하는 경우입니다. 이 경우 리플레이 시 다시 보상을 호출할 수 있으므로, 결국 패턴 1(외부 멱등성)과 같이 써야 안전합니다.
패턴 3: 보상 실행을 ‘단일 원자 단계’로 만들기 (상태 저장소 활용)
보상 실행을 정말로 한 번만 하려면, 워크플로 내부 플래그만으로는 원자성을 보장하기 어렵습니다. 가장 흔한 해법은 “우리 DB에 보상 실행 레코드를 먼저 기록하고, 그 기록이 성공한 경우에만 보상 Activity를 수행”하는 방식입니다.
핵심은 DB의 유니크 제약으로 중복 실행을 차단하는 것입니다.
예시: PostgreSQL로 보상 실행 1회 보장
- 보상 실행 시도 시
compensations테이블에 먼저 insert - 유니크 키 충돌이면 이미 실행(또는 실행 중)이므로 스킵
- insert 성공이면 보상 수행 후 상태를
DONE으로 업데이트
create table if not exists compensations (
key text primary key,
status text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
// activities.ts (DB 접근은 Activity에서)
export async function tryBeginCompensation(key: string): Promise<boolean> {
// pseudo code
// insert into compensations(key, status) values ($1, 'STARTED')
// on conflict do nothing
// return row inserted?
return true;
}
export async function markCompensationDone(key: string): Promise<void> {
// update compensations set status='DONE', updated_at=now() where key=$1
}
// workflow.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';
const { tryBeginCompensation, refundPayment, markCompensationDone } =
proxyActivities<typeof activities>({ startToCloseTimeout: '30s' });
export async function orderWorkflow(input: { orderId: string; amount: number }) {
const key = `refund:${input.orderId}`;
try {
// ...
throw new Error('fail');
} catch (e) {
const shouldRun = await tryBeginCompensation(key);
if (shouldRun) {
await refundPayment({
orderId: input.orderId,
amount: input.amount,
idempotencyKey: key,
});
await markCompensationDone(key);
}
throw e;
}
}
이 패턴의 장점은 “워크플로가 어떤 이유로 재실행/재시도되어도 DB 유니크 키가 최후의 방어선”이 되어준다는 점입니다. 단점은 DB 의존성이 생기고, STARTED에서 멈춘 레코드(보상 실행 중 워커 다운 등)에 대한 청소/재개 정책이 필요합니다.
이런 “중복/유실/꼬임” 문제는 이벤트 소싱에서도 자주 발생합니다. 복구 전략 관점은 Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략 글도 결이 비슷합니다.
패턴 4: 보상 Activity의 Retry 정책을 ‘중복 비용’에 맞게 조정
Temporal의 기본 재시도는 강력하지만, 보상은 성격이 다릅니다.
- 보상은 “실패해도 재시도해야 하는” 경우가 많지만
- 동시에 “중복 호출 비용”이 큰 경우도 많습니다(환불 API, 이메일 발송, 재고 조정)
권장 접근:
- 보상 Activity는 반드시 멱등하게 만든다(필수)
- 재시도는 하되, 빠른 연타를 피하도록 백오프를 둔다
nonRetryableErrorTypes로 “재시도해도 소용없는 오류”는 즉시 실패 처리
TypeScript 예시:
const { refundPayment } = proxyActivities<typeof activities>({
startToCloseTimeout: '20s',
retry: {
initialInterval: '2s',
backoffCoefficient: 2,
maximumInterval: '1m',
maximumAttempts: 10,
nonRetryableErrorTypes: ['InvalidRequestError', 'PermissionDeniedError'],
},
});
재시도/백오프는 외부 API 레이트리밋과도 직결됩니다. 레이트리밋 대응까지 포함한 설계는 OpenAI 429 Rate Limit 해결 - 백오프·큐·배치에서 설명한 패턴을 그대로 응용할 수 있습니다.
패턴 5: “보상 등록” 자체가 중복되지 않게 Saga 구현을 점검
Temporal에서 Saga를 직접 구현할 때 흔한 실수는 다음입니다.
- 워크플로 실행 중 어떤 분기에서 보상 함수를 등록
- 리플레이 시에도 같은 분기가 평가되며 보상 등록이 다시 일어남
- 실패 시 보상 리스트에 동일 항목이 중복으로 들어가 역순 실행 때 두 번 호출
해결 방향:
- 보상 등록을 “한 번만 일어나도록” 결정적 조건과 상태로 가드
- 또는 보상 리스트에 넣을 때 키 기반으로 중복 제거
간단한 중복 제거 예시(워크플로 내 자료구조 사용):
type Compensation = {
key: string;
run: () => Promise<void>;
};
export async function wf() {
const comps: Compensation[] = [];
const seen = new Set<string>();
function addCompensation(c: Compensation) {
if (seen.has(c.key)) return;
seen.add(c.key);
comps.push(c);
}
addCompensation({
key: 'refund:order-123',
run: async () => {
// call activity
},
});
try {
// ...
} catch (e) {
for (const c of comps.reverse()) {
await c.run();
}
throw e;
}
}
다만 이 방식은 “보상 실행 시점의 원자성”을 해결해주진 않습니다. 결국 외부 멱등 키 또는 DB 유니크 키가 함께 있어야 운영에서 안전합니다.
운영 체크리스트: 중복 보상을 ‘사고’로 만들지 않기
- 보상 Activity는 무조건 멱등 키를 받는다
- 외부 시스템이 멱등 키를 지원하지 않으면, 우리 DB로 멱등 레이어를 만든다
- 워크플로 내부에서는 보상 실행 여부를 상태로 가드한다(불필요한 호출 감소)
- 보상 Activity 재시도 정책은 백오프 포함으로 보수적으로 설정한다
STARTED에서 멈춘 보상 레코드(또는 외부 요청)의 재처리 정책을 문서화한다- 관측성: 보상 키를 로그/트레이스의 코어 필드로 남긴다(검색 가능하게)
마무리
Temporal Saga에서 보상 트랜잭션 중복 실행을 완전히 “없애는” 것보다, 중복이 발생해도 안전하도록 멱등성과 상태 기록을 설계하는 것이 현실적인 정답입니다. 가장 추천하는 조합은 다음입니다.
- 1순위: 외부 호출에 멱등 키 적용
- 2순위: 워크플로 상태로 중복 호출 가드
- 3순위: DB 유니크 키로 보상 실행 1회 보장(필요 시)
이 3가지를 갖추면, 재시도/워커 재시작/리플레이가 일상인 분산 환경에서도 보상 트랜잭션을 “중복 실행돼도 안전한 시스템”으로 만들 수 있습니다.