Temporal 완벽 가이드 — 분산 워크플로우 엔진
이 글의 핵심
Temporal은 장애·재시작 이후에도 동일한 비즈니스 절차를 재현하는 분산 워크플로우 엔진입니다. Workflow·Activity·Worker, 내구성 실행·재시도, 시그널·쿼리, 타임아웃, 버전 관리, @temporalio TypeScript SDK, Saga(보상) 관점의 결제 오케스트레이션까지 다룹니다.
이 글의 핵심
Temporal은 마이크로서비스·레거시 API·메시지 큐를 아우르는 업무 절차를 코드로 표현하고, 프로세스 재시작·네트워크 단절·인프라 장애가 있어도 동일한 순서와 의미로 실행을 이어 가게 하는 오케스트레이션 플랫폼입니다. 본문에서는 Workflow·Activity·Worker의 책임 분리, 이벤트 히스토리에 기반한 내구성 있는 실행(durable execution)과 재시도, 외부와의 비동기 연동을 위한 시그널·쿼리, 운영 안정성을 위한 타임아웃·스케줄, 배포 시 충돌을 줄이는 버전 관리, @temporalio TypeScript SDK의 패키지·클라이언트·테스트, 관측성을 정리합니다. 이어 Saga(보상 트랜잭션) 관점으로 오케스트레이션을 읽는 방법을 짚고, 카드 승인·입금 확인·환불 보상이 얽힌 결제 시나리오를 TypeScript 예제로 제시합니다.
1. 왜 Temporal인가
분산 환경에서 “주문 생성 → 결제 → 재고 차감 → 알림” 같은 절차를 직접 구현하면, 각 단계마다 상태 저장소, 재시도, 타임아웃, 중복 처리(idempotency), 장애 복구 스크립트가 흩어집니다. Temporal은 이런 횡단 관심사를 워크플로우 실행 엔진과 기록된 이벤트 히스토리로 일원화합니다. 워커 프로세스가 죽어도 서버가 히스토리를 보존하고, 새 워커가 같은 워크플로우 코드로 리플레이하여 어디까지 진행했는지 복원합니다.
운영 관점에서는 “스크립트로만 돌아가던 배치성 업무”와 “코드로 표현된 상태 머신”의 차이가 큽니다. Temporal은 후자에 가깝고, UI·메트릭·추적을 통해 실행 중인 비즈니스 절차를 제품처럼 다룰 수 있습니다.
2. 아키텍처 개요
- Temporal Cluster(서버): 네임스페이스별로 워크플로우 실행, 태스크 큐 라우팅, 이벤트 히스토리 영속화를 담당합니다. 자체 호스팅 또는 Temporal Cloud를 선택할 수 있습니다.
- Worker: 사용자가 배포하는 프로세스로, 워크플로우 태스크와 액티비티 태스크를 폴링해 실행합니다. 수평 확장이 가능합니다.
- Client: 워크플로우 시작, 시그널 전송, 쿼리 등을 요청하는 애플리케이션 측 SDK입니다.
데이터 플로우는 “클라이언트가 실행 시작 → 서버가 히스토리에 이벤트 적재 → 워커가 태스크 처리 → 결과가 다시 히스토리에 기록”의 순환입니다. 이 구조가 내구성의 기반이 됩니다.
3. 핵심 개념: Workflow, Activity, Worker
3.1 Workflow(워크플로우)
워크플로우는 비즈니스 절차의 오케스트레이션 로직을 표현하는 함수입니다. 중요한 제약은 결정론(determinism)입니다. 동일한 이벤트 히스토리를 입력으로 리플레이할 때 항상 같은 분기와 같은 Activity 호출 순서가 나와야 합니다. 그래서 다음은 워크플로우에 두지 않습니다.
- 네트워크·DB·외부 API 직접 호출
Math.random(),Date.now()같은 비결정적 값(언어별로 안전한 API가 따로 있음)- 임의의 스레드·고루틴에서 상태 변경
이런 작업은 Activity로 위임하고, 워크플로우는 Activity의 프록시 호출과 제어 흐름만 담당합니다.
3.2 Activity(액티비티)
Activity는 부작용(side effect)이 있는 작업 단위입니다. HTTP 호출, DB 트랜잭션, 메시지 발행, 파일 처리 등이 여기에 해당합니다. Activity는 재시도 정책·타임아웃·하트비트를 독립적으로 가질 수 있어, 외부 시스템 특성에 맞게 튜닝합니다.
3.3 Worker(워커)
Worker는 태스크 큐에 연결되어 워크플로우 태스크와 액티비티 태스크를 실행합니다. 하나의 Worker 프로세스는 여러 워크플로우 유형·액티비티 유형을 등록할 수 있습니다. 처리량은 Worker 수와 태스크 큐 구독 수로 조절합니다.
태스크 큐 이름은 Worker가 어떤 일을 할지 구분하는 논리적 채널입니다. 서비스별·환경별로 큐를 나누면 배포와 장애 격리에 유리합니다.
3.4 네임스페이스(Namespace)
네임스페이스는 논리적 테넌트 경계입니다. 프로덕션·스테이징·팀별로 나누면 설정(리텐션, 권한)과 운영을 분리할 수 있습니다. 워크플로우 ID는 네임스페이스 안에서 유일하면 되며, 다른 네임스페이스와는 충돌하지 않습니다.
3.5 Child Workflow와 Continue-As-New
긴 실행(수년 단위 구독, 대량 배치)은 이벤트 히스토리가 커져 리플레이 비용이 증가합니다. Continue-As-New로 실행을 끊고 새 실행으로 이어 가면 히스토리를 줄일 수 있습니다. 하위 절차를 독립적인 생명주기로 묶으려면 Child Workflow를 쓰고, 부모는 자식 완료를 기다리거나 병렬로 기동합니다. 이렇게 하면 단계별 타임아웃·취소 정책을 분리하기 쉽습니다.
4. 내구성 실행과 리플레이
서버는 워크플로우 실행을 이벤트의 나열로 저장합니다. 워커가 재시작되면, 런타임은 저장된 히스토리를 처음부터 재생하며 워크플로우 함수를 다시 실행합니다. 이미 완료된 Activity 결과는 히스토리에서 읽어 오고, 아직 없는 지점까지 도달하면 새로운 스케줄링이 일어납니다.
이 모델 덕분에 “중간 상태를 DB에 직접 저장하지 않아도” 절차의 진행 상황이 서버에 남습니다. 다만 워크플로우 코드 변경은 리플레이와 충돌할 수 있어, 뒤에서 다루는 버전 관리가 필수입니다.
5. 재시도 전략과 오류 모델
5.1 Activity 재시도
외부 API는 타임아웃·일시적 5xx·레이트 리밋으로 실패합니다. Activity에는 보통 다음을 설정합니다.
- Initial interval: 첫 재시도 전 대기
- Backoff coefficient: 지수 백오프 배수
- Maximum interval: 백오프 상한
- Maximum attempts: 최대 시도 횟수
- Non-retryable error types: 비즈니스 규칙 위반 등 재시도해도 의미 없는 오류는 즉시 실패로 분류
재시도 가능 여부는 멱등성(idempotency)과 함께 설계합니다. 동일 Activity 실행이 두 번 이상 일어날 수 있으므로, 결제 승인 키·요청 ID 같은 아이덴포턴시 키를 외부 시스템에 전달하는 패턴이 일반적입니다.
5.2 Workflow Task 타임아웃
워크플로우 태스크는 한 번의 리플레이·결정 사이클을 완료해야 합니다. 너무 무거운 로직이나 버그로 태스크가 오래 걸리면 Workflow Task Timeout이 발생합니다. 이는 Activity 타임아웃과 별개이므로, 워크플로우 안에서는 가벼운 제어만 두는 것이 안전합니다.
6. 시그널(Signal)과 쿼리(Query)
6.1 시그널
시그널은 실행 중인 워크플로우 인스턴스에 비동기로 들어오는 외부 이벤트입니다. 예를 들어 “결제 게이트웨이 웹훅 수신”, “사용자가 주문 취소 버튼 클릭”, “수동 승인 완료” 등을 시그널로 모델링합니다. 워크플로우는 signal 핸들러에서 상태를 갱신하고, condition 등으로 특정 시그널이 올 때까지 기다릴 수 있습니다.
시그널 처리도 리플레이 대상이므로, 핸들러 내부에서 비결정적 연산을 하면 안 됩니다.
6.2 쿼리
쿼리는 워크플로우의 현재 상태를 읽기 전용으로 조회하는 경로입니다. UI 대시보드나 지원 도구에서 “이 주문 워크플로우가 어느 단계인지”를 실시간으로 보여 줄 때 유용합니다. 쿼리 핸들러는 부작용을 일으키지 않아야 하며, 워크플로우 상태의 일관된 스냅샷을 반환하도록 설계합니다.
7. 타임아웃과 스케줄링
7.1 Activity 타임아웃의 종류
일반적으로 다음을 조합합니다.
- Schedule-to-Start: 태스크가 큐에 올라온 뒤 Worker에 할당되기까지 허용 시간. Worker 부족·큐 적체를 감지합니다.
- Start-to-Close: Activity 실행 본문이 끝나기까지의 시간. 처리 로직의 상한입니다.
- Heartbeat: 장시간 작업이 살아 있는지 전송하는 펄스. 진행률 보고와 함께 Heartbeat timeout으로 “멈춘 작업”을 감지합니다.
결제·정산 연동에서는 파트너 API SLA에 맞춰 Start-to-Close를 잡고, 웹훅 대기는 시그널·타이머로 모델링하는 경우가 많습니다.
7.2 워크플로우 실행 타임아웃
전체 비즈니스 절차에 상한을 두려면 Workflow Execution Timeout을 설정합니다. 장기 인간 승인이 필요한 프로세스는 타임아웃을 넉넉히 하거나, 단계별 Child Workflow로 나눕니다.
7.3 타이머와 스케줄
타이머는 “3일 후 미결제면 자동 취소” 같은 요구를 워크플로우 코드로 표현하게 합니다. Cron 또는 Schedule API(제품·버전에 따라 명칭 상이)로 주기적 배치를 워크플로우에 위임할 수 있어, 크론 서버와 애플리케이션 코드가 분리된 운영 부담을 줄입니다.
8. 버전 관리와 마이그레이션
배포 시마다 워크플로우 소스가 바뀌면, 오래 실행 중인 인스턴스가 리플레이될 때 새 분기가 생겨 히스토리와 맞지 않을 수 있습니다. 이를 완화하는 대표 패턴은 다음과 같습니다.
- 패치(Patch): 특정 변경이 적용된 빌드인지 식별해, 구버전 실행은 이전 로직 분기, 신규 실행은 신규 분기로 유도합니다. 모든 구 실행이 종료되면 패치 분기를 제거해 코드를 정리합니다.
- Worker 버전 호환: 여러 버전의 Worker를 동시에 띄우고, 네임스페이스 또는 빌드 ID 정책으로 라우팅해 점진적 전환을 합니다.
마이그레이션 계획에는 “실행 중인 워크플로우 수”, “최대 실행 기간”, “외부 시스템 계약 변경 시점”을 함께 적습니다. Activity 입출력 스키마를 바꿀 때는 하위 호환 또는 새 Activity 이름으로 전환하는 편이 안전합니다.
8.1 검색 속성(Search Attributes)과 운영 조회
실행 중인 인스턴스를 주문번호·고객 ID·결제 상태로 찾으려면 검색 속성을 정의해 워크플로우 시작 시 채우거나 시그널 처리 시 갱신합니다. 운영 콘솔·API에서 필터링할 수 있어 “미결제 건만”, “특정 상점만” 같은 조회가 가능해집니다. 민감한 값은 검색 속성에 넣지 않는 것이 안전합니다.
9. TypeScript SDK(@temporalio/*) 구조
공식 TypeScript/JavaScript 스택은 역할별로 패키지가 나뉘어 있으며, 빌드·배포 파이프라인에서 워크플로우 번들을 분리하는 것이 일반적입니다.
9.1 패키지 역할
| 패키지 | 역할 |
|---|---|
@temporalio/workflow | 워크플로우 함수 전용 API. proxyActivities, defineSignal, defineQuery, sleep, condition 등. Node 전용이 아닌 워크플로우 샌드박스 규약을 따릅니다. |
@temporalio/activity | Activity 컨텍스트(heartbeat, cancellationToken 등). Activity 구현 파일에서 import합니다. |
@temporalio/worker | Worker.create로 태스크 큐를 구독하고 워크플로우·액티비티를 실행합니다. |
@temporalio/client | Connection, WorkflowClient로 실행 시작, 시그널, 쿼리, 취소, terminate 등 오케스트레이션 제어면을 담당합니다. |
@temporalio/testing | 시간 진행·모킹을 포함한 테스트 유틸(로컬 또는 시간 테스트 환경). |
워크플로우 소스는 리플레이 시 별도로 번들되므로, 애플리케이션 의존성과 다른 해석 규칙이 적용됩니다. package.json의 workflows 진입점과 bundleWorkflowCode 등 빌드 옵션을 팀 표준으로 문서화하는 것이 좋습니다.
9.2 클라이언트: 실행 시작과 제어
API 서버나 배치 잡에서 Temporal에 연결해 워크플로우를 기동할 때는 @temporalio/client를 사용합니다. 주소·TLS·네임스페이스는 환경별로 분리합니다.
// src/temporal/client.ts — 개념 예시
import { Connection, Client } from '@temporalio/client';
export async function createTemporalClient() {
const connection = await Connection.connect({
address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
// TLS: Temporal Cloud 등에서는 tls: { ... } 와 API 키/인증서 설정
});
return new Client({
connection,
namespace: process.env.TEMPORAL_NAMESPACE ?? 'default',
});
}
// 워크플로우 시작 (타입 안전하려면 workflow 타입·인자를 래핑한 헬퍼 권장)
export async function startPaymentWorkflow(client: Client, orderId: string, amount: number) {
const handle = await client.workflow.start('paymentWorkflow', {
taskQueue: 'payments',
workflowId: `payment-${orderId}`,
args: [orderId, amount],
// workflowIdReusePolicy, memo, searchAttributes 등 운영 옵션
});
return handle;
}
client.workflow.getHandle(workflowId)로 핸들을 얻은 뒤 signal, query, cancel, terminate를 호출합니다. 시그널과 함께 시작하려면 SDK의 signalWithStart 계열 API로 한 번의 왕복에 “기동 + 초기 시그널”을 보낼 수 있어, 웹훅 경합을 줄이는 패턴에 쓰입니다.
9.3 Worker 설정에서 자주 쓰는 옵션
maxConcurrentActivityTaskExecutions/maxConcurrentWorkflowTaskExecutions: 프로세스당 동시 실행 상한.identity: 로그·UI에 표시되는 워커 식별자.shutdownGraceTime: 배포 시 진행 중 태스크를 마칠 시간.- 데이터 컨버터(Data Converter): 페이로드 암호화·압축이 필요하면 커스텀 페이로드 컨버터를 연결합니다(PCI·PII 분리와 병행).
9.4 테스트
@temporalio/testing의 TestWorkflowEnvironment 등으로 로컬 서버를 띄우거나 시간을 가속해, 시그널 순서·타이머·재시도를 결정적으로 검증합니다. 프로덕션과 동일한 워크플로우 번들을 쓰면 “배포 후에만 터지는” 리플레이 불일치를 줄일 수 있습니다.
10. 모니터링과 디버깅
10.1 웹 UI
Temporal Web UI에서는 워크플로우 ID별로 이벤트 히스토리, 실패 원인, 재시도 횟수, 시그널·쿼리 기록을 확인할 수 있습니다. 운영자가 “어느 Activity에서 멈췄는지”를 추적하기 좋습니다.
10.2 메트릭과 로깅
Worker와 클러스터는 처리량, 태스크 지연, 큐 적체, 실패율 등의 메트릭을 노출할 수 있습니다. 애플리케이션 로그에는 워크플로우 ID·Run ID·Activity 유형을 구조화해 넣으면, 지원 티켓과 실행 기록을 연결하기 쉽습니다.
10.3 추적(Tracing)
OpenTelemetry 등으로 클라이언트 요청부터 워크플로우·Activity까지 분산 추적을 연결하면, 마이크로서비스 간 지연이 워크플로우 단계와 맞물리는지 볼 수 있습니다.
10.4 디버깅 시 유의점
- 리플레이 불가 오류: 최근 코드 변경이 과거 히스토리와 충돌할 때 발생합니다. 패치·버전 전략을 점검합니다.
- 무한 재시도: Non-retryable로 분류해야 할 오류가 재시도되면 비용과 부하가 커집니다.
- 시그널 순서: 동시에 여러 시그널이 오면 처리 순서가 비즈니스 규칙과 맞는지 검증합니다.
11. 실전 예제: 결제 워크플로우(TypeScript SDK)
아래는 카드 승인 요청 → 비동기 입금 확인(시그널) → 실패 시 환불 Activity를 단순화한 예시입니다. 프로덕션에서는 PCI 범위·PG사 API·멱등 키·사기 탐지·감사 로그를 추가해야 합니다.
11.1 도메인 상수와 시그널 정의
// src/payment/workflows.ts
import * as wf from '@temporalio/workflow';
import type * as activities from './activities';
const { authorizeCard, capturePayment, refundPayment } = wf.proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
initialInterval: '1s',
backoffCoefficient: 2,
maximumInterval: '30s',
maximumAttempts: 5,
nonRetryableErrorTypes: ['InvalidCardError', 'InsufficientFundsError'],
},
});
export const paymentSettled = wf.defineSignal<[{ status: 'success' | 'failed'; referenceId: string }]>(
'paymentSettled'
);
export const getStatus = wf.defineQuery<string>('getStatus');
type PaymentState =
| 'pending_auth'
| 'awaiting_settlement'
| 'settlement_ok'
| 'captured'
| 'refunded'
| 'failed';
export async function paymentWorkflow(orderId: string, amount: number): Promise<void> {
let state: PaymentState = 'pending_auth';
let lastReference = '';
wf.setHandler(getStatus, () => state);
wf.setHandler(paymentSettled, (payload) => {
lastReference = payload.referenceId;
if (payload.status === 'success') {
state = 'settlement_ok';
} else {
state = 'failed';
}
});
const auth = await authorizeCard({ orderId, amount });
if (!auth.approved) {
state = 'failed';
return;
}
state = 'awaiting_settlement';
const settled = await wf.condition(
() => state === 'settlement_ok' || state === 'failed',
'72h' // 입금 확인 대기 상한
);
if (!settled) {
await refundPayment({ orderId, authorizationId: auth.authorizationId, reason: 'settlement_timeout' });
state = 'refunded';
return;
}
if (state === 'failed') {
await refundPayment({ orderId, authorizationId: auth.authorizationId, reason: 'settlement_rejected' });
state = 'refunded';
return;
}
await capturePayment({
orderId,
authorizationId: auth.authorizationId,
captureReferenceId: lastReference,
});
state = 'captured';
}
condition은 시그널로 상태가 바뀔 때까지 기다립니다. 72시간 내 입금 확인이 없으면 타임아웃 분기에서 환불 Activity를 호출합니다. 시그널 핸들러는 결정론적으로 상태만 갱신합니다.
11.2 Activity 구현(개념)
// src/payment/activities.ts
export async function authorizeCard(input: {
orderId: string;
amount: number;
}): Promise<{ approved: boolean; authorizationId: string }> {
// PG사 승인 API 호출 — 요청 ID로 멱등 처리
return { approved: true, authorizationId: `auth_${input.orderId}` };
}
export async function capturePayment(input: {
orderId: string;
authorizationId: string;
captureReferenceId: string;
}): Promise<void> {
// 매입 확정 API
}
export async function refundPayment(input: {
orderId: string;
authorizationId: string;
reason: string;
}): Promise<void> {
// 환불 API — 실패 시 재시도 정책 또는 수동 처리 큐로 위임
}
11.3 웹훅에서 시그널 전송
결제 파트너의 웹훅 핸들러는 주문 ID로 워크플로우 ID를 매핑한 뒤, 클라이언트 SDK로 paymentSettled 시그널을 보냅니다. 웹훅은 반드시 서명 검증과 중복 전달 대비를 합니다.
11.4 Worker 등록
// src/payment/worker.ts
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
import { paymentWorkflow } from './workflows';
async function run() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'payments',
});
await worker.run();
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
운영에서는 workflowsPath 대신 번들 전략을 쓰기도 합니다. 태스크 큐 payments는 결제 전용 Worker 풀과 1:1로 맞추는 구성이 흔합니다.
11.5 클라이언트에서 워크플로우 기동(보충)
HTTP API 레이어에서 주문 생성 직후 결제 워크플로우를 붙일 때는 앞서 설명한 @temporalio/client의 Client로 workflow.start를 호출합니다. workflowId는 주문 ID와 1:1 매핑을 권장하며, 동일 ID 재사용 정책(WORKFLOW_ID_REUSE_POLICY)으로 “이미 진행 중인 주문”과 충돌을 방지합니다. 멱등한 “주문 생성” API라면, 동일 요청이 두 번 와도 같은 workflowId로 시작 시도하고 AlreadyStarted 같은 응답을 처리하는 패턴이 흔합니다.
12. Saga 패턴과 보상 트랜잭션
Saga는 여러 서비스에 걸친 작업을 로컬 트랜잭션의 연쇄로 모델링하고, 실패 시 보상(compensating) 작업으로 이전 단계를 되돌리는 패턴입니다. 전통적인 2PC와 달리 각 단계가 자체 커밋 경계를 가지므로, 마이크로서비스·외부 PG·재고 시스템처럼 분산된 환경에 잘 맞습니다.
Temporal에서는 Saga의 “단계”를 Activity로, “진행 방향과 보상 순서”를 워크플로우 제어 흐름으로 표현합니다. 예를 들어 reserveInventory → chargePayment → shipOrder가 실패하면 releaseInventory, refundPayment를 역순으로 호출하는 식입니다. 중요한 점은 다음과 같습니다.
- 보상 Activity도 멱등해야 합니다. 재시도·중복 시그널로 같은 보상이 두 번 호출될 수 있습니다.
- 보상 실패는 워크플로우만으로 “완전 자동 복구”가 어려운 경우가 많습니다. 알림·지원 큐·수동 런북으로 넘기는 분기를 코드에 명시합니다.
- 시그널·타이머와 결합하면 “일정 시간 내 입금 없으면 자동 취소” 같은 비즈니스 규칙을 Saga 단계와 같은 워크플로우 안에서 일관되게 유지할 수 있습니다.
코디네이티드(coordinated) Saga와 코레오그래피(choreography) 방식이 있지만, Temporal을 쓰는 경우 오케스트레이션(중앙 워크플로우가 단계·보상을 호출)이 자연스럽고, 이벤트 히스토리로 감사 추적이 쉽습니다.
13. 모범 사례와 함정
- 워크플로우는 짧게: 수백 단계를 한 워크플로우에 넣기보다 Child Workflow로 모듈화하면 재사용과 타임아웃 정책 분리가 쉽습니다.
- Activity 이름과 페이로드 버전: 스키마 진화 시 필드를 추가하고 기본값을 두거나, 새 Activity로 갈아타며 구 실행을 보호합니다.
- 비밀 정보: 카드 번호 등은 워크플로우 인자로 넣지 않고, 토큰화된 참조만 전달합니다.
- 전역 상태 금지: 워크플로우 간 공유 메모리에 의존하지 않습니다.
- 테스트: 시간 진행을 모킹하는 테스트 환경으로 타이머·시그널 순서를 검증합니다.
14. 정리
Temporal은 실행 기록과 결정론적 워크플로우 코드를 결합해 분산 환경에서도 업무 절차를 끊기지 않게 합니다. Workflow·Activity·Worker의 역할을 명확히 하고, 재시도·타임아웃·시그널·쿼리·버전 관리·관측성을 한 세트로 설계하면, 결제·주문·온보딩처럼 장기 실행과 외부 연동이 많은 도메인에서 운영 품질을 크게 높일 수 있습니다. 이 글의 결제 예제는 출발점으로 삼고, 실제 PG 연동·규정 준수·장애 대응 런북을 팀 표준에 맞게 확장하기 바랍니다.
배포 전 git add, git commit, git push 후 npm run deploy를 실행하는 것이 안전합니다.