- Published on
Temporal로 분산 트랜잭션 - 중복·재시도·보상처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 서비스(결제, 재고, 배송, 알림 등)를 엮는 순간 “트랜잭션”은 DB BEGIN/COMMIT의 영역을 벗어납니다. 네트워크 타임아웃, 부분 성공, 중복 요청, 재시도 폭주가 일상이고, 결국 사가(Saga) 패턴(보상 트랜잭션)과 멱등성(idempotency), 재시도 정책이 핵심 설계가 됩니다.
Temporal은 이 문제를 “분산 트랜잭션 프레임워크”라기보다 내구성 있는 오케스트레이션 엔진으로 풀어줍니다. 워크플로우 코드가 크래시 나도 상태가 보존되고, 액티비티 재시도/타임아웃이 표준화되며, 보상 로직을 워크플로우로 안전하게 모델링할 수 있습니다.
이 글에서는 Temporal로 분산 트랜잭션을 구성할 때 실무에서 가장 자주 부딪히는 세 가지를 중심으로 설명합니다.
- 중복 실행(중복 주문/중복 결제)
- 재시도(타임아웃/일시 장애)와 재시도 폭주
- 보상 처리(부분 성공 이후 롤백)
Temporal로 보는 분산 트랜잭션의 현실
전통적인 2PC는 마이크로서비스 환경에서 운영 비용과 결합도가 너무 큽니다. 그래서 대부분은 다음 형태로 갑니다.
- 오케스트레이터가 단계별로 호출
- 각 단계는 “성공” 또는 “실패”
- 실패 시 이미 성공한 단계는 보상으로 되돌림
Temporal에서 이 오케스트레이터가 바로 Workflow입니다.
- Workflow: 장기 실행 트랜잭션의 상태 머신
- Activity: 외부 시스템 호출(결제 API, DB 업데이트, 메시지 발행)
- Retry/Timeout: 액티비티 단위로 표준 제공
- Signal/Query: 외부에서 상태 업데이트/조회
핵심은 Workflow는 결정적(deterministic)으로 실행되어야 하고, 외부 I/O는 Activity로 분리한다는 점입니다.
중복(Dedup): “한 번만 처리”는 어디서 보장할까
중복은 두 층에서 발생합니다.
- 클라이언트 재시도(사용자 더블 클릭, 모바일 네트워크)
- 서버 재시도(타임아웃 후 재호출, 워커 크래시 후 재실행)
Temporal은 “워크플로우 실행 자체”에 대해 강력한 중복 방지 수단을 제공합니다.
1) Workflow ID를 비즈니스 키로 고정
가장 단순하고 강력한 패턴은 주문 ID를 Workflow ID로 사용하는 것입니다.
- 주문 생성 요청이 여러 번 와도 같은 Workflow ID로 시작하면 중복 실행을 막을 수 있음
- 정책에 따라 기존 실행을 재사용하거나, 이미 실행 중이면 거절할 수 있음
아래는 TypeScript SDK 예시입니다.
import { Connection, Client } from '@temporalio/client';
import { orderWorkflow } from './workflows';
const connection = await Connection.connect();
const client = new Client({ connection });
async function startOrder(orderId: string, input: { userId: string; items: any[] }) {
const handle = await client.workflow.start(orderWorkflow, {
taskQueue: 'order',
workflowId: `order-${orderId}`,
args: [input],
});
return handle.workflowId;
}
이때 “이미 존재하면 어떻게 할지”는 workflowIdReusePolicy 같은 옵션으로 조절합니다(정책 이름은 SDK/버전에 따라 다를 수 있으니 사용 중인 SDK 문서를 확인하세요). 중요한 건 비즈니스 유일 키로 Workflow ID를 설계하는 습관입니다.
2) Activity 멱등성 키: “외부 시스템” 중복을 막아야 함
Workflow 중복을 막아도 Activity는 재시도될 수 있습니다. 결제 승인 Activity가 두 번 호출되면 큰일입니다.
그래서 Activity 호출에는 다음 중 하나가 필요합니다.
- 외부 API가 idempotency key를 지원하면 반드시 사용
- 내부 DB 업데이트는 유니크 제약과 UPSERT로 멱등 처리
- 메시지 발행은 outbox 패턴 또는 dedup 테이블 사용
결제 API가 idempotencyKey를 지원한다고 가정한 예시입니다.
// activities.ts
export async function capturePayment(input: {
orderId: string;
amount: number;
idempotencyKey: string;
}) {
// 결제사 API에 idempotencyKey 전달
// 네트워크 타임아웃이 나도 같은 키로 재시도하면 결제는 1회만 승인
return paymentProvider.capture({
orderId: input.orderId,
amount: input.amount,
idempotencyKey: input.idempotencyKey,
});
}
Workflow에서는 orderId 기반으로 안정적인 키를 만들어 넘깁니다.
import { proxyActivities } from '@temporalio/workflow';
const { capturePayment } = proxyActivities<typeof import('./activities')>({
startToCloseTimeout: '30s',
retry: {
initialInterval: '1s',
maximumInterval: '30s',
backoffCoefficient: 2,
maximumAttempts: 8,
},
});
export async function orderWorkflow(input: { userId: string; items: any[] }) {
const orderId = await createOrderIdSomehow();
await capturePayment({
orderId,
amount: 1000,
idempotencyKey: `pay-${orderId}`,
});
}
정리하면 이렇습니다.
- Workflow ID로 “오케스트레이션 중복”을 차단
- Activity 멱등성으로 “외부 부작용 중복”을 차단
둘 중 하나만 해서는 사고가 납니다.
재시도(Retry): “실패는 정상”을 시스템으로 흡수하기
Temporal의 장점은 재시도를 “개발자 개인의 구현”이 아니라 플랫폼 기능으로 끌어올린다는 점입니다. 다만 재시도는 잘못 설정하면 장애를 키웁니다.
1) Timeout을 먼저 설계하고, Retry는 그 다음
재시도는 타임아웃과 세트입니다.
startToCloseTimeout: 액티비티 한 번 실행의 최대 시간scheduleToStartTimeout: 워커가 잡을 때까지 대기 시간(큐 적체 시 중요)scheduleToCloseTimeout: 대기+실행 전체 상한
타임아웃이 없으면 “영원히 대기”하거나, 반대로 너무 짧으면 정상 요청도 실패로 분류되어 재시도 폭탄이 됩니다.
쿠버네티스 환경에서는 워커가 재시작되거나 노드가 흔들리면 타임아웃/재시도가 눈덩이처럼 커질 수 있습니다. 특히 헬스체크 실패로 파드가 반복 재시작되는 상황이면 액티비티 실패율이 급증합니다. 관련해서는 EKS Pod 1분마다 재시작? livenessProbe 실패 해결 같은 체크리스트 관점의 접근이 Temporal 워커 운영에도 그대로 적용됩니다.
2) 재시도는 “일시적 오류”에만
모든 실패를 재시도하면 안 됩니다.
- 재시도해야 하는 것: 네트워크 타임아웃, 5xx, rate limit, DB deadlock
- 재시도하면 안 되는 것: 4xx 검증 실패, 잔액 부족, 재고 없음 같은 비즈니스 실패
Temporal에서는 Activity에서 “재시도 불가” 오류로 분류되도록 에러 타입을 나누는 패턴을 씁니다(언어 SDK별로 non-retryable 설정 방식이 다릅니다).
예시(개념 코드):
export class NonRetryableError extends Error {
constructor(message: string) {
super(message);
this.name = 'NonRetryableError';
}
}
export async function reserveInventory(input: { orderId: string; items: any[] }) {
const ok = await inventory.tryReserve(input.items);
if (!ok) throw new NonRetryableError('OUT_OF_STOCK');
return { reserved: true };
}
그리고 액티비티 옵션에서 해당 에러를 재시도 제외로 처리합니다(구체 옵션은 SDK 문서에 맞춰 적용).
3) 재시도 폭주를 막는 백오프와 서킷 브레이크
Temporal의 지수 백오프는 기본 방어선입니다. 하지만 외부 의존성이 완전히 죽은 경우에는 “재시도 자체”가 트래픽을 더 올립니다.
실무 팁:
- 결제/배송 같은 외부 API는 별도 rate limit, 동시성 제한 적용
- 워커 프로세스에서 액티비티 동시 실행 수 제한
- 외부 API에 서킷 브레이커(예: 일정 실패율 이상이면 즉시 실패) 적용 후 Temporal 재시도에 맡기기
네트워크 레벨 문제(라우팅/방화벽/보안그룹)로 타임아웃이 반복될 때는 애플리케이션 재시도만으로는 해결이 안 됩니다. 인프라 원인 진단 관점은 Azure VM SSH 타임아웃 - NSG·UDR 진단 체크리스트처럼 “패킷이 어디서 막히는지”를 먼저 확인하는 접근이 도움이 됩니다.
보상(Compensation): “되돌리기”는 기능이 아니라 설계다
사가 패턴의 본질은 이겁니다.
- 모든 단계를 원자적으로 묶을 수 없으니
- 성공한 단계는 기록하고
- 실패하면 기록을 역순으로 보상한다
Temporal Workflow는 이 보상 흐름을 코드로 명확하게 표현할 수 있습니다.
1) 보상은 “역순” + “멱등”이 기본
보상 액티비티도 재시도될 수 있습니다. 따라서 보상 또한 멱등해야 합니다.
- 결제 취소: 같은 결제 키로 여러 번 취소 요청해도 안전해야 함(결제사 정책 확인)
- 재고 해제: 이미 해제된 예약이면 no-op
- 배송 취소: 이미 픽업된 경우 취소 불가 등 상태 기반 처리
2) Workflow에서 보상 스택을 관리하는 패턴
아래는 TypeScript로 “성공한 단계마다 보상 함수를 스택에 쌓고, 실패 시 역순 실행”하는 전형적인 패턴입니다.
import { proxyActivities } from '@temporalio/workflow';
const activities = proxyActivities<typeof import('./activities')>({
startToCloseTimeout: '60s',
retry: {
initialInterval: '1s',
maximumInterval: '1m',
backoffCoefficient: 2,
maximumAttempts: 10,
},
});
type Compensation = () => Promise<void>;
export async function orderWorkflow(input: {
orderId: string;
userId: string;
items: any[];
amount: number;
}) {
const compensations: Compensation[] = [];
try {
// 1) 재고 예약
await activities.reserveInventory({ orderId: input.orderId, items: input.items });
compensations.push(() => activities.releaseInventory({ orderId: input.orderId }));
// 2) 결제 승인
await activities.capturePayment({
orderId: input.orderId,
amount: input.amount,
idempotencyKey: `pay-${input.orderId}`,
});
compensations.push(() => activities.refundPayment({ orderId: input.orderId }));
// 3) 배송 생성
const shipmentId = await activities.createShipment({ orderId: input.orderId });
compensations.push(() => activities.cancelShipment({ shipmentId }));
// 4) 주문 확정
await activities.markOrderCompleted({ orderId: input.orderId });
return { ok: true };
} catch (err) {
// 실패 시 보상은 역순
for (let i = compensations.length - 1; i >= 0; i -= 1) {
try {
await compensations[i]();
} catch (compErr) {
// 보상 실패는 “추가 처리”가 필요
// - 알람
// - 수동 개입 큐
// - 별도 워크플로우로 재시도
await activities.recordCompensationFailure({
orderId: input.orderId,
reason: String(compErr),
});
}
}
await activities.markOrderFailed({ orderId: input.orderId, reason: String(err) });
throw err;
}
}
이 패턴의 장점:
- 어떤 단계가 성공했고, 어떤 보상이 필요한지 코드로 명확
- 실패 지점이 어디든 동일한 보상 루틴으로 정리
주의할 점:
- 보상은 “원복”이 아니라 “반대 방향의 새 작업”입니다. 이미 외부 상태가 바뀌었을 수 있습니다.
- 배송이 이미 진행되었으면 취소 대신 “반품 프로세스”로 전환해야 합니다. 즉, 보상은 도메인 규칙을 반드시 반영해야 합니다.
3) 보상 실패를 시스템적으로 다루기
보상 실패는 생각보다 흔합니다.
- 결제 취소 API가 장애
- 배송 취소 불가 상태
- 재고 시스템이 다운
이때 “워크플로우를 그냥 실패로 끝내기”만 하면 운영이 지옥이 됩니다. 다음 중 하나를 설계해야 합니다.
- 보상 실패를 별도 테이블에 적재하고 운영자가 처리
- 보상 전용 워크플로우를 분리해 장기 재시도
- 최종적으로는 “회계적으로 맞추는” 정산 작업(사후 조정)
Temporal은 워크플로우 상태가 남기 때문에, 실패 케이스를 수집하고 재처리하는 파이프라인을 만들기 좋습니다.
중복·재시도·보상처리를 함께 맞물리게 하는 체크리스트
Temporal을 도입해도 아래를 놓치면 사고는 그대로 납니다.
1) 모든 Activity는 멱등해야 한다
- “재시도될 수 있다”를 기본 전제로 두기
- 외부 API는 idempotency key 사용
- 내부 DB는 유니크 키 + UPSERT
2) 재시도 정책은 서비스별로 다르게
- 결제: 짧은 타임아웃 + 제한된 재시도(결제사는 중복에 민감)
- 재고: 비교적 공격적인 재시도 가능(내부 시스템이면 더)
- 알림: 실패해도 비즈니스 핵심이 아니면 느슨하게
3) 보상은 “가능/불가능” 상태를 모델링
- 취소 불가 상태에서 무한 재시도하지 않기
- 취소 대신 반품/정산 등 다음 프로세스로 전환
4) 운영 관점: 워커 안정성이 곧 트랜잭션 안정성
워크플로우는 살아있어도 워커가 불안정하면 액티비티가 계속 실패합니다. 배포/권한 문제로 워커가 외부 리소스에 접근을 못해 장애가 나는 경우도 많습니다. CI/CD에서 클라우드 권한을 안전하게 다루는 방법은 GitHub Actions OIDC로 AWS 키 없이 배포 오류 해결 같은 글의 관점을 참고해 “비밀키 없이도 안정적으로 권한을 부여”하는 방식으로 정리해두면 운영 리스크가 줄어듭니다.
마무리: Temporal은 만능이 아니라 ‘규율’을 강제한다
Temporal이 분산 트랜잭션을 “자동으로” 해결해주지는 않습니다. 대신 다음을 강하게 강제합니다.
- 재시도는 일상이며, 그에 맞춰 멱등성을 설계해야 한다
- 장기 실행 상태를 코드로 명시하고, 실패/보상을 워크플로우로 모델링해야 한다
- 운영에서 워커/의존성 안정성이 곧 트랜잭션 품질이다
Temporal로 중복·재시도·보상처리를 제대로 맞추면, 분산 환경에서 가장 골치 아픈 “부분 성공”과 “중복 부작용”을 시스템적으로 통제할 수 있습니다. 다음 단계로는 워크플로우 버전닝(호환성), Search Attribute로 운영 쿼리 고도화, 보상 실패를 위한 별도 재처리 워크플로우 설계를 함께 가져가면 좋습니다.