Temporal.io + TypeScript SDK로 장기 실행 워크플로 만들기 — Saga·재시도·Signal을 코드로 선언하기
마이크로서비스 환경에서 "결제는 됐는데 재고 예약이 실패했을 때 어떻게 환불하나요?"라는 질문을 받아본 적 있으신가요? 처음 분산 트랜잭션 문제를 마주쳤을 때 메시지 브로커 기반 보상 로직을 직접 짜다가 사흘을 날린 기억이 아직도 생생합니다. 상태 추적 테이블, 실패 시 재처리 큐, 보상 이벤트 발행 — 코드는 늘어나는데 정작 비즈니스 로직은 어디에도 보이지 않았습니다.
Temporal.io는 이 문제를 워크플로 코드 자체에 가두어 버립니다. 재시도 정책, Saga 보상 흐름, 장기 실행 상태 관리가 별도 인프라가 아니라 TypeScript 함수 안에 선언됩니다. 서버가 재시작되어도, 네트워크가 끊겨도, 워크플로는 정확히 멈춘 지점부터 다시 이어집니다. 상태를 직접 저장할 필요가 없습니다.
이 글에서는 TypeScript SDK로 Temporal 워크플로를 구성하는 방법, proxyActivities와 Retry Policy로 분산 재시도를 선언하는 방법, 그리고 전자상거래 주문 처리 시나리오에 Saga 패턴을 적용하는 방법을 코드와 함께 살펴봅니다.
핵심 개념
Temporal이 푸는 문제: Durable Execution
전통적인 백그라운드 잡 시스템은 프로세스가 죽으면 실행 상태를 잃습니다. Temporal은 이벤트 소싱(event sourcing) 방식으로 워크플로 실행 이력을 서버에 영속 저장합니다. 워커가 재시작되면 이력을 재생(replay)해서 중단된 지점을 복원하고, 거기서부터 실행을 이어갑니다.
이것을 durable execution이라고 부릅니다. 개발자 입장에서는 "그냥 함수를 쓴다"는 느낌인데, 내부적으로는 체크포인트가 자동으로 관리되는 셈입니다.
Workflow vs Activity: 무엇을 어디에 넣나
Temporal의 가장 중요한 구분이 여기에 있습니다.
| 구분 | Workflow | Activity |
|---|---|---|
| 역할 | 비즈니스 흐름 선언, 오케스트레이션 | 외부 API 호출, DB 쿼리, 파일 I/O |
| 결정론성 | 필수 — 재실행 시 동일 결과 보장 | 제약 없음 |
| 외부 I/O | 금지 (fetch, DB, 파일시스템 등) | 자유롭게 사용 가능 |
| 시간·랜덤 | Date.now()·Math.random()·setTimeout은 샌드박스가 replay-safe로 자동 대체 |
그냥 사용 |
| 실패 처리 | 보상 로직 오케스트레이션 | Retry Policy에 따라 자동 재시도 |
TypeScript SDK의 특징이 이 표에 그대로 드러납니다. 다른 언어 SDK와 달리 TS SDK는 워크플로 샌드박스가 Date.now()·new Date()·Math.random()·setTimeout을 결정론적 버전으로 자동 대체합니다. 그래서 replay 시에도 최초 실행 때와 같은 값이 되돌아옵니다.
바꿔 말하면, **실제로 워크플로에 넣으면 안 되는 것은 "외부 세계와 통신하는 코드"**입니다. fetch, DB 클라이언트, 파일 시스템, gRPC 호출 — 이렇게 결과가 매번 달라질 수 있는 것들은 반드시 Activity 안으로 빼야 합니다.
핵심 API: proxyActivities와 Retry Policy
TypeScript SDK에서 Workflow는 Activity 함수를 직접 import하지 않고 프록시 형태로 사용합니다. 여기서 재시도 정책을 선언합니다.
주의: 아래
proxyActivities호출은 반드시 워크플로 모듈의 최상위 스코프에서 실행되어야 합니다. 워크플로 함수 안에서 호출하면 replay마다 새 프록시가 생성되어 결정론성이 깨집니다. 워크플로 파일 상단,export async function보다 위에 두는 것이 정석입니다.
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';
const {
reserveInventory,
processPayment,
arrangeShipping,
sendConfirmationEmail,
updateOrderStatus,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
initialInterval: '1s',
backoffCoefficient: 2,
maximumAttempts: 5,
nonRetryableErrorTypes: ['CreditCardExpiredException', 'InvalidOrderException'],
},
});
// 보상 Activity는 별도 프록시로 분리 — 재시도를 더 공격적으로
const {
releaseInventory,
refundPayment,
cancelShipping,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '1m',
retry: {
initialInterval: '2s',
backoffCoefficient: 2,
maximumAttempts: 20,
},
});nonRetryableErrorTypes가 특히 중요합니다. 만료된 카드 오류처럼 재시도해도 의미 없는 실패는 여기에 등록해두면, 불필요한 재시도 없이 즉시 실패로 처리됩니다.
보상 Activity를 별도 프록시로 분리한 이유가 있는데, 실무에서 자주 놓치는 지점이라 조금 뒤 Saga 절에서 다시 짚겠습니다.
Saga 패턴: try/catch로 보상 흐름 선언하기
Saga 패턴은 분산 트랜잭션을 일련의 로컬 트랜잭션으로 쪼개고, 중간에 실패하면 앞서 성공한 단계를 역순으로 되돌리는 방식입니다. Temporal에서는 이것이 일반 TypeScript의 try/catch 블록으로 자연스럽게 표현됩니다.
import { log } from '@temporalio/workflow';
export async function orderWorkflow(orderId: string): Promise<void> {
const compensations: Array<() => Promise<void>> = [];
try {
await reserveInventory(orderId);
compensations.push(() => releaseInventory(orderId));
await processPayment(orderId);
compensations.push(() => refundPayment(orderId));
await arrangeShipping(orderId);
compensations.push(() => cancelShipping(orderId));
await sendConfirmationEmail(orderId);
} catch (err) {
for (const compensate of [...compensations].reverse()) {
try {
await compensate();
} catch (compensationErr) {
// 보상 실패는 로그로 남기되 나머지 보상은 계속 진행
log.error('Compensation failed', { orderId, compensationErr });
}
}
throw err;
}
}여기서 짚을 지점이 세 가지입니다.
- 보상 함수는 해당 단계가 성공한 직후 스택에 쌓습니다.
reserveInventory가 성공하면 그 짝인releaseInventory를 등록합니다. 실패 시 역순으로 실행되므로 가장 나중에 성공한 단계부터 되돌리게 됩니다. [...compensations].reverse()로 배열을 복사한 뒤 뒤집습니다.reverse()는 원본을 변이시키므로, 이 패턴을 다른 곳에 옮겨 쓸 때 예상치 못한 부작용을 피할 수 있습니다.- 보상 자체가 실패해도 나머지 보상은 계속 진행합니다. 재고 취소가 실패했다고 결제 환불까지 건너뛰면 곤란합니다. 앞서 보상 Activity에 별도 프록시로 더 공격적인 재시도(예: 20회)를 걸어둔 이유가 이것입니다. 그래도 최종 실패한 경우에는 로그로 남기고, 필요하다면 별도의 dead-letter 처리로 이어야 합니다.
실전 적용
전자상거래 주문 처리 시나리오 전체 구성
실제 프로젝트 구조를 기준으로 파일을 나누면 다음과 같이 됩니다.
src/
workflows/
orderWorkflow.ts ← 비즈니스 흐름 선언
activities/
inventoryActivities.ts
paymentActivities.ts
shippingActivities.ts
notificationActivities.ts
orderActivities.ts
worker.ts ← Worker 프로세스 진입점
client.ts ← 워크플로 시작Activity 구현 — 외부 I/O는 여기에 모입니다.
// activities/paymentActivities.ts
import { ApplicationFailure } from '@temporalio/activity';
export async function processPayment(orderId: string): Promise<void> {
const result = await paymentGateway.charge(orderId);
if (result.errorCode === 'CARD_EXPIRED') {
throw ApplicationFailure.nonRetryable(
'Card has expired',
'CreditCardExpiredException',
);
}
if (!result.success) {
throw new Error(`Payment failed: ${result.message}`);
}
}
export async function refundPayment(orderId: string): Promise<void> {
await paymentGateway.refund(orderId);
}Worker 등록 — Workflow와 Activity를 같은 Task Queue에 등록합니다.
// worker.ts
import { Worker } from '@temporalio/worker';
import * as inventoryActivities from './activities/inventoryActivities';
import * as paymentActivities from './activities/paymentActivities';
import * as shippingActivities from './activities/shippingActivities';
import * as notificationActivities from './activities/notificationActivities';
import * as orderActivities from './activities/orderActivities';
async function run() {
// 주의: 여러 모듈을 spread하면 같은 이름의 export가 있을 때
// 뒤에 오는 모듈이 앞을 조용히 덮어씁니다. 각 파일의 export 이름을
// 겹치지 않게 관리하거나, 접두어 붙인 객체를 명시적으로 구성하세요.
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows/orderWorkflow'),
activities: {
...inventoryActivities,
...paymentActivities,
...shippingActivities,
...notificationActivities,
...orderActivities,
},
taskQueue: 'order-processing',
});
await worker.run();
}
run().catch(console.error);클라이언트에서 워크플로 시작
// client.ts
import { Client, Connection } from '@temporalio/client';
async function startOrder(orderId: string) {
const connection = await Connection.connect({ address: 'localhost:7233' });
const client = new Client({ connection });
const handle = await client.workflow.start('orderWorkflow', {
taskQueue: 'order-processing',
workflowId: `order-${orderId}`,
args: [orderId],
});
console.log(`워크플로 시작: ${handle.workflowId}`);
const result = await handle.result();
return result;
}주문 처리 흐름과 실패 시 보상 경로
보상 노드는 하나로 묶어 표현했지만, 실제 실행은 등록된 순서의 역순 — 배송 취소, 결제 환불, 재고 예약 취소 — 로 이루어집니다.
Signal로 외부 이벤트 주입하기: 배송 상태 업데이트와 타임아웃
장기 실행 워크플로의 강점 중 하나는 외부 이벤트를 Signal로 받아 흐름을 이어갈 수 있다는 점입니다. 예를 들어 배송 완료 알림을 기다리는 패턴을 이렇게 작성할 수 있습니다. 이때 7일이 지나도 Signal이 오지 않는 케이스를 반드시 분기해야 합니다.
import { defineSignal, setHandler, condition } from '@temporalio/workflow';
export const deliveryConfirmedSignal = defineSignal<[{ trackingCode: string }]>(
'deliveryConfirmed',
);
export async function orderWorkflow(orderId: string): Promise<void> {
// ... Saga 로직 ...
let delivered = false;
let trackingCode = '';
setHandler(deliveryConfirmedSignal, ({ trackingCode: code }) => {
delivered = true;
trackingCode = code;
});
// condition은 조건이 참이 되면 true, 타임아웃이면 false를 반환합니다
const arrived = await condition(() => delivered, '7d');
if (arrived) {
await updateOrderStatus(orderId, 'DELIVERED', trackingCode);
} else {
// 배송사 웹훅이 오지 않은 경우 — 별도 조사 큐로 넘기거나 재시도 워크플로 시작
await updateOrderStatus(orderId, 'DELIVERY_TIMEOUT', '');
}
}외부 시스템(배송사 웹훅 등)에서 Signal을 보내는 코드는 클라이언트에서 한 줄입니다.
await client.workflow
.getHandle(`order-${orderId}`)
.signal(deliveryConfirmedSignal, { trackingCode: 'KR1234567890' });로컬 테스트: TestWorkflowEnvironment
Temporal 서버 없이도 워크플로를 테스트할 수 있습니다. @temporalio/testing의 TestWorkflowEnvironment를 사용하면 Activity를 목(mock)으로 대체하고, sleep 같은 시간 대기를 건너뛸 수 있습니다.
import { TestWorkflowEnvironment } from '@temporalio/testing';
import { Worker } from '@temporalio/worker';
let testEnv: TestWorkflowEnvironment;
beforeAll(async () => {
testEnv = await TestWorkflowEnvironment.createLocal();
});
afterAll(async () => {
await testEnv.teardown();
});
test('결제 실패 시 재고 예약이 취소된다', async () => {
const { client, nativeConnection } = testEnv;
const mockActivities = {
reserveInventory: jest.fn().mockResolvedValue(undefined),
processPayment: jest.fn().mockRejectedValue(new Error('Payment failed')),
releaseInventory: jest.fn().mockResolvedValue(undefined),
refundPayment: jest.fn().mockResolvedValue(undefined),
arrangeShipping: jest.fn().mockResolvedValue(undefined),
cancelShipping: jest.fn().mockResolvedValue(undefined),
sendConfirmationEmail: jest.fn().mockResolvedValue(undefined),
updateOrderStatus: jest.fn().mockResolvedValue(undefined),
};
const worker = await Worker.create({
connection: nativeConnection,
taskQueue: 'test-queue',
workflowsPath: require.resolve('./workflows/orderWorkflow'),
activities: mockActivities,
});
await worker.runUntil(async () => {
// 워크플로가 최종적으로 실패하는 것이 이 테스트의 기대 동작.
// 따라서 rejects.toThrow로 실패 자체를 검증하고, 이후 보상 호출 여부를 확인합니다.
await expect(
client.workflow.execute('orderWorkflow', {
taskQueue: 'test-queue',
workflowId: 'test-order-1',
args: ['order-1'],
}),
).rejects.toThrow(/Payment failed/);
});
expect(mockActivities.reserveInventory).toHaveBeenCalledWith('order-1');
expect(mockActivities.releaseInventory).toHaveBeenCalledWith('order-1');
expect(mockActivities.refundPayment).not.toHaveBeenCalled();
});장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 코드 우선 접근 | BPMN 다이어그램 없이 TypeScript 코드로 워크플로 선언, IDE 자동완성과 타입 체크 완전 지원 |
| Durable Execution | 프로세스 재시작·네트워크 단절 후 정확한 지점에서 재개 — 체크포인트 로직 직접 구현 불필요 |
| 선언적 재시도 | retry 옵션만으로 지수 백오프·최대 시도 횟수·비재시도 예외 타입 지정 |
| Saga 자연스러운 구현 | try/catch + 보상 함수 패턴으로 분산 트랜잭션 보상 구현 |
| 가시성 | Temporal Web UI를 통해 모든 워크플로 인스턴스의 히스토리·상태·오류를 실시간 조회 |
| 테스트 지원 | TestWorkflowEnvironment로 로컬에서 목 기반 단위 테스트 가능 |
| 다언어 SDK | TypeScript, Go, Java, Python, .NET 지원으로 폴리글랏 환경 수용 |
단점 및 고려사항
| 항목 | 내용 |
|---|---|
| 학습 곡선 | 결정론성 개념과 이벤트 소싱 기반 실행 모델을 이해하는 데 상당한 시간 소요 |
| 워크플로 코드 제약 | 외부 I/O(fetch, DB, 파일시스템) 직접 호출 금지 — 모두 Activity로 이관 필요 |
| 인프라 운영 복잡도 | 자체 호스팅 시 Temporal 서버·DB(PostgreSQL 또는 Cassandra)·Elasticsearch 클러스터 운영 필요 |
| 히스토리 크기 제한 | 단일 워크플로 이벤트 수 기본 상한(약 5만 건 / 50MiB) — 장기 실행 워크플로는 continueAsNew 필요 |
| 오버킬 가능성 | 단순 큐 기반 작업이나 짧은 백그라운드 잡에는 도입 비용이 ROI를 초과할 수 있음 |
실무에서 자주 보이는 실수 세 가지
1. Workflow 안에서 실제 외부 I/O 호출
앞서 언급한 대로, TS SDK 샌드박스는 Date.now()·Math.random()·setTimeout 같은 표준 API를 결정론적 버전으로 자동 대체합니다. 따라서 이 함수들 자체는 워크플로 안에서 써도 replay가 깨지지 않습니다.
그러나 fetch, DB 클라이언트, 파일 시스템 접근 같은 실제 외부 I/O는 여전히 금지입니다. 처음 접하는 개발자가 가장 흔히 하는 실수는 워크플로 파일 상단에서 무심코 외부 서비스 클라이언트를 초기화하거나, "간단한 호출인데" 하며 fetch를 직접 부르는 것입니다. 이런 호출은 반드시 Activity로 옮겨야 합니다.
// 잘못된 방법: 워크플로 안에서 직접 HTTP 호출
export async function orderWorkflow(orderId: string) {
const res = await fetch(`https://api.example.com/orders/${orderId}`); // 금지
}
// 올바른 방법: Activity로 감싸서 프록시로 호출
export async function orderWorkflow(orderId: string) {
const order = await fetchOrderDetails(orderId); // proxyActivities로 노출된 함수
}시간 정보가 필요한데 "workflow 시작 시각"이 아니라 "지금의 wall-clock 시각"이 필요한 특수한 경우라면, Activity에서 조회해 워크플로로 되돌려주는 것이 안전합니다.
2. 장기 실행 워크플로에서 continueAsNew 누락
수개월 이상 실행되는 워크플로(구독 갱신, 사용자 온보딩 시퀀스 등)는 이벤트 히스토리가 상한(기본 약 5만 이벤트 또는 50MiB)에 도달할 수 있습니다. 이를 넘기면 워크플로가 진행되지 못합니다. 실무에서는 반복 횟수가 아니라 실제 히스토리 길이를 기준으로 continueAsNew를 걸어야 신뢰할 수 있습니다.
import { continueAsNew, workflowInfo } from '@temporalio/workflow';
export async function longRunningWorkflow(iteration: number): Promise<void> {
// ... 작업 수행 ...
// 상한의 절반쯤 도달하면 안전하게 새 인스턴스로 넘김
if (workflowInfo().historyLength > 10_000) {
await continueAsNew<typeof longRunningWorkflow>(iteration + 1);
}
}3. 단순 작업에 Temporal 도입
단순히 "3회 재시도가 필요한 API 호출"이라면 Temporal은 과합니다. Temporal이 진가를 발휘하는 건 여러 서비스에 걸친 보상 트랜잭션, 수 시간~수일 단위의 장기 실행, 외부 이벤트 대기 같은 시나리오입니다. Sidekiq이나 BullMQ 한 줄로 해결되는 일에 Temporal 서버·DB·Worker 프로세스를 얹으면 배보다 배꼽이 커집니다.
마치며
Temporal.io는 분산 트랜잭션 보상, 자동 재시도, 장기 실행 상태 관리라는 세 가지 문제를 워크플로 코드 안으로 가져옵니다. 별도의 상태 추적 DB, 재시도 큐, 보상 이벤트 토픽을 설계하는 대신, TypeScript 함수와 try/catch 블록으로 비즈니스 흐름을 선언하면 됩니다.
결정론성 규칙 — 워크플로 안에서 외부 I/O를 직접 호출하지 않는 것 — 이 처음엔 가장 낯선 제약이지만, 이것이 durable execution을 가능하게 하는 핵심 계약이기도 합니다. TS SDK가 Date.now()·Math.random()·setTimeout을 알아서 결정론적으로 만들어주므로, 남는 규칙은 "외부 세계와의 통신은 전부 Activity로" 하나뿐입니다.
지금 손에 잡히는 순서로 시작해보시려면 이렇게 접근할 수 있습니다.
- 로컬 Temporal 서버 실행 —
docker compose up으로 공식 docker-compose 파일을 띄우면localhost:7233에 서버가,localhost:8080에 Web UI가 열립니다. - Hello World Workflow 작성 —
@temporalio/worker,@temporalio/workflow,@temporalio/client패키지를 설치하고, 공식learn.temporal.io의 TypeScript 코스 첫 번째 예제를 따라가면 전체 구조를 파악하기에 좋습니다. - 기존 백그라운드 잡 하나를 Workflow로 이식 — 새 프로젝트를 만들기보다, 현재 운영 중인 서비스에서 "실패 시 처리가 애매한 멀티스텝 잡" 하나를 골라 Activity로 분리하고 Workflow로 감싸보면 Temporal의 가치를 직접 체감할 수 있습니다.
복잡한 보상 로직과 장기 실행 상태를 "그냥 코드"로 다룰 수 있다는 것 — 한 번 손에 익으면 이전 방식으로 돌아가기 어렵습니다.
참고 자료
- Temporal 공식 문서 — TypeScript SDK 개발자 가이드
- Activity 실행 — TypeScript SDK 공식 문서
- Retry Policy 백과사전 — Temporal 공식 문서
- Temporal 사용 사례 및 디자인 패턴
- Saga Compensating Transactions — Temporal 공식 블로그
- Mastering Saga Patterns for Distributed Transactions — Temporal 블로그
- Replay 2025 신기능 발표 — Temporal 블로그
- Temporal TypeScript SDK 데모 — 공식 블로그
- Node.js Durable Execution with Temporal + TS: Saga Patterns — Medium
- Implementing the Saga Pattern with Temporal (2026년 7월) — Medium
- Temporal Workflow Design Patterns — DZone
- Temporal Workflows and Activities Best Practices
- Learn Temporal — 공식 학습 허브
- Temporal 이커머스 주문 이행 데모