- Published on
Temporal로 분산 트랜잭션 재시도·중복처리 끝내기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
분산 시스템에서 트랜잭션이 어려운 이유는 단순합니다. 하나의 요청이 여러 서비스, 여러 데이터 저장소, 외부 결제사 같은 경계 밖 시스템을 거치면서 부분 성공이 자연스럽게 발생하기 때문입니다. 네트워크 타임아웃이 나면 실제로는 성공했는데 클라이언트는 실패로 인식할 수 있고, 그 순간부터 재시도는 곧 중복 실행을 의미합니다.
그동안 많이 쓰던 접근은 다음 중 하나였습니다.
- 각 서비스에 재시도 로직을 흩뿌리고, 메시지 큐에
at-least-once를 기대며 멱등 키를 붙인다 - 사가 패턴을 직접 구현하고 상태 저장 테이블을 만든다
- 타임아웃, 백오프, 서킷브레이커를 조합하지만 “어디까지 진행됐는지”를 잃는다
문제는 “재시도” 자체가 아니라 재시도와 상태 추적을 함께 해야 한다는 점입니다. 여기서 Temporal은 분산 트랜잭션을 ACID로 만들겠다고 약속하지 않습니다. 대신, 사가 기반의 장기 실행 프로세스를 내구성 있는 워크플로 상태로 관리하고, 재시도와 중복처리를 프레임워크 레벨에서 다루게 해줍니다.
이 글은 Temporal을 이용해 분산 트랜잭션의 재시도·중복처리를 끝내는(최소화하는) 실전 설계 방법을 정리합니다.
분산 트랜잭션의 실패 모드: 왜 재시도가 중복이 되는가
전형적인 주문 생성 플로우를 보겠습니다.
- 주문 서비스: 주문 생성
- 결제 서비스: 결제 승인
- 재고 서비스: 재고 차감
- 배송 서비스: 배송 생성
여기서 흔한 장애 시나리오는 다음입니다.
결제 승인 API 호출 후 타임아웃
- 결제사는 승인 완료
- 우리 서비스는 실패로 판단하고 재시도
- 결제 중복 승인 발생
재고 차감은 성공했는데 배송 생성에서 실패
- 재시도 시 재고가 또 차감되거나, 재고 복구가 누락됨
메시지 큐 소비자가 크래시 후 재처리
- 동일 메시지를 다시 처리하면서 중복 주문/중복 배송 생성
결국 핵심은 이 두 가지입니다.
- 실행의 내구성: 어디까지 진행됐는지 잃지 않아야 함
- 멱등성: 동일 단계가 여러 번 호출돼도 결과가 한 번만 반영돼야 함
Temporal은 워크플로 실행 상태를 이벤트 히스토리로 저장하고, 워커가 죽어도 재시작 시 같은 상태에서 이어갑니다. 즉, 재시도하더라도 “진행 상황”을 잃지 않는 쪽으로 설계를 바꿀 수 있습니다.
Temporal이 주는 핵심: 워크플로는 상태 머신, 액티비티는 I/O
Temporal을 한 문장으로 요약하면 다음입니다.
- 워크플로: 결정 로직(순수하게 상태를 전이)
- 액티비티: 외부 I/O(HTTP, DB, 메시지 발행 등)
워크플로는 재실행(replay)될 수 있으므로, 워크플로 코드에는 비결정적 동작(현재 시간, 랜덤, 외부 호출)을 직접 넣지 않는 것이 원칙입니다. 외부 호출은 액티비티로 분리하고, 액티비티는 Temporal이 제공하는 재시도 정책으로 감쌉니다.
이 구조가 분산 트랜잭션에 좋은 이유는 다음과 같습니다.
- 재시도 정책이 “각 서비스”가 아니라 “각 단계”에 붙는다
- 워크플로 히스토리가 곧 진행 상황이므로, 부분 성공을 잃지 않는다
- 보상 트랜잭션을 워크플로에서 일관되게 실행한다
설계 목표: 재시도는 Temporal에 맡기고, 중복처리는 경계에서 막는다
Temporal만 쓴다고 중복이 자동으로 사라지지는 않습니다. 다음 두 레벨을 동시에 잡아야 합니다.
- 워크플로 레벨 중복 방지
- 동일 비즈니스 요청이 워크플로를 여러 번 시작하지 않도록 한다
- 액티비티 레벨 멱등성
- 외부 시스템 호출이 여러 번 일어나도 결과가 한 번만 반영되게 한다
Temporal은 1번을 비교적 쉽게 만들고, 2번은 설계로 해결해야 합니다.
워크플로 중복 방지: 비즈니스 키를 Workflow ID로 고정
가장 강력한 패턴은 Workflow ID를 비즈니스 유니크 키로 고정하는 것입니다.
예시
- 주문 생성 요청이라면
orderId또는merchantOrderId - 결제 승인이라면
paymentId
즉 워크플로 시작 시 workflowId = "order-" + orderId 같은 규칙을 강제합니다. 그러면 같은 주문에 대해 워크플로가 중복 시작되는 것을 크게 줄일 수 있습니다.
다만, 클라이언트가 재시도할 때 “이미 실행 중인 워크플로에 합류”해야 합니다. Temporal은 워크플로 ID 충돌 시 정책을 선택할 수 있습니다.
- 이미 실행 중이면 실패시키기
- 이미 실행 중이면 기존 실행을 재사용
- 기존 실행을 종료하고 새로 시작
대부분은 “기존 실행 재사용” 또는 “이미 실행 중이면 실패”를 선택하고, API 레이어에서 적절히 처리합니다.
액티비티 멱등성: 재시도는 기본, 중복은 설계로 제거
Temporal 액티비티는 실패 시 자동 재시도를 걸기 쉽습니다. 하지만 재시도는 곧 중복 호출을 의미하므로, 액티비티는 멱등해야 합니다.
멱등성 구현의 대표 선택지는 다음입니다.
- 외부 API가 멱등 키를 지원한다면 반드시 사용
- 예:
Idempotency-Key헤더
- 예:
- 내부 DB에
requestId또는workflowId + step기반 유니크 제약을 둔다 - “이미 처리됨”을 먼저 확인하고, 처리 결과를 재사용한다
특히 결제, 포인트 차감, 쿠폰 사용 같은 금전/자산 계열은 유니크 제약 기반의 멱등성이 가장 안전합니다.
예제: 주문 사가 워크플로 (TypeScript)
아래 예시는 Temporal TypeScript SDK 기준으로, 주문 생성 사가를 단순화한 형태입니다.
- 단계
- 결제 승인
- 재고 차감
- 배송 생성
- 실패 시 보상
- 배송 취소
- 재고 복구
- 결제 취소
주의: MDX 빌드 에러 방지를 위해 제네릭, 화살표 등 부등호가 들어갈 수 있는 표현은 모두 코드 블록 또는 인라인 코드로 처리합니다.
// workflows/orderWorkflow.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as acts from '../activities/orderActivities';
const {
authorizePayment,
cancelPayment,
reserveStock,
releaseStock,
createShipment,
cancelShipment,
} = proxyActivities<typeof acts>({
startToCloseTimeout: '30s',
retry: {
initialInterval: '1s',
backoffCoefficient: 2,
maximumInterval: '30s',
maximumAttempts: 10,
},
});
export type OrderInput = {
orderId: string;
paymentId: string;
userId: string;
items: Array<{ sku: string; qty: number }>;
address: string;
};
export async function orderSagaWorkflow(input: OrderInput) {
// 보상 실행을 위해 “성공한 단계”만 기록
let paymentAuthorized = false;
let stockReserved = false;
let shipmentCreated = false;
try {
await authorizePayment({
paymentId: input.paymentId,
orderId: input.orderId,
// 멱등 키로 쓰기 좋은 값
idempotencyKey: `wf-${input.orderId}-authorizePayment`,
});
paymentAuthorized = true;
await reserveStock({
orderId: input.orderId,
items: input.items,
idempotencyKey: `wf-${input.orderId}-reserveStock`,
});
stockReserved = true;
await createShipment({
orderId: input.orderId,
address: input.address,
idempotencyKey: `wf-${input.orderId}-createShipment`,
});
shipmentCreated = true;
return { ok: true };
} catch (e) {
// 역순 보상
if (shipmentCreated) {
await cancelShipment({
orderId: input.orderId,
idempotencyKey: `wf-${input.orderId}-cancelShipment`,
});
}
if (stockReserved) {
await releaseStock({
orderId: input.orderId,
idempotencyKey: `wf-${input.orderId}-releaseStock`,
});
}
if (paymentAuthorized) {
await cancelPayment({
paymentId: input.paymentId,
orderId: input.orderId,
idempotencyKey: `wf-${input.orderId}-cancelPayment`,
});
}
throw e;
}
}
여기서 중요한 포인트는 다음입니다.
- 재시도는 액티비티 레벨에서 Temporal이 수행
- 각 액티비티가
idempotencyKey를 받도록 강제 - 워크플로는 성공 여부 플래그로 “보상 필요 단계”만 수행
이 구조는 “실패했으니 처음부터 다시”가 아니라, 실패한 단계만 재시도하고, 이미 성공한 단계는 중복 실행을 피하도록 설계합니다.
액티비티에서 멱등성 보장하기: 유니크 키 테이블 패턴
외부 API가 멱등 키를 지원하지 않거나, 내부 DB 반영이 중복될 수 있다면 서버 측에서 멱등성 테이블을 두는 방식이 안전합니다.
예시로 PostgreSQL에 idempotency_keys 테이블을 두고, key에 유니크 제약을 겁니다.
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
response_json JSONB
);
Node.js 액티비티에서의 사용 예시는 다음입니다.
// activities/orderActivities.ts
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function runIdempotent<T>(key: string, fn: () => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const existing = await client.query(
'SELECT response_json FROM idempotency_keys WHERE key = $1 FOR UPDATE',
[key]
);
if (existing.rowCount === 1 && existing.rows[0].response_json) {
await client.query('COMMIT');
return existing.rows[0].response_json as T;
}
// 키 선점: 없으면 생성
await client.query(
'INSERT INTO idempotency_keys(key) VALUES ($1) ON CONFLICT (key) DO NOTHING',
[key]
);
const result = await fn();
await client.query(
'UPDATE idempotency_keys SET response_json = $2 WHERE key = $1',
[key, JSON.stringify(result)]
);
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
export async function reserveStock(input: {
orderId: string;
items: Array<{ sku: string; qty: number }>;
idempotencyKey: string;
}) {
return runIdempotent(input.idempotencyKey, async () => {
// 재고 차감 로직 (예: 재고 테이블 업데이트)
// 여기서도 가능하면 sku 단위로 조건부 업데이트를 사용
return { reserved: true };
});
}
이 패턴의 장점
- Temporal 재시도, 워커 재시작, 네트워크 중복 호출에도 결과가 한 번만 반영
- “이미 처리된 요청이면 이전 결과를 그대로 반환”이 가능
주의점
- 키 테이블이 무한히 커지지 않도록 TTL 또는 주기적 정리가 필요
- 결과를 저장할지 여부는 단계 성격에 따라 결정
- 결제 승인처럼 “승인 ID”를 재사용해야 하면 저장이 유리
- 단순히 “처리됨 여부”만 필요하면 플래그만 저장해도 됨
타임아웃과 재시도 전략: 무한 재시도는 장애를 숨긴다
Temporal의 재시도는 강력하지만, 잘못 설정하면 장애를 오래 숨기면서 외부 시스템에 부하를 줄 수 있습니다.
실무에서 추천하는 가이드
- 일시적 장애로 회복 가능한 단계
- 짧은
startToCloseTimeout - 지수 백오프 + 최대 시도 횟수
- 짧은
- 사람 개입이 필요한 단계
- 최대 시도 횟수를 낮게
- 실패 시 워크플로를 대기 상태로 전환하고 알림
또한 액티비티 타임아웃은 “HTTP 클라이언트 타임아웃”과 일치시키는 것이 좋습니다. HTTP는 2초인데 액티비티 타임아웃이 30초면, 워커 스레드가 불필요하게 묶일 수 있습니다.
중복처리의 마지막 퍼즐: 워크플로 시그널과 외부 콜백
결제사, 배송사처럼 비동기 콜백이 오는 시스템은 다음 문제가 생깁니다.
- 콜백이 먼저 오고, 워크플로는 아직 그 단계를 시작하지 않음
- 콜백이 중복으로 여러 번 옴
Temporal에서는 이때 워크플로에 Signal을 보내 상태를 업데이트하는 패턴을 씁니다. 핵심은 “콜백 핸들러가 직접 DB를 마무리하지 않고, 워크플로에 신호를 보내 오케스트레이션을 일원화”하는 것입니다.
Signal도 중복 수신될 수 있으니, 워크플로 내부에 “이미 처리됨” 플래그를 두거나, 콜백 이벤트 ID로 멱등 처리합니다.
운영 관점: 재시도 폭주와 워커 장애를 어떻게 다룰까
Temporal 도입 후 흔히 겪는 운영 이슈는 다음입니다.
- 외부 API 장애로 액티비티 재시도가 폭주
- 워커 배포 중 워크플로 처리량 저하
- 특정 단계가 느려 워커 스레드가 고갈
대응 체크리스트
- 액티비티 별로
taskQueue를 분리해 병목 전파를 막기- 결제, 재고, 배송을 같은 큐에 넣으면 결제 장애가 전체를 막을 수 있음
- 외부 API에 대한 동시성 제한
- 워커 레벨에서 rate limit 또는 세마포어
- 실패 알림과 가시성
- “몇 번 실패했는지, 마지막 에러가 무엇인지”를 워크플로/액티비티 메타데이터로 남기기
MSA 환경에서 게이트웨이 레벨의 인증/라우팅 장애가 재시도를 증폭시키는 경우도 많습니다. 이 경우 게이트웨이 트러블슈팅이 선행돼야 합니다. 관련해서는 Kong API Gateway MSA 라우팅·JWT 401 트러블슈팅도 함께 참고하면 좋습니다.
Temporal을 도입하면 “분산 트랜잭션”이 어떻게 바뀌나
Temporal은 2PC 같은 강한 분산 트랜잭션을 제공하지 않습니다. 대신 다음이 바뀝니다.
- 트랜잭션의 단위가 “DB 커밋”이 아니라 “워크플로 실행”으로 확장
- 실패는 예외가 아니라 정상 흐름이 되고, 보상이 1급 시민이 됨
- 재시도는 코드 곳곳이 아니라 정책으로 선언
결과적으로 팀이 얻게 되는 실익은 다음입니다.
- 재시도 로직의 중복 제거
- 중복처리(멱등성)를 단계별로 강제하는 구조
- 장애 시 “어디까지 됐는지”를 추적할 수 있는 관측성
세션/로그인 같은 상태 꼬임도 결국 “동시성 + TTL + 재시도”의 조합에서 많이 발생합니다. 분산 상태를 다루는 감각을 키우는 데는 Spring Boot Redis 세션 꼬임 - TTL·동시로그인 해법 같은 글도 같이 보면 도움이 됩니다.
도입 로드맵: 작은 사가부터 시작하기
Temporal을 처음 넣을 때는 결제 포함 전체 주문을 한 번에 옮기려다 실패하는 경우가 많습니다. 다음 순서를 추천합니다.
- “재시도 때문에 중복이 가장 치명적인” 단계부터 워크플로로 감싸기
- 결제 승인, 포인트 차감 등
- 액티비티 멱등성 테이블을 먼저 도입
- 워크플로 ID를 비즈니스 키로 고정
- 보상 트랜잭션을 역순으로 정리
- 마지막에 시그널 기반 비동기 콜백까지 통합
이 과정을 거치면 “분산 트랜잭션”을 ACID로 만들지 않고도, 실무에서 가장 골치 아픈 재시도·중복처리를 상당 부분 끝낼 수 있습니다.
마무리: 재시도는 기능이 아니라 제품 품질이다
분산 환경에서 재시도는 반드시 발생합니다. 중요한 건 재시도를 “각 서비스의 책임”으로 흩뿌리지 않고, 워크플로라는 제품 레벨의 품질로 끌어올리는 것입니다.
Temporal은 그 중심에 워크플로 히스토리라는 내구성 있는 상태를 두고, 재시도와 보상을 체계화합니다. 여기에 멱등성 키와 유니크 제약 같은 기본기를 결합하면, 중복 결제·중복 차감·중복 배송 같은 사고를 구조적으로 줄일 수 있습니다.