Temporal로 장기 실행 비즈니스 워크플로 구현하기
프로덕션에서 결제 워크플로가 중단된 뒤 어디까지 실행됐는지 파악하는 데 반나절을 쓴 적 있으신가요? 저희 팀에서는 Worker 프로세스 재시작 이후 재고는 차감된 채 결제 여부가 불확실한 주문이 7건 남았고, 고객 지원팀과 이틀을 써서 하나씩 수동으로 정리했습니다. 이중 청구 4건, 미청구 3건이었습니다.
당시 대응책은 Redis 큐 + DB 상태 컬럼 + 재처리 크론 잡이었는데, 각 구성 요소가 서로의 실패를 메워주다 보니 코드 복잡도가 기하급수적으로 올라갔습니다.
Temporal은 이 문제를 근본적으로 다르게 접근합니다. "실패를 어떻게 감지하고 복구할까"가 아니라, "실패가 존재하지 않는 것처럼 비즈니스 로직만 작성" 하게 해주는 프레임워크입니다. Worker가 죽어도, 서버가 재시작돼도 — 워크플로는 마지막 완료 지점에서 정확히 재개됩니다.
Temporal의 핵심 아이디어: Durable Execution
일반적인 백그라운드 잡 시스템에서 Worker가 중간에 죽으면 부분 실행 상태를 직접 추적해야 합니다. Temporal의 핵심 아이디어는 다릅니다. 모든 실행 이벤트를 Event History에 영속적으로 기록하고, 장애 후 재시작 시 이 히스토리를 재생(replay) 해 상태를 그대로 복원합니다.
이 구조 덕분에 개발자는 복구 로직 대신 정상 경로의 비즈니스 로직에만 집중할 수 있습니다.
전체 아키텍처 흐름
코드를 보기 전에 구성 요소들이 어떻게 연결되는지 먼저 파악하면 이후 예제들이 훨씬 자연스럽게 읽힙니다.
Worker는 수평으로 확장할 수 있고, 하나가 죽으면 다른 Worker가 Event History를 재생해 이어받습니다. 클라이언트는 Temporal 서버와만 통신하며, Worker의 장애 여부를 알 필요가 없습니다.
네 가지 핵심 구성 요소
Workflow — 비즈니스 로직의 뼈대
Workflow는 비즈니스 프로세스를 순수 TypeScript 함수로 표현합니다. 반드시 결정론적(deterministic) 으로 작성해야 합니다. 같은 Event History를 재생했을 때 항상 동일한 실행 순서가 보장되어야 하기 때문입니다.
Workflow 코드에서 직접 HTTP 요청은 허용되지 않습니다. 시간 참조는
Date.now()대신@temporalio/workflow에서 제공하는now()를 사용합니다. TypeScript SDK는 샌드박스에서Date.now()를 패치하지만, SDK 제공 API를 쓰는 것이 관용적이고 다른 언어 SDK로 마이그레이션할 때도 일관성을 유지합니다.
모든 I/O와 부작용은 Activity에 위임합니다.
Activity — 실제 일이 일어나는 곳
Activity는 외부 API 호출, DB 쿼리, 결제 처리 같은 부작용이 있는 작업을 담당합니다. 기본적으로 지수 백오프(exponential backoff)와 함께 자동 재시도가 적용됩니다.
Worker — 실행 엔진
Worker는 Temporal 서버로부터 태스크를 폴링해서 Workflow와 Activity를 실행하는 프로세스입니다. 여러 Worker를 띄워 수평 확장이 가능하고, 하나가 죽어도 다른 Worker가 이어받습니다.
Signal / Query — 실행 중 상호작용
- Signal: 실행 중인 워크플로에 외부 입력을 비동기로 전달 (예: 결제 승인, 취소 요청)
- Query: 현재 워크플로 상태를 동기적으로 조회 (예: 주문 진행 단계 확인)
Saga 패턴 — 분산 트랜잭션 롤백을 언어 그대로
분산 시스템에서 "A는 성공했는데 B가 실패한 경우 A를 어떻게 되돌리나"는 오래된 문제입니다. 데이터베이스 트랜잭션처럼 한 번에 롤백할 수 없으니까요.
Temporal은 이 Saga 패턴을 특별한 프레임워크 없이 네이티브 try/catch로 표현하게 해줍니다. 완료된 단계마다 보상 함수(compensating transaction)를 배열에 쌓아두고, 오류 발생 시 역순(LIFO)으로 실행하는 방식입니다.
각 단계가 실패하면 그 시점까지 쌓인 보상 함수들이 역순으로 실행됩니다. 메일 발송 단계에서 실패하면 결제 환불 → 재고 취소 순으로 롤백됩니다.
프로젝트 설정
pnpm add @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activitytsconfig.json에서 "lib": ["ES2017"] 이상, strict 모드를 활성화합니다.
최소 동작 예제
복잡한 패턴을 보기 전에 기본 구조를 먼저 확인합니다. Activity 하나와 Workflow 하나로 구성된 최소 예제입니다.
// greeting.activities.ts
export async function greet(name: string): Promise<string> {
return `안녕하세요, ${name}!`;
}// greeting.workflow.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './greeting.activities';
const { greet } = proxyActivities<typeof activities>({
startToCloseTimeout: '5s',
});
export async function greetingWorkflow(name: string): Promise<string> {
return await greet(name);
}// client.ts
import { Client, Connection } from '@temporalio/client';
import { greetingWorkflow } from './greeting.workflow';
const connection = await Connection.connect();
const client = new Client({ connection });
const result = await client.workflow.execute(greetingWorkflow, {
args: ['세상'],
taskQueue: 'greeting',
workflowId: 'greeting-001',
});
console.log(result); // "안녕하세요, 세상!"이 구조 — Activity에서 부작용, Workflow에서 조합, Client에서 실행 — 가 모든 Temporal 애플리케이션의 뼈대입니다.
결제 워크플로 — Saga 패턴 적용
compensations 배열 패턴이 처음엔 다소 장황하게 느껴질 수 있습니다. 그런데 실제로 4~5단계짜리 주문 처리를 구현해보면, 각 단계와 그 보상이 나란히 있어서 오히려 추적하기 더 쉬운 구조입니다.
// payment.workflow.ts
import { proxyActivities, ApplicationFailure } from '@temporalio/workflow';
import type * as activities from './payment.activities';
const {
reserveInventory,
chargePayment,
sendConfirmation,
releaseInventory,
refundPayment,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
maximumAttempts: 5,
initialInterval: '1s',
backoffCoefficient: 2,
nonRetryableErrorTypes: ['InvalidPaymentMethodError'],
},
});
export async function orderWorkflow(orderId: string, amount: number): Promise<void> {
const compensations: Array<() => Promise<void>> = [];
try {
await reserveInventory(orderId);
compensations.push(() => releaseInventory(orderId));
await chargePayment(orderId, amount);
compensations.push(() => refundPayment(orderId));
await sendConfirmation(orderId);
// 메일 자체는 되돌릴 수 없어 보상을 쌓지 않는다.
// 이 단계에서 예외가 발생하면 catch가 앞선 결제·재고 보상을 정상 실행한다.
} catch (originalErr) {
// 각 보상을 독립적으로 실행 — 하나가 실패해도 나머지는 계속
const compensationErrors: Error[] = [];
for (const compensate of [...compensations].reverse()) {
try {
await compensate();
} catch (err) {
compensationErrors.push(err as Error);
}
}
if (compensationErrors.length > 0) {
// 부분 롤백 상태: 워크플로는 FAILED로 종료되고 수동 개입이 필요하다
throw ApplicationFailure.nonRetryable(
`Saga 보상 ${compensationErrors.length}건 최종 실패 — 수동 복구 필요`,
'PartialCompensationFailure'
);
}
throw originalErr;
}
}★ Insight ─────────────────────────────────────
proxyActivities에서 설정한retry정책은 Activity 수준에서 적용됩니다. Workflow 자체는 재시도되지 않으므로 보상 로직이 Saga의 핵심입니다.nonRetryableErrorTypes로 "잘못된 카드 번호" 같은 사용자 오류는 재시도 없이 즉시 실패 처리할 수 있습니다.- 보상 함수도 Activity이므로 실패 시 자동 재시도됩니다. 단, 최대 재시도를 모두 소진하면 Workflow는
FAILED상태로 진입하고 부분 롤백된 데이터가 남습니다.PartialCompensationFailure타입으로 구분해 알림·운영 대시보드에서 즉시 식별할 수 있게 하는 것이 중요합니다.─────────────────────────────────────────────────
멱등성 키 — 중복 결제를 막는 필수 패턴
Activity는 at-least-once 실행 모델입니다. Worker가 결제 API를 성공적으로 호출했지만 Temporal 서버에 완료를 보고하기 전 크래시가 나면, 해당 Activity가 재실행됩니다. 이때 중복 청구가 발생할 수 있습니다.
해결책은 멱등성 키(idempotency key) 를 항상 전달하는 것입니다.
// payment.activities.ts
import { stripe } from './stripe.client';
import { db } from './db.client';
export async function chargePayment(orderId: string, amount: number): Promise<string> {
const charge = await stripe.charges.create(
{ amount, currency: 'usd', source: 'tok_visa' },
{ idempotencyKey: `charge-${orderId}` }
);
// 결제 기록 저장 — refundPayment에서 stripeId를 조회한다
await db.charge.upsert({
where: { orderId },
create: { orderId, stripeId: charge.id },
update: {}, // 이미 저장됐으면 무시 (멱등)
});
return charge.id;
}
export async function reserveInventory(orderId: string): Promise<void> {
await db.inventory.upsert({
where: { orderId },
create: { orderId, status: 'reserved' },
update: {},
});
}
export async function releaseInventory(orderId: string): Promise<void> {
await db.inventory.updateMany({
where: { orderId, status: 'reserved' },
data: { status: 'released' },
});
}
export async function refundPayment(orderId: string): Promise<void> {
const charge = await db.charge.findUnique({ where: { orderId } });
if (!charge) return; // chargePayment가 DB 저장 전 실패한 경우
await stripe.refunds.create(
{ charge: charge.stripeId },
{ idempotencyKey: `refund-${orderId}` }
);
}
export async function sendConfirmation(orderId: string): Promise<void> {
await emailService.send({
template: 'order-confirmed',
orderId,
idempotencyKey: `email-${orderId}`,
});
}★ Insight ─────────────────────────────────────
chargePayment가 DB 저장까지 담당하는 이유: Activity의 at-least-once 특성 때문에 재실행 시upsert로 중복을 막고,refundPayment가stripeId를 안전하게 조회할 수 있습니다.- 멱등성 키는
orderId기반의 결정론적 값으로 만들어야 합니다.uuid()같은 무작위 값을 쓰면 Activity 재시도마다 새 키가 생성되어 멱등성을 깹니다.─────────────────────────────────────────────────
외부 API 폴링 — 영속적 타이머와 continue-as-new
배송 추적 API처럼 "완료될 때까지 기다리는" 패턴도 Temporal로 깔끔하게 표현됩니다. sleep()은 일반 setTimeout과 달리 Worker가 재시작돼도 타이머가 유지됩니다.
단, 수 주 이상 실행되는 워크플로는 Event History가 계속 쌓여 크기 한도에 도달할 수 있습니다. continueAsNew로 새 실행을 시작하되, 데드라인 상태를 반드시 인자로 넘겨야 한다는 점이 핵심입니다. 상대적 남은 시간 대신 절대 타임스탬프를 넘기면 continue-as-new 이후에도 원래 데드라인이 유지됩니다.
// shipping.workflow.ts
import {
proxyActivities,
sleep,
now,
workflowInfo,
continueAsNew,
ApplicationFailure,
} from '@temporalio/workflow';
import type * as activities from './shipping.activities';
const { checkShipmentStatus, notifyDelivered } =
proxyActivities<typeof activities>({ startToCloseTimeout: '10s' });
export interface TrackShipmentInput {
orderId: string;
deadlineEpoch: number; // 클라이언트에서 Date.now() + N 일로 계산해 전달
}
export async function trackShipmentWorkflow({
orderId,
deadlineEpoch,
}: TrackShipmentInput): Promise<void> {
while (true) {
if (now() >= deadlineEpoch) {
throw ApplicationFailure.nonRetryable(`배송 타임아웃: ${orderId}`);
}
const status = await checkShipmentStatus(orderId);
if (status === 'delivered') {
await notifyDelivered(orderId);
return;
}
if (status === 'lost' || status === 'returned') {
throw ApplicationFailure.nonRetryable(`배송 이상: ${orderId} - ${status}`);
}
// 히스토리가 커지기 전에 새 실행으로 이어받는다
// deadlineEpoch(절대값)를 넘기므로 재시작 후에도 원래 데드라인이 유지된다
if (workflowInfo().historyLength > 1000) {
await continueAsNew<typeof trackShipmentWorkflow>({ orderId, deadlineEpoch });
}
await sleep('30m');
}
}클라이언트에서는 절대 타임스탬프로 시작합니다:
await client.workflow.start(trackShipmentWorkflow, {
args: [{ orderId, deadlineEpoch: Date.now() + 14 * 24 * 60 * 60 * 1000 }],
taskQueue: 'shipping',
workflowId: `shipping-${orderId}`,
});★ Insight ─────────────────────────────────────
now()는 재생 시 Event History에 기록된 시각을 반환합니다. 워크플로 내 시간 비교는 항상 이 함수를 사용합니다.continueAsNew에 상대적 남은 시간(deadlineEpoch - now())을 넘기면 재실행 시점의now()가 달라져 데드라인이 틀어집니다. 절대 에포크를 쓰는 이유입니다.- 클라이언트 코드에서
Date.now()를 쓰는 건 완전히 안전합니다. 결정론 제약은 Workflow 코드에만 적용됩니다.─────────────────────────────────────────────────
Signal로 외부 이벤트 수신
결제 대기 중 사용자가 취소를 요청하거나, 관리자가 수동으로 승인하는 상황을 Signal로 처리할 수 있습니다.
// approval.workflow.ts
import {
defineSignal,
setHandler,
condition,
proxyActivities,
} from '@temporalio/workflow';
import type * as activities from './approval.activities';
export const approvalSignal = defineSignal<[boolean]>('approvalDecision');
const { processApprovedOrder, notifyRejection } =
proxyActivities<typeof activities>({ startToCloseTimeout: '30s' });
export async function manualApprovalWorkflow(orderId: string): Promise<string> {
let approved: boolean | null = null;
setHandler(approvalSignal, (decision: boolean) => {
approved = decision;
});
// 최대 48시간 대기, 응답 없으면 false 반환
const received = await condition(() => approved !== null, '48h');
if (!received || approved === false) {
await notifyRejection(orderId);
return 'rejected';
}
await processApprovedOrder(orderId);
return 'approved';
}외부에서 승인 신호를 보내는 방법:
// 클라이언트 코드
import { Client } from '@temporalio/client';
import { approvalSignal } from './approval.workflow';
const client = new Client();
await client.workflow.getHandle(`approval-${orderId}`).signal(approvalSignal, true);Worker 설정
// worker.ts
import { Worker } from '@temporalio/worker';
import * as paymentActivities from './payment.activities';
import * as shippingActivities from './shipping.activities';
const worker = await Worker.create({
workflowsPath: new URL('./workflows', import.meta.url).pathname, // ESM 환경
activities: {
...paymentActivities,
...shippingActivities,
},
taskQueue: 'order-processing',
});
await worker.run();CJS 환경(require 사용 시)에서는 workflowsPath: require.resolve('./workflows')를 씁니다.
장단점 분석
장점
| 항목 | 설명 |
|---|---|
| 코드 중심 접근 | JSON/YAML 대신 TypeScript로 워크플로 정의 — IDE 자동완성, 타입 안전성, 단위 테스트 모두 활용 가능 |
| 자동 재시도 | Activity별 세밀한 재시도 정책 내장 — 최대 시도 횟수, 백오프 계수, 재시도 불가 오류 유형 분류 |
| 크래시 복구 | Worker 프로세스 사망 시 다른 Worker에서 마지막 완료 지점부터 자동 재개 |
| 테스트 용이성 | @temporalio/testing으로 인메모리 환경에서 전체 워크플로 단위 테스트 가능 |
| 가시성 | Temporal UI에서 모든 워크플로 상태, 이벤트 히스토리, 재시도 내역 실시간 확인 |
| 폴리글랏 | Go, Java, TypeScript, Python, .NET, PHP SDK 공식 지원 |
주의사항
| 항목 | 설명 |
|---|---|
| 결정론적 제약 | Workflow 코드에서 직접 HTTP 요청, 무작위 값 생성 불가. SDK 제공 API(now(), sleep())를 사용하는 습관 필요 |
| 운영 복잡성 | 자체 호스팅 시 PostgreSQL/Cassandra, Elasticsearch, 여러 서비스 컴포넌트, 모니터링 인프라 관리 필요 |
| 과잉 엔지니어링 위험 | 단순 백그라운드 작업(3단계 이하)에는 BullMQ 같은 경량 큐가 더 적합 |
| 보상 실패 시 최종 상태 | 보상 Activity가 재시도를 모두 소진하면 Workflow는 FAILED로 종료되고 부분 롤백된 데이터가 남습니다. 이 경우는 자동 복구가 불가능하므로 PartialCompensationFailure 같은 명시적 에러 타입으로 구분해 운영 알림을 연결해야 합니다 |
| 버전 관리 난이도 | 실행 중인 워크플로가 있을 때 코드 변경 시 재생 결정론성 위반 가능 — patched() API로 하위 호환 처리 필요 |
자주 하는 실수
1. Workflow 안에서 직접 API 호출
// 잘못된 예
export async function wrongWorkflow(orderId: string) {
const response = await fetch(`https://api.example.com/order/${orderId}`);
}
// 올바른 예
export async function correctWorkflow(orderId: string) {
const result = await fetchOrderDetails(orderId); // proxyActivities로 감싼 Activity
}2. Node.js 기본 타이머 사용
// 잘못된 예 — Worker 재시작 시 타이머 소멸
await new Promise(resolve => setTimeout(resolve, 30000));
// 올바른 예 — Worker가 죽어도 타이머 상태 유지
await sleep('30s');3. 멱등성 키 누락
at-least-once 모델을 간과하고 외부 API 호출 시 멱등성 키를 빠뜨리면 중복 결제, 중복 발송이 발생합니다. Activity 안의 모든 외부 쓰기 작업에는 반드시 결정론적 멱등성 키(orderId, ${orderId}-step)를 사용합니다.
4. Saga 보상 루프를 단일 try/catch로 감쌈
보상 함수들을 루프로 실행할 때 개별 try/catch 없이 하나의 try/catch만 두면, 첫 번째 보상이 실패했을 때 나머지 보상은 실행되지 않습니다. 각 보상은 독립적으로 실패를 처리하고 나머지를 계속 실행해야 합니다.
언제 Temporal이 아닌 다른 도구를 써야 할까
| 상황 | 추천 도구 |
|---|---|
| 단순 백그라운드 잡 (1~2단계) | BullMQ / Redis 큐 |
| 낮은 인프라 오버헤드의 내구 함수 | Restate |
| Next.js/Remix 친화적 백그라운드 잡 | Trigger.dev |
| 배치 데이터 파이프라인 | Apache Airflow |
| 복잡한 다단계 비즈니스 워크플로 + 보상 로직 | Temporal |
Temporal Cloud vs 자체 호스팅 기준: 월 3,000만~5,000만 Action 이상이고 Kubernetes + Cassandra 운영 역량이 있을 때 자체 호스팅의 비용 경쟁력이 생깁니다. 그 이하에서는 Temporal Cloud 소비 기반 요금이 대부분 더 저렴합니다.
마치며
Temporal의 핵심 가치는 Event History와 결정론적 재생을 인프라 계층으로 밀어넣어 "실패 처리 코드" 대신 "성공 경로 코드"만 작성하게 해준다는 것입니다. 복잡한 롤백 조율, 재시도 상태 추적, 크래시 복구 로직이 프레임워크 안으로 들어가고, 개발자는 비즈니스 흐름에만 집중할 수 있습니다.
물론 단순한 작업에 도입하는 건 과잉 엔지니어링입니다. 실패 복구가 비즈니스 크리티컬하고, 여러 단계가 있고, 보상 트랜잭션이 필요한 경우 — 그때가 Temporal을 꺼낼 타이밍입니다.
지금 시작하는 3단계:
- 로컬 개발 서버로 시작 —
temporal server start-dev로 인프라 없이 바로 실험할 수 있습니다. Temporal Cloud 무료 티어도 선택지입니다. - 가장 자주 터지는 워크플로 하나만 먼저 포팅 — 전부 바꾸지 않아도 됩니다. 실제로 재시도·보상이 필요한 결제나 알림 플로우 하나를 골라 효과를 먼저 확인해보세요.
@temporalio/testing으로 장애 시나리오 테스트 — 인메모리 환경에서 Activity를 모킹하고, Worker 크래시나 타임아웃 같은 실패 케이스를 코드로 검증하는 습관을 만들어두면 이후 유지보수가 훨씬 수월합니다.
참고 자료
- Temporal 공식 문서 - Workflows
- Temporal 공식 문서 - Workflow Execution 개요
- Learn Temporal - TypeScript SDK Durable Execution 튜토리얼
- Replay 2026 제품 발표 공식 블로그
- Temporal Serverless Workers — AWS Lambda 배포 발표
- Saga Compensating Transactions | Temporal 공식 블로그
- A Practical Guide to Temporal Workflow Design Patterns (DZone)
- Temporal + Encore.ts Durable Workflow 가이드
- Replay 2026 커뮤니티 론치 요약 (공식 포럼)