Temporal의 내구성 실행: Workflow·Activity·Worker, 리플레이, 재시도, 시그널, 버전 관리
이 글의 핵심
Temporal은 장애·재시작 이후에도 동일한 비즈니스 절차를 재현하는 분산 워크플로우 엔진입니다. Workflow·Activity·Worker, 내구성 실행·재시도, 시그널·쿼리, 타임아웃, 버전 관리, @temporalio TypeScript SDK, Saga(보상) 관점의 결제 오케스트레이션까지 다룹니다.
Temporal은 마이크로서비스, 레거시 API, 메시지 큐에 걸친 업무 절차를 일반 코드로 작성하게 해 주고, 프로세스 재시작이나 네트워크 단절, 인프라 장애가 있어도 같은 순서와 의미로 실행을 이어 가게 하는 오케스트레이션 플랫폼입니다. 이 글에서는 Workflow, Activity, Worker의 역할 분리부터 이벤트 히스토리에 기반한 내구성 실행(durable execution)과 재시도, 시그널·쿼리·업데이트, 타임아웃과 스케줄, 버전 관리, @temporalio TypeScript SDK의 구조를 살펴보고, 마지막으로 Saga(보상 트랜잭션) 관점에서 결제 워크플로우 예제를 읽어 봅니다.
왜 Temporal인가
분산 환경에서 “주문 생성 → 결제 → 재고 차감 → 알림” 같은 절차를 직접 구현하면 단계마다 상태 저장소, 재시도, 타임아웃, 중복 처리(idempotency), 장애 복구 스크립트가 여기저기 흩어집니다. Temporal은 이런 공통 관심사를 워크플로우 실행 엔진과 서버에 기록된 이벤트 히스토리로 모읍니다. 워커 프로세스가 죽어도 서버가 히스토리를 보존하고, 새 워커가 같은 워크플로우 코드를 리플레이해 어디까지 진행했는지 복원합니다.
운영 관점에서도 차이가 큽니다. 크론 스크립트와 상태 테이블로 흩어져 있던 업무가 코드로 표현된 하나의 실행 단위가 되므로, 웹 UI와 메트릭으로 “지금 어떤 주문이 어느 단계에 있는지”를 직접 확인할 수 있습니다.
서버와 워커로 나뉜 아키텍처
Temporal 서버(클러스터)는 네임스페이스별로 워크플로우 실행 상태를 관리하고, 태스크 큐로 작업을 라우팅하며, 이벤트 히스토리를 영속화합니다. 직접 운영하거나 Temporal Cloud를 쓸 수 있습니다. 워커는 사용자가 배포하는 프로세스로, 태스크 큐를 폴링해 워크플로우 태스크와 액티비티 태스크를 실행하며 수평 확장이 가능합니다. 클라이언트는 워크플로우 시작, 시그널 전송, 쿼리 같은 요청을 보내는 애플리케이션 측 SDK입니다.
흐름은 “클라이언트가 실행 시작 → 서버가 히스토리에 이벤트 기록 → 워커가 태스크 처리 → 결과가 다시 히스토리에 기록”의 반복입니다. 중요한 점은 사용자 코드가 실행되는 곳은 언제나 워커이고, 서버는 사용자 코드를 실행하지 않는다는 것입니다. 이 구조가 내구성의 기반입니다.
핵심 개념: Workflow, Activity, Worker
Workflow(워크플로우)
워크플로우는 비즈니스 절차의 오케스트레이션 로직을 표현하는 함수입니다. 가장 중요한 제약은 결정론(determinism)입니다. 같은 이벤트 히스토리로 리플레이할 때 항상 같은 분기와 같은 Activity 호출 순서가 나와야 합니다. 그래서 네트워크·DB·외부 API 호출은 워크플로우에서 직접 하지 않고, 현재 시각이나 난수, 스레드·고루틴 생성도 SDK가 제공하는 결정론적 API로만 다룹니다.
이 규칙의 구체적인 모습은 SDK마다 다릅니다. TypeScript SDK는 워크플로우 코드를 별도의 샌드박스(V8 격리 컨텍스트)에서 실행하면서 Date.now()와 Math.random()을 결정론적인 버전으로 바꿔 주므로, 이 둘은 워크플로우 안에서 써도 됩니다. 대신 Node.js의 fs나 네트워크 API는 쓸 수 없습니다. Go SDK에서는 time.Now() 대신 workflow.Now(), 고루틴 대신 workflow.Go()를 써야 합니다.
외부와 상호작용하는 작업은 Activity로 위임하고, 워크플로우는 Activity 호출과 제어 흐름만 담당합니다.
Activity(액티비티)
Activity는 부작용(side effect)이 있는 작업 단위입니다. HTTP 호출, DB 트랜잭션, 메시지 발행, 파일 처리 등이 여기에 해당합니다. Activity마다 재시도 정책, 타임아웃, 하트비트를 따로 정할 수 있어, 호출하는 외부 시스템의 특성에 맞게 조정합니다.
Worker(워커)
Worker는 태스크 큐에 연결되어 워크플로우 태스크와 액티비티 태스크를 실행합니다. 하나의 Worker 프로세스에 여러 워크플로우 유형과 액티비티 유형을 등록할 수 있고, 처리량은 Worker 인스턴스 수와 Worker별 동시 실행 상한으로 조절합니다.
태스크 큐 이름은 어떤 Worker가 어떤 일을 처리할지 구분하는 논리적 채널입니다. 서비스별, 환경별로 큐를 나누면 배포와 장애 격리에 유리합니다.
네임스페이스(Namespace)
네임스페이스는 논리적인 격리 단위입니다. 프로덕션·스테이징·팀별로 나누면 히스토리 보존 기간이나 권한 같은 설정과 운영을 분리할 수 있습니다. 워크플로우 ID는 네임스페이스 안에서 의미가 있으며, 같은 ID로 동시에 실행 중인 워크플로우는 하나만 존재할 수 있습니다.
Child Workflow와 Continue-As-New
장기 실행(몇 년 단위의 구독 관리, 대량 배치)은 이벤트 히스토리가 커져 리플레이 비용이 늘어납니다. Continue-As-New는 현재 실행을 끝내면서 같은 워크플로우 ID로 새 실행을 시작해, 필요한 상태만 인자로 넘기고 히스토리를 새로 시작하게 합니다. 하위 절차를 독립적인 생명주기로 묶으려면 Child Workflow를 쓰며, 부모는 자식의 완료를 기다리거나 여러 자식을 병렬로 시작할 수 있습니다. 이렇게 나누면 단계별 타임아웃과 취소 정책을 분리하기 쉽습니다.
내구성 실행과 리플레이
서버는 워크플로우 실행을 이벤트의 나열로 저장합니다. 워커가 재시작되거나 캐시에서 실행 상태가 밀려나면, SDK는 저장된 히스토리를 처음부터 재생하며 워크플로우 함수를 다시 실행합니다. 이미 완료된 Activity의 결과는 다시 호출하지 않고 히스토리에서 읽어 오며, 히스토리가 끝나는 지점에 도달하면 그때부터 새로운 명령(Activity 스케줄, 타이머 등)이 서버로 전송됩니다.
이 모델 덕분에 중간 상태를 DB에 직접 저장하지 않아도 절차의 진행 상황이 서버에 남습니다. 다만 리플레이 중에 코드가 히스토리와 다른 명령을 만들면 비결정성 오류(nondeterminism error)가 발생하므로, 워크플로우 코드를 바꿀 때는 뒤에서 다루는 버전 관리가 필요합니다.
재시도 전략과 오류 모델
Activity 재시도
외부 API는 타임아웃, 일시적 5xx, 레이트 리밋으로 실패합니다. Activity의 재시도 정책은 다음 값으로 구성됩니다.
| 설정 | 의미 | 기본값 |
|---|---|---|
| Initial interval | 첫 재시도 전 대기 시간 | 1초 |
| Backoff coefficient | 지수 백오프 배수 | 2.0 |
| Maximum interval | 백오프 상한 | Initial interval의 100배 |
| Maximum attempts | 최대 시도 횟수 | 0(무제한) |
| Non-retryable error types | 재시도해도 의미 없는 오류 타입 | 없음 |
기본 정책은 시도 횟수 제한이 없으므로, 타임아웃(Schedule-to-Close)이나 최대 시도 횟수를 정하지 않으면 실패하는 Activity가 계속 재시도됩니다. 카드 번호 오류나 잔액 부족처럼 비즈니스 규칙상 다시 시도해도 결과가 같은 오류는 non-retryable로 분류해 즉시 실패시킵니다.
재시도는 멱등성(idempotency)과 함께 설계해야 합니다. Activity가 외부 시스템에서는 성공했는데 결과 보고 전에 워커가 죽으면 같은 Activity가 다시 실행되므로, 같은 작업이 두 번 이상 일어날 수 있습니다. 그래서 결제 요청 ID 같은 멱등 키를 외부 시스템에 함께 전달하는 패턴이 일반적입니다.
Workflow Task 타임아웃
워크플로우 태스크는 워커가 히스토리를 받아 워크플로우 코드를 다음 대기 지점까지 실행하고 명령을 돌려주는 한 번의 처리 단위입니다. 무거운 계산이나 버그로 이 처리가 오래 걸리면 Workflow Task Timeout(기본 10초)이 발생하고 태스크가 다시 시도됩니다. 이는 Activity 타임아웃과 별개이므로, 워크플로우 안에는 가벼운 제어 로직만 두는 것이 안전합니다.
시그널(Signal)과 쿼리(Query)
시그널
시그널은 실행 중인 워크플로우 인스턴스에 비동기로 전달되는 외부 이벤트입니다. 결제 게이트웨이 웹훅 수신, 사용자의 주문 취소, 수동 승인 완료 같은 사건을 시그널로 모델링합니다. 워크플로우는 시그널 핸들러에서 상태를 갱신하고, condition 등으로 특정 시그널이 올 때까지 기다릴 수 있습니다.
시그널도 히스토리에 기록되어 리플레이되므로, 핸들러 안에서 비결정적인 연산을 하면 안 됩니다.
쿼리
쿼리는 워크플로우의 현재 상태를 읽기 전용으로 조회하는 경로입니다. 대시보드나 지원 도구에서 “이 주문 워크플로우가 어느 단계인지”를 보여 줄 때 유용합니다. 쿼리는 히스토리에 기록되지 않으므로, 쿼리 핸들러는 상태를 바꾸거나 부작용을 일으키면 안 됩니다.
업데이트(Update)
시그널은 서버가 받았다는 것까지만 보장하고 처리 결과를 돌려주지 않습니다. 그래서 승인 버튼을 누른 사용자에게 “승인됨” 또는 “이미 취소된 주문이라 거부됨”을 바로 보여 주려면, 예전에는 시그널을 보낸 뒤 쿼리로 폴링하는 어색한 조합을 써야 했습니다. Update는 이 간극을 메우는 요청-응답 경로로, 워크플로우 상태를 바꾸면서 값을 반환하고, 호출자는 처리가 끝날 때까지 기다립니다. 검증기(validator)를 붙이면 조건에 맞지 않는 요청을 히스토리에 기록하기 전에 거절할 수 있어, 잘못된 요청이 이벤트 히스토리를 불리지도 않습니다.
import { defineQuery, defineUpdate, setHandler, condition } from "@temporalio/workflow"
export const statusQuery = defineQuery<string>("status")
export const approveUpdate = defineUpdate<boolean, [string]>("approve")
export async function approvalWorkflow(): Promise<void> {
let status = "waiting"
setHandler(statusQuery, () => status)
setHandler(
approveUpdate,
(reason: string) => { status = `approved: ${reason}`; return true },
{ validator: (_reason: string) => { if (status !== "waiting") throw new Error("already decided") } },
)
await condition(() => status !== "waiting")
}
트레이드오프는 가용성입니다. Update는 워커가 실제로 처리해야 응답이 오므로, 워커가 전부 내려가 있으면 호출자가 기다리다 타임아웃을 맞습니다. 반면 시그널은 서버에 기록되기만 하면 성공이므로 워커 장애와 무관하게 받아집니다. 결과를 즉시 알아야 하는지가 선택 기준입니다.
타임아웃과 스케줄링
Activity 타임아웃의 종류
Activity에는 Start-to-Close와 Schedule-to-Close 중 적어도 하나를 지정해야 합니다.
- Schedule-to-Start: 태스크가 큐에 올라간 뒤 Worker가 가져가기까지 허용하는 시간입니다. Worker 부족이나 큐 적체를 감지하는 데 쓰지만, 이 타임아웃은 재시도되지 않으므로 보통은 메트릭으로 감시합니다.
- Start-to-Close: Worker가 Activity를 시작한 뒤 한 번의 시도가 끝나기까지의 시간입니다. 시도마다 적용되며, 넘기면 재시도 정책에 따라 다시 시도합니다.
- Schedule-to-Close: 재시도를 모두 포함한 전체 시간의 상한입니다.
- Heartbeat timeout: 장시간 작업은 진행 중에 하트비트를 보내고, 이 시간 동안 하트비트가 없으면 멈춘 것으로 보고 실패 처리합니다. 하트비트에 진행 상황을 담으면 재시도할 때 이어서 처리할 수 있습니다.
결제·정산 연동에서는 파트너 API의 응답 시간 보장에 맞춰 Start-to-Close를 정하고, 웹훅처럼 외부에서 오는 결과는 Activity로 기다리지 않고 시그널과 타이머로 모델링하는 경우가 많습니다.
워크플로우 실행 타임아웃
전체 비즈니스 절차에 상한을 두려면 Workflow Execution Timeout을 설정할 수 있습니다. 기본값은 무제한이며, 이 타임아웃에 걸리면 워크플로우 코드가 정리 작업을 할 기회 없이 종료되므로 보통은 설정하지 않고 워크플로우 안에서 타이머로 기한을 처리하는 편을 권장합니다.
타이머와 스케줄
타이머를 쓰면 “3일 후에도 미결제면 자동 취소” 같은 요구를 워크플로우 코드 안의 sleep이나 condition의 타임아웃으로 표현할 수 있습니다. 주기적인 배치는 Schedule 기능으로 워크플로우를 정해진 주기에 시작하게 할 수 있습니다. 예전의 Cron 워크플로우 옵션도 있지만, 현재는 일시 정지, 백필, 겹침 정책을 지원하는 Schedule이 권장됩니다. 별도의 크론 서버와 애플리케이션 코드가 분리되어 있던 운영 부담이 줄어듭니다.
버전 관리와 마이그레이션
배포로 워크플로우 코드가 바뀌면, 오래 실행 중인 인스턴스가 리플레이될 때 새 코드가 히스토리와 다른 명령을 만들어 비결정성 오류가 날 수 있습니다. 이를 피하는 대표적인 방법이 패치입니다. 특정 변경을 식별자로 표시해, 기존 실행은 이전 분기로, 새 실행은 새 분기로 가게 합니다.
import { patched, deprecatePatch } from "@temporalio/workflow"
export async function orderWorkflow(): Promise<void> {
if (patched("fraud-check-v2")) {
await fraudCheckV2() // 패치 이후 시작된 실행(또는 이 지점을 처음 지나는 실행)
} else {
await fraudCheckV1() // 이미 v1 경로를 기록한 기존 실행의 리플레이
}
}
// 구 실행이 모두 끝나면: deprecatePatch("fraud-check-v2") + v2 경로만 남기고,
// 그 배포까지 끝난 뒤 다음 배포에서 deprecatePatch 호출도 제거
패치를 한 번에 제거하지 않고 deprecatePatch 단계를 거치는 이유는, 그 사이에 시작되어 patched 마커가 히스토리에 기록된 실행이 남아 있을 수 있기 때문입니다. 이 단계를 건너뛰면 배포 직후 해당 실행들의 워크플로우 태스크가 비결정성 오류로 계속 실패합니다.
다른 방법은 Worker Versioning입니다. Worker에 빌드 ID를 부여하고, 기존 실행은 그 실행을 시작한 버전의 Worker에서 끝까지 처리하게 하며 새 실행만 새 버전으로 보내는 방식입니다. 실행 기간이 짧은 워크플로우라면 패치 코드 없이 안전하게 배포할 수 있지만, 이전 버전 Worker를 실행이 끝날 때까지 유지해야 합니다. 이 기능의 API는 SDK와 서버 버전에 따라 바뀌어 왔으므로 사용하는 버전의 문서를 확인해야 합니다.
마이그레이션 계획에는 실행 중인 워크플로우 수, 최대 실행 기간, 외부 시스템 계약이 바뀌는 시점을 함께 적어 둡니다. Activity의 입출력 형식을 바꿀 때는 필드를 추가하고 기본값을 두는 식으로 하위 호환을 유지하거나, 새 Activity 이름으로 전환하는 편이 안전합니다.
검색 속성(Search Attributes)과 운영 조회
실행 중인 인스턴스를 주문 번호, 고객 ID, 결제 상태로 찾으려면 검색 속성을 정의해 워크플로우 시작 시 채우거나 실행 중에 갱신합니다(TypeScript SDK에서는 upsertSearchAttributes). 그러면 웹 UI나 API에서 “미결제 건만”, “특정 상점만” 같은 조건으로 실행을 필터링할 수 있습니다. 검색 속성은 가시성 저장소에 평문으로 인덱싱되므로 민감한 값은 넣지 않습니다.
TypeScript SDK(@temporalio/*) 구조
공식 TypeScript SDK는 역할별로 패키지가 나뉘어 있고, 워크플로우 코드는 Worker가 따로 번들링해 샌드박스에서 실행합니다.
패키지 역할
| 패키지 | 역할 |
|---|---|
@temporalio/workflow | 워크플로우 함수 전용 API. proxyActivities, defineSignal, defineQuery, sleep, condition 등. 샌드박스 안에서 실행되므로 Node.js API를 쓸 수 없습니다. |
@temporalio/activity | Activity 실행 컨텍스트. heartbeat(), Context.current()의 cancellationSignal(AbortSignal) 등을 Activity 구현에서 사용합니다. |
@temporalio/worker | Worker.create로 태스크 큐를 구독하고 워크플로우·액티비티를 실행합니다. |
@temporalio/client | Connection, Client로 실행 시작, 시그널, 쿼리, 업데이트, 취소, 강제 종료 등 제어 요청을 보냅니다. |
@temporalio/testing | 시간 건너뛰기(time skipping)를 지원하는 테스트 환경과 Activity 테스트 도구입니다. |
워크플로우 코드는 일반 애플리케이션 코드와 다른 방식으로 번들링되어 샌드박스에서 실행되므로, 워크플로우 파일에서 Activity 구현이나 Node 전용 모듈을 직접 import하면 번들링 단계에서 오류가 납니다. Activity는 import type으로 타입만 가져와 proxyActivities로 호출합니다. Worker는 개발 중에는 workflowsPath로 소스를 지정하고, 운영에서는 bundleWorkflowCode로 미리 만든 번들을 workflowBundle 옵션으로 넘기는 방식을 많이 씁니다.
클라이언트: 실행 시작과 제어
API 서버나 배치 잡에서 워크플로우를 시작할 때는 @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',
// Temporal Cloud 등에서는 tls 옵션과 API 키 또는 mTLS 인증서를 설정
});
return new Client({
connection,
namespace: process.env.TEMPORAL_NAMESPACE ?? 'default',
});
}
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],
});
return handle;
}
함수 이름 문자열 대신 워크플로우 함수를 import type으로 가져와 넘기면 인자 타입까지 검사됩니다. 이미 시작된 실행은 client.workflow.getHandle(workflowId)로 핸들을 얻어 signal, query, executeUpdate, cancel, terminate를 호출합니다. client.workflow.signalWithStart를 쓰면 “실행이 없으면 시작하고, 있으면 시그널만 보내는” 동작을 한 번의 요청으로 처리할 수 있어, 웹훅이 워크플로우 시작보다 먼저 도착하는 경합을 줄일 수 있습니다.
Worker 설정에서 자주 쓰는 옵션
maxConcurrentActivityTaskExecutions와 maxConcurrentWorkflowTaskExecutions는 프로세스당 동시 실행 상한이고, identity는 로그와 UI에 표시되는 워커 식별자, shutdownGraceTime은 종료 신호를 받은 뒤 진행 중인 Activity를 마칠 시간입니다. 페이로드를 암호화하거나 압축해야 한다면 데이터 컨버터에 페이로드 코덱(PayloadCodec)을 연결합니다. 이렇게 하면 서버와 히스토리에는 암호화된 페이로드만 저장됩니다.
테스트
@temporalio/testing의 TestWorkflowEnvironment.createTimeSkipping()을 쓰면 테스트 서버가 타이머를 즉시 진행시켜, 72시간 대기 같은 로직도 몇 초 안에 검증할 수 있습니다. 시그널 순서, 타이머, 재시도 동작을 결정적으로 테스트할 수 있습니다. 또 운영 환경의 히스토리를 내려받아 새 코드로 리플레이해 보는 리플레이 테스트(Worker.runReplayHistory)를 CI에 넣으면, 배포 후에야 드러나는 비결정성 오류를 미리 잡을 수 있습니다.
모니터링과 디버깅
Temporal 웹 UI에서는 워크플로우 ID별로 이벤트 히스토리, 실패 원인, 재시도 횟수, 대기 중인 Activity, 받은 시그널을 확인할 수 있어, 어느 Activity에서 멈췄는지 추적하기 좋습니다.
Worker와 서버는 처리량, 태스크 지연(schedule-to-start latency), 큐 적체, 실패율 같은 메트릭을 Prometheus 형식 등으로 노출합니다. 애플리케이션 로그에는 워크플로우 ID, Run ID, Activity 유형을 구조화해 넣으면 지원 티켓과 실행 기록을 연결하기 쉽습니다. OpenTelemetry 인터셉터로 클라이언트 요청부터 워크플로우와 Activity까지 분산 추적을 연결하면, 마이크로서비스 간 지연이 워크플로우의 어느 단계에서 생기는지 볼 수 있습니다.
디버깅에서 자주 만나는 문제는 세 가지입니다. 첫째, 비결정성 오류는 최근 코드 변경이 과거 히스토리와 맞지 않을 때 발생하므로 패치와 버전 전략을 점검합니다. 둘째, non-retryable로 분류했어야 할 오류가 무제한 기본 재시도로 계속 반복되면 외부 시스템에 부하를 주므로 재시도 정책을 점검합니다. 셋째, 여러 시그널이 거의 동시에 들어오면 처리 순서가 비즈니스 규칙과 맞는지 확인해야 합니다.
예제: 결제 워크플로우(TypeScript SDK)
아래는 “카드 승인(authorization) → PG사의 결제 확인 웹훅 대기(시그널) → 매입(capture), 실패하면 승인 취소”를 단순화한 예시입니다. 카드 결제에서 승인은 한도를 잡아 두는 단계이고 실제 청구는 매입에서 일어나므로, 매입 전에 실패하면 환불이 아니라 승인 취소(void)로 보상합니다. 프로덕션에서는 PCI 범위, PG사 API, 멱등 키, 사기 탐지, 감사 로그를 추가해야 합니다.
워크플로우와 시그널 정의
// src/payment/workflows.ts
import * as wf from '@temporalio/workflow';
import type * as activities from './activities';
const { authorizeCard, capturePayment, voidAuthorization } = 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'
| 'voided'
| '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) => {
if (state !== 'awaiting_settlement') return; // 중복·뒤늦은 웹훅 무시
lastReference = payload.referenceId;
state = payload.status === 'success' ? 'settlement_ok' : '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 || state === 'failed') {
await voidAuthorization({
orderId,
authorizationId: auth.authorizationId,
reason: settled ? 'settlement_rejected' : 'settlement_timeout',
});
state = 'voided';
return;
}
await capturePayment({
orderId,
authorizationId: auth.authorizationId,
captureReferenceId: lastReference,
});
state = 'captured';
}
condition은 조건이 참이 되면 true를, 타임아웃이 지나면 false를 반환합니다. 72시간 안에 결제 확인 시그널이 오지 않으면 승인 취소 Activity를 호출합니다. 시그널 핸들러는 상태만 결정론적으로 갱신하고, 이미 처리한 뒤에 들어온 중복 웹훅은 무시합니다. 승인 이전에 시그널이 먼저 도착하는 경우까지 처리하려면, 핸들러가 받은 값을 따로 저장해 두었다가 승인 뒤에 확인하도록 바꾸면 됩니다.
Activity 구현(개념)
// src/payment/activities.ts
export async function authorizeCard(input: {
orderId: string;
amount: number;
}): Promise<{ approved: boolean; authorizationId: string }> {
// PG사 승인 API 호출: orderId 기반 멱등 키를 함께 전달
return { approved: true, authorizationId: `auth_${input.orderId}` };
}
export async function capturePayment(input: {
orderId: string;
authorizationId: string;
captureReferenceId: string;
}): Promise<void> {
// 매입 API 호출
}
export async function voidAuthorization(input: {
orderId: string;
authorizationId: string;
reason: string;
}): Promise<void> {
// 승인 취소 API 호출: 이미 취소된 승인에 대한 응답도 성공으로 처리해 멱등하게
}
웹훅에서 시그널 전송
결제 파트너의 웹훅 핸들러는 주문 ID로 워크플로우 ID(payment-${orderId})를 만든 뒤, 클라이언트 SDK로 paymentSettled 시그널을 보냅니다. 웹훅은 반드시 서명을 검증하고, PG사가 같은 웹훅을 여러 번 보낼 수 있다는 전제로 처리해야 합니다.
Worker 등록
// src/payment/worker.ts
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
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);
});
Worker 프로세스는 워크플로우 모듈을 직접 import하지 않고 workflowsPath로 경로만 넘깁니다. Worker가 그 경로의 코드를 번들링해 샌드박스에 올리기 때문입니다. 태스크 큐 payments를 결제 전용 Worker 풀이 구독하게 하면, 다른 업무의 부하가 결제 처리에 영향을 주지 않습니다.
주문 API에서 워크플로우 시작
HTTP API에서 주문 생성 직후 결제 워크플로우를 시작할 때는 앞의 startPaymentWorkflow처럼 client.workflow.start를 호출합니다. workflowId를 주문 ID에서 만들면, 같은 주문에 대해 실행 중인 워크플로우가 이미 있을 때 WorkflowExecutionAlreadyStartedError가 발생하므로 중복 시작이 막힙니다. 주문 생성 API가 재시도되어 같은 요청이 두 번 들어와도 이 오류를 “이미 진행 중”으로 처리하면 됩니다. 종료된 실행과 같은 ID를 다시 쓸 수 있는지는 workflowIdReusePolicy 옵션으로 정합니다.
Saga 패턴과 보상 트랜잭션
Saga는 여러 서비스에 걸친 작업을 로컬 트랜잭션의 연쇄로 모델링하고, 실패하면 보상(compensating) 작업으로 앞 단계의 효과를 되돌리는 패턴입니다. 전통적인 2단계 커밋(2PC)과 달리 각 단계가 자체 커밋 경계를 가지므로, 마이크로서비스나 외부 PG, 재고 시스템처럼 분산된 환경에 맞습니다.
Temporal에서는 Saga의 각 단계를 Activity로, 진행 방향과 보상 순서를 워크플로우의 제어 흐름으로 표현합니다. 예를 들어 reserveInventory → chargePayment → shipOrder 중 하나가 실패하면, 그때까지 성공한 단계의 보상인 refundPayment, releaseInventory를 역순으로 호출합니다. 보상 함수를 배열에 쌓아 두었다가 catch 블록에서 거꾸로 실행하는 방식이 흔히 쓰입니다.
보상 Activity도 재시도나 중복 호출로 두 번 실행될 수 있으므로 멱등해야 합니다. 또 보상 자체가 실패하는 경우는 워크플로우만으로 자동 복구하기 어려운 경우가 많아서, 알림이나 수동 처리 큐로 넘기는 분기를 코드에 명시해 두어야 합니다. 시그널과 타이머를 함께 쓰면 “일정 시간 안에 입금이 없으면 자동 취소” 같은 규칙도 같은 워크플로우 안에서 일관되게 유지할 수 있습니다.
Saga를 구현하는 방식에는 중앙 조정자가 각 단계와 보상을 호출하는 오케스트레이션과, 서비스들이 이벤트를 주고받으며 각자 다음 단계를 진행하는 코레오그래피가 있습니다. Temporal은 오케스트레이션 방식에 해당하며, 전체 흐름이 한 곳의 코드와 이벤트 히스토리에 남아 감사 추적이 쉽습니다.
히스토리 한도, Local Activity, 비밀 정보 등 운영 함정
한 실행의 이벤트 히스토리는 51,200개 이벤트 또는 50MB를 넘으면 강제로 종료되며, 10,240개 이벤트(또는 10MB)부터 경고가 기록됩니다. 루프를 돌며 폴링하거나 큐를 소비하는 장수 워크플로우는 일정 반복마다 continueAsNew로 새 실행을 시작해야 합니다. 이 한도를 늦게 알면 몇 달 동안 잘 돌던 워크플로우가 어느 날 한꺼번에 종료되는 일이 생깁니다. TypeScript SDK에서는 workflowInfo().continueAsNewSuggested로 서버가 권하는 시점을 확인할 수 있습니다.
proxyLocalActivities로 호출하는 Local Activity는 태스크 큐를 거치지 않고 워크플로우를 실행 중인 같은 Worker에서 바로 실행되므로 지연이 작습니다. 하지만 워크플로우 태스크 처리의 일부로 실행되기 때문에 오래 걸리면 태스크 타임아웃과 재실행 비용이 커집니다. 캐시 조회나 간단한 변환처럼 짧은 작업에만 쓰고, 외부 API 호출은 일반 Activity로 둡니다.
워크플로우 인자와 Activity 결과는 히스토리에 그대로 저장되므로, 카드 번호 같은 비밀 정보는 넣지 않고 토큰화된 참조만 전달합니다(또는 앞에서 설명한 페이로드 코덱으로 암호화합니다). 큰 데이터도 히스토리 크기를 키우므로 외부 저장소에 두고 참조만 넘깁니다.
수백 단계를 한 워크플로우에 넣기보다는 Child Workflow로 나누면 재사용과 타임아웃 정책 분리가 쉬워집니다. Worker의 동시 실행 상한을 무작정 올리기보다는 Worker 인스턴스를 수평 확장하는 편이 장애 격리에 유리합니다. 같은 로직을 Python SDK(@workflow.defn, workflow.execute_activity)로 작성해도 결정론 규칙과 타임아웃 모델은 동일합니다.