서버가 꺼져도 워크플로는 이어진다: Temporal.io Durable Execution으로 결제·알림·LLM 파이프라인 안정화하기
결제 API 호출이 끝난 직후 서버가 죽었다고 상상해 보세요. Stripe에는 이미 청구가 완료됐는데 주문 데이터베이스엔 기록이 없습니다. 재시도하면 이중 청구고, 재시도 안 하면 돈만 빠져나간 채 주문은 미완성입니다. 저도 처음 이 상황을 마주쳤을 때 "멱등성 키만 잘 쓰면 되겠지"라고 생각했는데, 현실에서는 그 위에 메시지 큐, Redis 락, 별도 상태 테이블을 복잡하게 엮어야 했습니다. 그리고 그 엉킴에서 언제나 엣지 케이스가 남았습니다.
Temporal.io는 멱등성 키를 대체하는 도구가 아니라, 그 위에 실행 상태 자체를 영속화하는 층을 얹어 문제를 다른 차원에서 해결합니다. 분산 시스템의 장기 실행 워크플로를 안정적으로 처리하는 Durable Execution 플랫폼으로, 서버가 크래시되거나 네트워크가 끊기거나 배포로 재시작되어도 워크플로가 중단 없이 재개됩니다. Netflix, Stripe, Coinbase, Snap, NVIDIA 등이 프로덕션에서 사용하고 있으며, 각 사례의 구체적 도입 맥락은 글 하단의 공식 사례 자료에서 확인할 수 있습니다.
이 글에서는 Durable Execution이 어떻게 동작하는지, 그리고 결제·알림·LLM 파이프라인이라는 세 시나리오에서 어떻게 활용할 수 있는지를 TypeScript SDK 코드와 함께 정리합니다. 구조부터 짚은 뒤 실전 코드로 넘어가겠습니다.
핵심 개념
Temporal이 해결하는 문제
기존 분산 워크플로 처리의 핵심 문제는 상태 관리의 책임이 애플리케이션 코드에 있다는 점입니다. 결제 → 로그 기록 → 이메일 발송이라는 3단계 프로세스라면, 어느 단계까지 성공했는지 직접 DB에 기록하고 재시도 시 그 상태를 읽어 이어야 합니다. 이게 쌓이면 비즈니스 로직보다 인프라 관리 코드가 더 많아집니다.
Temporal은 이 상태 관리를 플랫폼이 책임집니다. 개발자는 "무엇을 해야 하는가"만 코드로 표현하고, "어디까지 진행되었는가"는 이벤트 히스토리에 자동 기록됩니다.
핵심 구성 요소
| 구성 요소 | 역할 |
|---|---|
| Workflow | 비즈니스 로직 전체 흐름을 정의하는 함수. "주문 처리", "사용자 온보딩" 같은 전체 시나리오. |
| Activity | 실제 I/O 작업 단위. 결제 API 호출, 이메일 발송, DB 조회 등 외부 시스템과의 접점. |
| Worker | Workflow·Activity 코드를 실제로 실행하고 Temporal 서버와 통신하는 프로세스. |
| Task Queue | Worker와 Temporal 서버 간 작업을 연결하는 큐. |
| Event History | 워크플로의 모든 상태 변화를 이벤트 로그로 영속 저장하는 핵심 메커니즘. |
Worker의 위치가 처음엔 헷갈릴 수 있는데, 정리하면 이렇습니다.
- **Temporal 서버(클러스터)**는 상태 저장과 오케스트레이션만 담당하며 사용자 코드를 실행하지 않습니다.
- Worker는 개발자가 별도 프로세스로 배포하는 사용자 코드 실행 런타임입니다. 서비스 코드베이스에서
Worker.create({...})로 부트스트랩하며, API 서버와 동일 저장소에 있어도 되고 별도 배포 단위로 분리해도 됩니다. - API 서버(클라이언트)는
client.workflow.start()로 워크플로 실행을 요청만 하고, 실제 실행은 Task Queue를 구독 중인 Worker가 가져가 처리합니다.
핵심 규칙 하나를 기억해두시면 좋습니다. Workflow 코드는 반드시 결정론적이어야 합니다. Date.now(), Math.random(), 외부 API 직접 호출은 Workflow 함수 안에서 쓸 수 없고, 전부 Activity로 분리해야 합니다. 저도 초반에 Workflow 안에서 날짜 계산을 하다 Replay 오류를 겪었는데, 이 규칙만 지키면 대부분의 문제가 사전에 방지됩니다.
Event History Replay: 어떻게 중단 지점을 기억하는가
Temporal의 핵심은 Event Sourcing에 있습니다. Workflow 실행 중 발생하는 모든 상태 변화—Workflow 시작, Activity 예약, Activity 완료, Timer 발화—가 불변의 이벤트 로그로 클러스터에 영속 저장됩니다.
Worker 크래시 감지는 마법이 아니라 두 가지 타임아웃의 조합으로 이루어집니다.
- Activity Heartbeat Timeout: 장기 실행 Activity가 주기적으로
heartbeat()를 보내다 중단되면, Temporal 서버가 설정된 heartbeat 간격 초과를 감지해 실패 처리합니다. - StartToClose / ScheduleToClose Timeout: Activity가 지정 시간 내에 완료 보고를 못 하면 자동 실패 처리됩니다.
즉, Temporal은 Worker 프로세스의 죽음을 직접 관찰하는 게 아니라 "정해진 시간 안에 응답이 오지 않았다"는 사실로 실패를 판정합니다. 실패로 판정된 Activity는 재시도 정책에 따라 다시 스케줄되고, 다른 Worker가 이를 집어 실행합니다. 이때 Workflow는 이벤트 히스토리를 처음부터 Replay해 이미 완료된 Activity 결과는 로그에서 복원하고, 미완료 지점부터 재개합니다.
결제 Activity가 이미 성공했다면 이후 로그 기록 Activity만 재시도 대상이 됩니다. 결제 자체는 재호출되지 않으니 이중 청구가 원천 차단됩니다.
Saga 패턴: 실패 시 자동 보상
분산 트랜잭션에서 일부 단계가 실패했을 때 이미 완료된 단계를 역순으로 되돌리는 것이 Saga 패턴입니다. Temporal은 이를 SDK 수준에서 자연스럽게 표현할 수 있습니다. 아래 다이어그램의 흐름을 뒤이어 결제 예시 코드로 그대로 옮깁니다.
어떤 도구를 선택할까
Temporal이 모든 상황의 정답은 아닙니다. 용도에 따라 적합한 도구가 다릅니다.
| 도구 | 특화 영역 |
|---|---|
| Temporal | 미션 크리티컬 내구성, 범용 분산 워크플로 |
| Prefect | Python 네이티브, ML·데이터 사이언스 파이프라인 |
| Airflow | 배치 데이터 파이프라인, DAG 기반 스케줄링 |
| Kestra | YAML 기반 ETL·데이터 파이프라인 |
| Camunda | BPMN 기반 비즈니스 프로세스 자동화 |
결제·알림·LLM처럼 실패 시 비용이 크고 순서·내구성이 핵심인 워크플로라면 Temporal이 유력한 후보입니다.
실전 적용
결제 파이프라인: 이중 청구 없는 재시도
먼저 Activity를 정의합니다. 외부 시스템과의 모든 접점이 여기에 위치하며, 재시도 정책과 상호작용하는 에러 클래스도 함께 정의합니다. 참고로 stripe.charges.create()는 2019년부터 레거시로 분류되어 있으니, 새 코드는 PaymentIntents API를 사용하는 편이 안전합니다. 그리고 Stripe SDK에서 idempotencyKey는 첫 번째 인자(파라미터 객체)가 아니라 **두 번째 인자(옵션 객체)**로 전달해야 실제로 적용됩니다.
// activities.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export class PaymentDeclinedError extends Error {
constructor(message: string) {
super(message);
this.name = 'PaymentDeclinedError';
}
}
export async function chargePayment(
orderId: string,
amount: number,
): Promise<string> {
try {
const intent = await stripe.paymentIntents.create(
{
amount,
currency: 'krw',
confirm: true,
payment_method: await resolvePaymentMethod(orderId),
metadata: { orderId },
},
{ idempotencyKey: `charge-${orderId}` },
);
return intent.id;
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
throw new PaymentDeclinedError(err.message);
}
throw err;
}
}
export async function reserveInventory(orderId: string): Promise<string> {
return inventoryService.reserve(orderId);
}
export async function releaseInventory(reservationId: string): Promise<void> {
await inventoryService.release(reservationId);
}
export async function registerShipment(orderId: string): Promise<string> {
return shippingService.register(orderId);
}
export async function refundPayment(chargeId: string): Promise<void> {
await stripe.refunds.create({ payment_intent: chargeId });
}Workflow 코드는 순수한 흐름만 표현합니다. proxyActivities 옵션이 낯설 수 있어 주요 항목만 짚어둡니다.
startToCloseTimeout: Activity 한 번의 실행이 완료되어야 하는 최대 시간. 초과 시 실패 처리 후 재시도 정책이 발동합니다.retry.maximumAttempts: 재시도 횟수 상한.retry.backoffCoefficient: 재시도 간 대기 시간의 증가 배수. 2로 두면 1초 → 2초 → 4초 형태로 늘어납니다.retry.nonRetryableErrorTypes:error.name이 이 목록에 있으면 즉시 실패로 판정하고 재시도하지 않습니다.PaymentDeclinedError처럼 재시도해봐야 결과가 같은 에러를 골라 지정합니다.
// workflows.ts
import { proxyActivities, ApplicationFailure } from '@temporalio/workflow';
import type * as activities from './activities';
const acts = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
retry: {
maximumAttempts: 3,
backoffCoefficient: 2,
nonRetryableErrorTypes: ['PaymentDeclinedError'],
},
});
export async function orderPaymentWorkflow(params: {
orderId: string;
amount: number;
}): Promise<void> {
const compensations: Array<() => Promise<void>> = [];
try {
const chargeId = await acts.chargePayment(params.orderId, params.amount);
compensations.push(() => acts.refundPayment(chargeId));
const reservationId = await acts.reserveInventory(params.orderId);
compensations.push(() => acts.releaseInventory(reservationId));
await acts.registerShipment(params.orderId);
} catch (err) {
for (const compensate of compensations.reverse()) {
await compensate();
}
throw err;
}
}여기서 놓치기 쉬운 설계 포인트가 하나 있습니다. 모든 에러를 곧바로 보상 실행으로 몰면 안 됩니다. 예컨대 registerShipment가 일시적 네트워크 오류로 실패했다면 그건 Activity 재시도 정책의 몫이고, 재시도 후에도 실패했을 때만 Saga 보상이 발동해야 합니다. Temporal의 Activity 재시도는 위 proxyActivities 옵션에서 이미 구성되어 있고, maximumAttempts 소진 후에야 예외가 Workflow로 전파됩니다. 반대로 "결제는 성공했지만 배송이 근본적으로 불가능한 상황"처럼 재시도가 의미 없는 에러는 Activity 내부에서 ApplicationFailure.nonRetryable(...)로 던져야 즉시 보상 흐름으로 넘어갑니다. 재시도로 회복 가능한 에러와 보상으로만 회복 가능한 에러를 Activity 레벨에서 분리하는 것이 Saga 설계의 실질적 핵심입니다.
클라이언트에서는 Workflow ID로 payment-${orderId}를 지정해 동일 주문에 대한 워크플로 중복 실행 자체를 차단합니다.
// client.ts
import { Client } from '@temporalio/client';
import { orderPaymentWorkflow } from './workflows';
const client = new Client();
const handle = await client.workflow.start(orderPaymentWorkflow, {
args: [{ orderId, amount }],
taskQueue: 'payment-queue',
workflowId: `payment-${orderId}`,
});Temporal의 workflowId 중복 방지와 Stripe의 idempotencyKey가 서로를 대체하지 않는다는 점도 짚어둡니다. workflowId는 워크플로 실행 자체의 중복을 막고, Stripe idempotency 키는 Activity 재시도가 발생했을 때 Stripe 측에서 동일 청구를 중복 처리하지 않도록 방어합니다. 두 층이 겹쳐야 이중 청구 리스크가 실질적으로 사라집니다.
알림 파이프라인: 순서 보장과 내구성 있는 타이머
sleep은 Temporal의 핵심 기능 중 하나입니다. Worker가 재시작되어도 타이머 상태가 보존되어, 인-메모리 setTimeout이 배포와 함께 날아가는 일이 없습니다.
// workflows.ts
import { proxyActivities, sleep } from '@temporalio/workflow';
import type * as activities from './activities';
const acts = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
retry: { maximumAttempts: 5 },
});
export async function orderNotificationWorkflow(params: {
userId: string;
orderId: string;
}): Promise<void> {
await acts.sendPushNotification(params.userId, '주문이 접수되었습니다');
await sleep('5 minutes');
await acts.sendEmailNotification(params.userId, params.orderId);
await sleep('24 hours');
await acts.sendShippingUpdateNotification(params.userId, params.orderId);
}Signal을 활용하면 외부 이벤트로 진행 중인 워크플로 흐름을 제어할 수 있습니다. Human-in-the-loop 승인 대기 구현에 특히 유용합니다.
import { defineSignal, setHandler, condition } from '@temporalio/workflow';
const approvalSignal = defineSignal<[boolean]>('approval');
export async function approvalWorkflow(requestId: string): Promise<string> {
let approved = false;
let received = false;
setHandler(approvalSignal, (isApproved: boolean) => {
approved = isApproved;
received = true;
});
await acts.notifyApprover(requestId);
const didReceive = await condition(() => received, '72 hours');
if (!didReceive) return 'timeout';
return approved ? 'approved' : 'rejected';
}LLM 파이프라인: 고비용 호출 재청구 방지
LLM API 호출은 비용이 높고 실패율도 존재합니다. 중간 단계 실패 시 이미 완료된 LLM 호출까지 재실행하는 상황이 Temporal에서는 발생하지 않습니다. 이미 성공한 Activity는 이벤트 히스토리에서 결과를 가져올 뿐, 재실행되지 않기 때문입니다.
// activities.ts
export async function splitDocument(documentId: string): Promise<string[]> {
const document = await storage.get(documentId);
return chunkText(document, 512);
}
export async function generateEmbedding(chunk: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: chunk,
});
return response.data[0].embedding;
}
export async function summarizeChunks(chunks: string[]): Promise<string> {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: '주어진 문단들을 한 단락으로 요약합니다.' },
{ role: 'user', content: chunks.join('\n\n---\n\n') },
],
});
return response.choices[0].message.content ?? '';
}
export async function storeEmbeddings(
documentId: string,
embeddings: number[][],
): Promise<void> {
await vectorDB.upsert({ id: documentId, embeddings });
}
export async function storeResult(
documentId: string,
summary: string,
): Promise<void> {
await db.summaries.insert({ documentId, summary });
}Workflow에서는 청크를 실제 프롬프트로 전달하고, 병렬 임베딩 생성은 동시성 제한을 함께 고려합니다. Promise.all(chunks.map(...))을 그대로 두면 청크 수만큼 Activity가 동시에 스케줄되어 OpenAI rate limit에 부딪히기 쉽습니다. 실무에서는 두 가지 완충 장치를 자주 씁니다.
- Task Queue 워커의
maxConcurrentActivityTaskExecutions: Worker당 동시 실행 Activity 수를 제한합니다. - 애플리케이션 레벨 배치·세마포어: 아래처럼 청크를 배치로 잘라 순차 처리하거나
p-limit같은 제한자를 사용합니다.
// workflows.ts
export async function documentProcessingWorkflow(params: {
documentId: string;
}): Promise<string> {
const chunks = await acts.splitDocument(params.documentId);
const batchSize = 8;
const embeddings: number[][] = [];
for (let i = 0; i < chunks.length; i += batchSize) {
const batch = chunks.slice(i, i + batchSize);
const batchEmbeddings = await Promise.all(
batch.map((chunk) => acts.generateEmbedding(chunk)),
);
embeddings.push(...batchEmbeddings);
}
await acts.storeEmbeddings(params.documentId, embeddings);
const summary = await acts.summarizeChunks(chunks);
await acts.storeResult(params.documentId, summary);
return summary;
}summarizeChunks가 성공한 이후 storeResult가 실패해도, 요약 호출은 재실행되지 않습니다. 이벤트 히스토리에서 이미 완료된 결과를 가져와 storeResult만 재시도합니다. 수십 센트에서 수 달러에 이르는 호출을 반복해서 지불하지 않아도 된다는 뜻입니다.
LLM 토큰 스트리밍이 필요한 경우, Temporal 팀이 최근 Signal & Update 프리미티브 기반의 스트리밍 활용 사례를 공유해 왔습니다. 구체 API와 정식 위치는 하단 참고 자료의 공식 블로그 및 릴리스 노트에서 확인하시는 편이 정확합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 내구성 보장 | 크래시·네트워크 단절·재배포 중에도 워크플로가 중단 없이 재개됩니다 |
| 이중 실행 방지 | 성공한 Activity는 재실행되지 않아 이중 청구·이중 발송이 원천 차단됩니다 |
| 장기 실행 지원 | 수 초에서 수 년에 걸친 워크플로를 동일한 추상화로 처리할 수 있습니다 |
| 코드 중심 설계 | Go, TypeScript, Python, Java, .NET 등 익숙한 언어로 워크플로를 작성합니다 |
| 가시성 | 내장 UI로 워크플로 상태·실패 이유·실행 히스토리를 실시간 확인할 수 있습니다 |
| 프로덕션 검증 | Netflix, Stripe, Coinbase, Snap, NVIDIA 등 다수의 대규모 서비스에서 사용 사례가 공개되어 있습니다 |
단점 및 고려사항
| 항목 | 내용 |
|---|---|
| 학습 곡선 | Durable Execution 모델이 기존 큐·스케줄러와 근본적으로 다릅니다 |
| 결정론적 코드 요구 | random(), Date.now(), 외부 API 직접 호출은 Workflow 코드 내에서 사용이 불가합니다 |
| 운영 복잡도 | 자체 호스팅 시 Temporal 클러스터(서버, DB, Elasticsearch) 운영이 필요합니다 |
| 이벤트 히스토리 한도 | 이벤트가 과도하게 쌓이면 성능에 영향을 줍니다. Continue-As-New 패턴으로 해소할 수 있습니다 |
| 버전닝 필수 | 실행 중인 워크플로가 있는 상태에서 코드 변경 시 Worker Versioning 전략이 필요합니다 |
| 데이터·ML 특화 기능 부족 | Prefect, Airflow 대비 데이터 중심 오케스트레이션 특화 기능은 제한적입니다 |
실무에서 흔한 실수
1. Workflow 코드 안에서 결정론적이지 않은 코드 사용
// 잘못된 예 — Replay 시 다른 값이 나와 오류 발생
export async function myWorkflow() {
const timestamp = Date.now();
const value = Math.random();
}
// 올바른 예 — Activity에서 처리
export async function myWorkflow() {
const timestamp = await acts.getCurrentTimestamp();
}2. Workflow 코드 변경 시 버전닝 없이 배포
실행 중인 Workflow가 있는 상태에서 Workflow 함수 내 로직을 변경하면 Replay가 실패합니다. patched() API로 구버전·신버전을 구분하거나, Worker Versioning으로 실행을 격리한 후 롤아웃하는 전략이 필요합니다.
3. 이벤트 히스토리 무한 증가
단일 Workflow가 수만 개의 이벤트를 쌓으면 성능에 영향을 줍니다. 반복 작업이 많다면 continueAsNew로 이벤트 히스토리를 주기적으로 초기화하는 방법이 권장됩니다.
import { continueAsNew } from '@temporalio/workflow';
export async function longRunningWorkflow(iteration: number): Promise<void> {
await acts.processNextBatch(iteration);
if (iteration < 10000) {
await continueAsNew<typeof longRunningWorkflow>(iteration + 1);
}
}마치며
Temporal은 분산 시스템에서 "상태 관리를 애플리케이션 코드가 아닌 플랫폼이 책임진다"는 발상의 전환을 제공합니다. 결제 이중 청구, 알림 누락, LLM 호출 낭비 같은 문제들이 인프라 수준에서 해결되고, 개발자는 비즈니스 로직에 집중할 수 있게 됩니다.
처음엔 "큐 잘 쓰면 되는 거 아닌가"라는 반응이 자연스럽습니다. 하지만 Saga 보상, 장기 타이머, Human-in-the-loop 승인 대기, LLM 파이프라인의 부분 재시도 같은 요구가 겹치기 시작하면 큐 위에 얹은 상태 테이블과 재시도 로직이 급격히 복잡해집니다. 그 지점이 Temporal의 추상화가 실제 코드 라인 수를 줄여주는 구간입니다.
지금 도입을 검토한다면 아래 순서를 권합니다. 각 단계는 다음 단계의 리스크를 낮추기 위한 최소 검증 지점입니다.
- 로컬 Temporal 클러스터 구동으로 개발 루프 확보: 공식
temporalio/docker-compose레포를 내려받아docker compose up으로 시작합니다. 내장 UI(localhost:8080)에서 이벤트 히스토리와 실패 원인을 눈으로 확인할 수 있어, SDK 문법을 익히는 데 드는 시간을 크게 줄여줍니다. - 기존 코드베이스의 가장 단순한 비동기 작업 하나부터 Activity로 이식: 이메일 발송이나 외부 API 호출처럼 실패 시 재시도 가치가 명확한 지점을 첫 대상으로 잡습니다. 격리된 스코프에서 Replay·재시도 동작을 관찰한 뒤 확장해야 이후 마이그레이션에서 결정론 규칙 위반 같은 실수를 최소화할 수 있습니다.
- Temporal Cloud 무료 티어로 프로덕션 파일럿: 셀프호스팅 부담 없이 workflowId 기반 중복 방지와 자동 재시도를 실제 트래픽에서 검증할 수 있습니다. 여기까지 통과하면 자체 호스팅 여부·클러스터 규모 산정 같은 운영 판단을 실제 데이터로 내릴 수 있습니다.
AI 에이전트 오케스트레이션 분야에서도 Temporal이 SDK 통합과 스트리밍·외부 스토리지 관련 기능을 지속적으로 확장하고 있습니다. 최신 로드맵과 세부 사양은 공식 블로그와 릴리스 노트를 통해 직접 확인하는 것이 가장 확실합니다.
참고 자료
- Temporal 공식 문서 — Durable Execution 이해
- Temporal 공식 블로그 — Durable Execution 정의
- Temporal 공식 문서 — 사용 사례 및 설계 패턴
- Temporal 공식 블로그 — AI/ML 및 데이터 엔지니어링 워크플로
- Temporal 공식 블로그 — Saga 패턴 적용
- Temporal 사례 연구 — Coinbase
- Temporal 공식 — Stripe 사례
- Temporal 공식 — Netflix 사례
- Stripe 공식 문서 — PaymentIntents API
- Stripe 공식 문서 — Idempotent Requests
- GitHub — Temporal TypeScript 샘플
- GitHub — Temporal Go 샘플
- Medium — Temporal AI 워크플로: 신뢰할 수 있는 LLM 파이프라인 구축
- DZone — Temporal 워크플로 설계 패턴
- HackerNoon — Temporal 비교 및 적용 시점