Temporal.io TypeScript SDK로 결제·승인·온보딩 워크플로우 구현하기 — Cron·메시지 큐 없이 장기 실행 비즈니스 프로세스를 내구성 있게 만드는 법
결제 처리 도중 서버가 죽었을 때, 재시도 큐에서 이미 청구된 카드를 또 긁지 않으려면 어떻게 해야 할까요? 매니저 승인을 기다리는 동안 프로세스가 종료되면 어디서부터 다시 시작해야 할까요? 저도 처음엔 이런 문제를 BullMQ로 풀려다가 상태 추적용 DB 테이블, 재시도 스케줄러, 타임아웃 처리 코드를 차례로 쌓다 보니 결국 직접 미니 워크플로우 엔진을 짜는 꼴이 됐습니다. Temporal.io를 알게 된 건 그 이후였는데, 그때 처음 든 생각이 "이게 진작 있었어야 했다"였습니다.
Temporal.io는 장기 실행 비즈니스 프로세스를 코드로 표현하고, 서버 충돌·배포 재시작·네트워크 파티션이 발생해도 워크플로우 상태를 자동으로 보존·재개하는 내구성 실행(Durable Execution) 플랫폼입니다. 결제 취소 창을 24시간 열어두거나, 매니저 승인을 최대 7일 기다리거나, 구독 요금을 30일마다 청구하는 로직을 TypeScript 함수 한 벌로 표현할 수 있습니다.
이 글에서는 Temporal의 핵심 동작 원리(이벤트 히스토리 기반 내구성)와 실제 비즈니스 시나리오별 TypeScript SDK 구현 패턴을 다룹니다. 결제 Saga 보상 트랜잭션, Signal 기반 다단계 승인, Cron 없는 구독 결제, 온보딩 파이프라인까지 코드 중심으로 살펴보겠습니다.
핵심 개념
Durable Execution: 실패가 없는 것처럼 코드를 작성한다
Temporal의 철학을 한 줄로 요약하면 "실패가 없는 것처럼 워크플로우 코드를 짤 수 있게 한다"입니다. 비결은 **이벤트 히스토리(Event History)**에 있습니다. Temporal Server는 Workflow 실행 중 발생하는 모든 이벤트(Activity 스케줄링·완료, Signal 수신, Timer 시작·만료 등)를 영구 저장소에 기록합니다. Worker 프로세스가 죽으면 새 Worker가 이 히스토리를 순서대로 재실행(Replay)해서 정확히 중단된 지점의 상태로 복원한 뒤 다음 작업을 이어받습니다.
이 구조 덕분에 멱등성 보장을 위한 중복 방지 로직을 별도로 짤 필요가 없습니다. 이미 완료된 Activity는 Replay 시 다시 호출되지 않고 히스토리에 저장된 결과가 그대로 반환됩니다. 대신 Workflow 함수는 반드시 **결정론적(Deterministic)**이어야 합니다.
TypeScript SDK는 이 결정론 제약을 Node.js 컨텍스트 기반 워크플로우 샌드박스로 강제합니다. 샌드박스 안에서는 Date, Math.random, setTimeout 같은 전역 API가 결정론적 버전으로 치환되므로 실수로 호출해도 Replay가 깨지지는 않습니다. 다만 의미 있는 지연은 반드시 @temporalio/workflow의 sleep()을 사용해야 Worker 재시작에도 유지되는 서버 측 Timer로 관리됩니다. HTTP 요청, DB 쿼리 같은 외부 I/O는 여전히 전부 Activity로 분리해야 하며, 이 원칙만 지키면 결정론 제약은 대체로 자연스럽게 지켜집니다.
Workflow·Activity·Worker: 역할 분리의 원칙
Temporal의 구성요소를 이해하는 게 첫 번째 관문입니다.
| 구성요소 | 역할 | 예시 |
|---|---|---|
| Workflow | 비즈니스 로직의 흐름을 정의하는 내구성 함수 | "카드 청구 → 24시간 대기 → 미승인 시 환불" |
| Activity | 부수 효과를 담당하는 실제 작업 단위 | 카드 청구 API 호출, 이메일 전송, DB 쓰기 |
| Worker | Workflow와 Activity 코드를 실행하고 결과를 서버에 보고 | 실제로 코드가 돌아가는 프로세스 |
| Signal | 실행 중인 Workflow에 외부 이벤트를 비동기로 전달 | 승인 완료 이벤트, 취소 요청 |
| Query | Workflow 내부 상태를 동기적으로 조회 | 현재 처리 단계, 남은 금액 |
| Update | Signal + Query의 혼합. 상태 변경과 즉시 응답을 함께 처리 | 승인 처리 후 결정 결과 즉시 반환 |
Workflow는 "무엇을, 어떤 순서로"를 결정하고, Activity는 "실제로 외부에 무언가를 한다"는 역할을 맡습니다.
// activities.ts — 실제 부수효과 담당
export async function chargeCard(orderId: string): Promise<void> {
await paymentGateway.charge(orderId);
}
export async function refundPayment(orderId: string): Promise<void> {
await paymentGateway.refund(orderId);
}
export async function sendReceipt(orderId: string): Promise<void> {
await emailService.send(orderId, 'receipt');
}// worker.ts — Worker 프로세스 설정
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
async function run() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'payment-queue',
});
await worker.run();
}
run().catch(console.error);Signal·condition·Query: 외부 이벤트를 Workflow 안으로
Workflow가 실행되는 동안 외부에서 "승인 완료" 또는 "취소 요청" 같은 이벤트를 어떻게 전달할까요? Temporal은 아래 메커니즘을 제공합니다.
- Signal: 실행 중인 Workflow에 외부 이벤트를 비동기로 전달. Workflow 내부에서
setHandler로 수신 - condition(): 특정 조건이 true가 될 때까지 대기. 타임아웃 지정 가능하며, 타임아웃 시
false반환 - Query: Workflow 내부 상태를 동기적으로 조회. 부수 효과 없이 읽기 전용
- Update: Signal + Query의 혼합. 상태 변경과 즉시 응답을 동시에 처리
이 조합으로 "매니저가 승인 버튼을 누를 때까지 기다리되, 7일이 지나면 자동 거절"같은 로직을 별도의 폴링 서버나 스케줄러 없이 구현할 수 있습니다.
실전 적용
결제 워크플로우와 Saga 보상 트랜잭션
분산 환경에서 결제는 여러 단계로 이루어집니다. 카드 청구, 재고 예약, 배송 준비 — 중간에 어느 단계가 실패했을 때 이전 단계를 어떻게 되돌릴지가 늘 골칫거리입니다. 전통적인 2PC(Two-Phase Commit) 없이 이를 구현하는 패턴이 Saga이고, Temporal은 이를 보상 Activity(Compensating Activity)로 자연스럽게 표현할 수 있습니다.
TypeScript SDK로 이 패턴을 구현하면 다음과 같습니다.
// workflows/orderWorkflow.ts
import { proxyActivities, defineSignal, setHandler } from '@temporalio/workflow';
import type * as activities from '../activities/orderActivities';
const {
chargeCard,
reserveInventory,
scheduleShipment,
refundPayment,
releaseInventory,
sendReceipt,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
retry: {
maximumAttempts: 3,
nonRetryableErrorTypes: ['InsufficientFundsError', 'CardDeclinedError'],
},
});
export const cancelSignal = defineSignal<[{ reason: string }]>('cancel');
export async function orderWorkflow(orderId: string): Promise<string> {
let cancelled = false;
let cancelReason = '';
setHandler(cancelSignal, ({ reason }) => {
cancelled = true;
cancelReason = reason;
});
let charged = false;
let inventoryReserved = false;
try {
await chargeCard(orderId);
charged = true;
// 취소 체크는 Activity 사이에서만 일어남.
// Activity 실행 중 즉시 중단하려면 CancellationScope로 감싸야 함.
if (cancelled) throw new Error('취소 요청됨');
await reserveInventory(orderId);
inventoryReserved = true;
if (cancelled) throw new Error('취소 요청됨');
await scheduleShipment(orderId);
await sendReceipt(orderId);
return 'completed';
} catch (err) {
if (inventoryReserved) await releaseInventory(orderId);
if (charged) await refundPayment(orderId);
return `failed: ${cancelReason || String(err)}`;
}
}nonRetryableErrorTypes로 재시도해도 의미 없는 오류 타입을 지정하는 부분이 포인트입니다. 카드 잔액 부족을 3번 재시도하는 건 의미가 없으니까요. 그리고 charged, inventoryReserved 플래그로 "어느 단계까지 완료됐는지"를 추적해서 보상 트랜잭션의 범위를 정확히 제어합니다.
한 가지 주의할 점은 이 취소 체크가 Activity 사이의 경계에서만 이루어진다는 것입니다. 이미 실행 중인 Activity를 즉시 중단하려면 CancellationScope.cancellable(...)로 감싸고, Activity 쪽에서도 heartbeat 기반 취소 요청을 처리해야 합니다.
다단계 승인 워크플로우
구매 요청이 들어오면 매니저가 승인하고, 금액이 클 경우 재무팀 추가 승인이 필요한 시나리오입니다. 승인 버튼 클릭이 HTTP 요청으로 들어오더라도 Workflow 안에서는 Signal로 수신된다는 점이 핵심입니다.
// workflows/purchaseApprovalWorkflow.ts
import {
proxyActivities,
defineSignal,
setHandler,
condition,
} from '@temporalio/workflow';
import type * as activities from '../activities/approvalActivities';
interface ApprovalDecision {
approved: boolean;
comment?: string;
}
interface PurchaseRequest {
id: string;
amount: number;
requester: string;
description: string;
}
const { notifyManager, notifyFinance, processPurchase, rejectRequest } =
proxyActivities<typeof activities>({
startToCloseTimeout: '10 seconds',
retry: { maximumAttempts: 3 },
});
export const managerApprovalSignal =
defineSignal<[ApprovalDecision]>('managerApproval');
export const financeApprovalSignal =
defineSignal<[ApprovalDecision]>('financeApproval');
export async function purchaseApprovalWorkflow(
request: PurchaseRequest
): Promise<string> {
let managerDecision: ApprovalDecision | null = null;
let financeDecision: ApprovalDecision | null = null;
setHandler(managerApprovalSignal, (d) => { managerDecision = d; });
setHandler(financeApprovalSignal, (d) => { financeDecision = d; });
await notifyManager(request);
const managerResponded = await condition(
() => managerDecision !== null,
'7 days'
);
if (!managerResponded || !managerDecision?.approved) {
await rejectRequest(request, '매니저 미승인 또는 타임아웃');
return 'rejected';
}
if (request.amount > 10_000) {
await notifyFinance(request);
const financeResponded = await condition(
() => financeDecision !== null,
'3 days'
);
if (!financeResponded || !financeDecision?.approved) {
await rejectRequest(request, '재무팀 미승인 또는 타임아웃');
return 'rejected';
}
}
await processPurchase(request);
return 'approved';
}승인 버튼이 눌리면 이를 어떻게 Signal로 연결할까요? 실무에서는 API 서버(예: Express)에서 승인 엔드포인트를 만들고, 그 핸들러 안에서 Temporal Client의 handle.signal()을 호출해 실행 중인 Workflow에 이벤트를 전달합니다.
// api/approval.ts — 승인 API 엔드포인트
import express from 'express';
import { Client } from '@temporalio/client';
import { managerApprovalSignal } from '../workflows/purchaseApprovalWorkflow';
const app = express();
const temporal = new Client();
app.post('/purchases/:id/approve', async (req, res) => {
const handle = temporal.workflow.getHandle(`purchase-${req.params.id}`);
await handle.signal(managerApprovalSignal, {
approved: req.body.approved,
comment: req.body.comment,
});
res.json({ ok: true });
});이 구조에서 API 서버는 Signal만 던지고 즉시 응답합니다. 실제 승인 이후 프로세싱은 Workflow가 이어받아 진행하기 때문에 HTTP 응답 지연이나 서버 재시작이 승인 처리 흐름에 영향을 주지 않습니다.
Cron 없는 구독 결제 워크플로우
이 패턴은 처음 봤을 때 가장 신선했습니다. Cron 없이 30일마다 청구하는 로직을 while 루프와 sleep()으로 표현할 수 있습니다.
// workflows/subscriptionWorkflow.ts
import {
proxyActivities,
sleep,
defineSignal,
setHandler,
continueAsNew,
} from '@temporalio/workflow';
import type * as activities from '../activities/subscriptionActivities';
const { chargeMonthlyFee, sendPaymentFailureNotice, cancelSubscription } =
proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
retry: { maximumAttempts: 5, initialInterval: '1 hour' },
});
export const cancelSignal = defineSignal('cancel');
const ITERATIONS_BEFORE_CONTINUE = 100;
export async function subscriptionWorkflow(
customerId: string,
iteration = 0
): Promise<void> {
let isCancelled = false;
setHandler(cancelSignal, () => { isCancelled = true; });
let current = iteration;
while (!isCancelled) {
try {
await chargeMonthlyFee(customerId);
} catch (err) {
await sendPaymentFailureNotice(customerId);
}
// 타이머는 Temporal Server가 관리 — Worker 재시작에 영향받지 않음
await sleep('30 days');
current += 1;
if (current >= ITERATIONS_BEFORE_CONTINUE) {
// 히스토리가 계속 누적되지 않도록 새 실행으로 이어짐
await continueAsNew<typeof subscriptionWorkflow>(customerId, 0);
}
}
await cancelSubscription(customerId);
}주목할 점은 sleep('30 days')가 Worker 프로세스에서 타이머를 돌리는 게 아니라는 것입니다. Timer 만료는 Temporal Server가 관리하고, 만료 시점에 Worker에게 알려줍니다. Worker가 배포로 재시작되어도 타이머는 영향받지 않습니다.
무한 루프 성격의 Workflow는 이벤트 히스토리가 계속 누적되는 문제가 있습니다. 히스토리 크기가 커지면 Replay 비용이 증가하고, 서버가 정한 상한(공식 가이드는 이벤트 수 기준 대략 5만개 근방을 상한, 그보다 훨씬 이전부터 경고 임계로 안내)에 다다르면 실행이 거부됩니다. 그래서 위 예제처럼 일정 반복 주기마다 continueAsNew로 새 실행을 시작해 히스토리를 리셋하는 패턴이 정석입니다. 실제 임계값은 SDK/서버 버전에 따라 다르니 공식 문서의 최신 수치를 확인하시길 권합니다.
사용자 온보딩 파이프라인
온보딩에서 Temporal이 특히 빛나는 이유는 이메일 인증, KYC, 문서 업로드처럼 사람이 개입하는 외부 검증 단계를 하나의 코드 흐름으로 통합할 수 있기 때문입니다. 별도의 스케줄러 없이 "24시간 후 리마인더"가 코드 안에 선언적으로 표현됩니다.
// workflows/onboardingWorkflow.ts
import {
proxyActivities,
defineSignal,
setHandler,
condition,
} from '@temporalio/workflow';
import type * as activities from '../activities/onboardingActivities';
const {
sendVerificationEmail,
sendKycRequest,
sendReminderEmail,
activateAccount,
expireOnboarding,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
});
export const emailVerifiedSignal = defineSignal('emailVerified');
export const kycCompletedSignal =
defineSignal<[{ passed: boolean }]>('kycCompleted');
export async function onboardingWorkflow(userId: string): Promise<string> {
let emailVerified = false;
let kycResult: { passed: boolean } | null = null;
setHandler(emailVerifiedSignal, () => { emailVerified = true; });
setHandler(kycCompletedSignal, (result) => { kycResult = result; });
await sendVerificationEmail(userId);
let verified = await condition(() => emailVerified, '24 hours');
if (!verified) {
await sendReminderEmail(userId, 'email-verification');
verified = await condition(() => emailVerified, '48 hours');
}
if (!verified) {
await expireOnboarding(userId);
return 'expired';
}
await sendKycRequest(userId);
const kycDone = await condition(() => kycResult !== null, '7 days');
if (!kycDone || !kycResult?.passed) {
await expireOnboarding(userId);
return 'kyc-failed';
}
await activateAccount(userId);
return 'activated';
}이 로직을 기존 방식으로 구현하려면 별도 스케줄러 테이블, 상태 컬럼, 리마인더 Job이 필요했을 겁니다. Temporal에서는 그 모든 인프라가 Workflow 코드 한 파일로 수렴됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 내구성 보장 | 서버 충돌, 배포 재시작, 네트워크 파티션에도 상태 보존·자동 재개 |
| Cron 대체 | 스케줄러 단일 장애점 없이 재시도 히스토리가 있는 주기 실행 |
| 메시지 큐 대체 | 큐 + 상태 DB + 스케줄러를 단일 Workflow 코드로 통합 |
| 정밀한 재시도 정책 | Activity 단위로 최대 횟수, 백오프 계수, 비재시도 오류 타입 설정 |
| 테스트 용이성 | TestWorkflowEnvironment로 시간 조작(time-skipping) 기반 단위·통합 테스트 |
| 실시간 가시성 | Temporal UI·CLI로 모든 실행 Workflow 상태, 히스토리, 실패 원인 조회 |
| 다양한 언어 지원 | TypeScript, Go, Java, Python, .NET, Rust SDK |
단점
| 항목 | 내용 |
|---|---|
| 결정론적 코드 제약 | 외부 I/O는 반드시 Activity로 분리, 지연은 sleep() 사용, 워크플로우 코드는 순수 논리 흐름만 유지 |
| 버전 관리 복잡성 | 실행 중인 Workflow가 있을 때 코드 변경 시 비결정론 오류 발생. patched API 또는 Worker Versioning 필요 |
| Self-Hosted 운영 비용 | Temporal Server + Elasticsearch + Cassandra/PostgreSQL 인프라에 상당한 엔지니어링 비용 |
| 이벤트 히스토리 한계 | 히스토리 누적에 상한이 존재하여 장기 루프는 continueAsNew 또는 External Storage 필요 |
| 단순 작업에는 과도 | 단일 단계 비동기 작업엔 BullMQ 같은 단순 큐가 더 적합 |
| Temporal Cloud 비용 | 사용량 기반 과금 모델이라 대량 실행 시 비용 계획 필요 |
다른 도구와의 비교
| 도구 | Temporal이 우위인 경우 | 해당 도구가 우위인 경우 |
|---|---|---|
| AWS Step Functions | 코드 중심 표현, 로컬 실행·테스트, 벤더 독립 | AWS 전용 스택에서 IaC·서비스 통합만 필요할 때 |
| Apache Kafka | 다단계 오케스트레이션, 상태 추적, 재시도 정책 | 고처리량 이벤트 스트리밍이 목적이고 상태 관리가 불필요할 때 |
| BullMQ | 다단계 조율, 장기 실행, 결정론적 재개 | 단순 작업 큐(썸네일 생성, 알림 발송 등)만 필요할 때는 인프라·학습 비용이 훨씬 저렴 |
| Inngest | 자체 호스팅 가능, 세밀한 재시도·타임아웃 제어 | 서버리스 이벤트 기반 함수 트리거만 필요할 때 |
| Conductor | 코드 표현력, TypeScript 타입 안정성 | JSON 정의로 워크플로우를 비개발자와 공유해야 할 때 |
실무에서 흔히 하는 실수들
1. Workflow 안에서 외부 I/O 직접 호출
가장 흔한 실수입니다. Workflow 함수 안에서 직접 HTTP 요청이나 DB 쿼리를 하면 Replay 시 실제로 외부 서비스를 다시 호출해버립니다.
// 잘못된 예 — Workflow 안에서 직접 외부 호출
export async function badWorkflow(orderId: string) {
const result = await fetch(`/api/charge/${orderId}`); // 재실행 시 이중 청구 위험
}
// 올바른 예 — Activity로 분리
export async function goodWorkflow(orderId: string) {
await chargeCard(orderId); // proxyActivities로 감싼 Activity 호출
}2. 버전 관리 없이 실행 중인 Workflow 코드 변경
프로덕션에 실행 중인 Workflow가 있는 상태에서 분기 로직을 변경하면 Replay 시 NonDeterminismError가 발생합니다. TypeScript SDK가 제공하는 patched API가 이 문제의 해법입니다.
import { patched } from '@temporalio/workflow';
export async function orderWorkflow(orderId: string) {
if (patched('add-email-step-v2')) {
// 새 Worker에서 실행되는 Workflow용 코드 경로
await sendConfirmationEmail(orderId);
await chargeCard(orderId);
} else {
// 이미 실행 중인 Workflow 호환 경로
await chargeCard(orderId);
}
}패치가 완전히 배포되어 모든 실행 흐름이 새 경로로 흐르게 됐다면 deprecatePatch로 표시해 정리 시점을 관리합니다.
3. Activity 타임아웃을 일괄 설정하는 함정
proxyActivities의 타임아웃을 모든 Activity에 동일하게 적용하면, 빠른 DB 쿼리와 느린 외부 결제 API가 같은 타임아웃으로 묶입니다. Activity를 특성별로 분리하고, 타입 수준에서도 각 proxy가 자신이 담당하는 Activity만 노출하도록 Pick으로 좁혀 실수 여지를 줄이는 게 좋습니다.
// activities/index.ts에서 모든 activities 함수를 export한다고 가정
import type * as activities from '../activities';
type FastActivities = Pick<typeof activities, 'updateOrderStatus' | 'logEvent'>;
type PaymentActivities = Pick<typeof activities, 'chargeCard' | 'refundPayment'>;
// 빠른 내부 작업용
const { updateOrderStatus, logEvent } = proxyActivities<FastActivities>({
startToCloseTimeout: '5 seconds',
});
// 외부 결제 API용 — 더 긴 타임아웃과 재시도
const { chargeCard, refundPayment } = proxyActivities<PaymentActivities>({
startToCloseTimeout: '2 minutes',
retry: { maximumAttempts: 3, initialInterval: '10 seconds' },
});이렇게 하면 updateOrderStatus를 결제 프록시에서 실수로 호출하는 실수를 컴파일 타임에 잡을 수 있습니다.
마치며
Temporal.io는 다단계 조율, 장기 대기, 사람의 개입, 정밀한 재시도 정책이 필요한 비즈니스 프로세스에 특히 강점이 있습니다. 결제 Saga, 승인 파이프라인, 구독 결제, 온보딩처럼 상태와 시간이 얽히는 흐름은 Signal·condition·Activity 세 가지 원칙만 익히면 대부분 표현할 수 있습니다.
반면 단일 단계 비동기 작업(썸네일 생성, 이메일 발송, 로그 집계)만 필요한 시스템에서는 Temporal 도입이 오버엔지니어링이 되기 쉽습니다. BullMQ나 SQS 같은 단순 큐가 학습 비용, 인프라 복잡도, 운영 부담 면에서 훨씬 저렴합니다. 도구 선택의 기준은 "우리가 진짜로 다단계 상태 조율을 하고 있는가" 하나로 좁혀도 큰 무리가 없습니다.
도입을 고려한다면 아래 순서로 접근해 보시길 권합니다.
- 로컬 환경 세팅:
temporal server start-dev로 개발 서버를 띄우고,@temporalio/create스캐폴딩으로 TypeScript 프로젝트 골격을 만들어 봅니다. - 공식 튜토리얼 완주: Learn Temporal의 TypeScript 구독 결제 튜토리얼은 Workflow, Activity, Worker, Client를 한 사이클로 경험하기에 적당합니다.
- 작은 도입 대상 찾기: 현재 Cron이나 BullMQ로 다루고 있는 다단계 프로세스 중 상태 추적과 재시도 로직이 커지고 있는 지점을 하나만 골라 이식해 보시길 권합니다. Temporal의 가치는 그 지점에서 가장 잘 드러납니다.
버전 관리(patched, Worker Versioning), 히스토리 관리(continueAsNew), Activity 취소(CancellationScope, heartbeat) 같은 주제는 별도로 파고들 가치가 충분하므로 첫 프로덕션 이식 이전에 반드시 공식 문서에서 확인해 보시길 권합니다.
참고 자료
- Temporal 공식 문서 — TypeScript Workflow 기초
- Learn Temporal — TypeScript SDK 첫 번째 프로그램
- Learn Temporal — TypeScript 구독 결제 시스템 구축
- Temporal TypeScript SDK 공식 샘플 저장소
- Temporal TypeScript API 레퍼런스
- Temporal 버전 관리 — TypeScript SDK
- Temporal 프로덕션 배포 가이드
- 공식 사용 사례 및 디자인 패턴 문서
- Temporal 공식 블로그 — Reliable Data Processing with Queues and Workflows
- Temporal — Coinbase 사례 연구
- Saga 패턴과 Temporal 분산 트랜잭션 구현
- Temporal 워크플로우와 Activity 모범 사례