Step 01 — 환경 구축과 첫 워크플로우
학습 목표
docker compose 로 Temporal Server 1.22.4 를 띄우고 temporal operator namespace list 로 연결을 확인한다
- Temporal Server / Worker / Client 의 3자 관계를 이해하고, Server 가 내 코드를 실행하지 않는다는 사실을 확인한다
@WorkflowInterface / @WorkflowMethod 로 첫 워크플로우를 정의하고 Worker 를 기동한다
WorkflowClient 로 워크플로우를 동기·비동기로 실행하고 WorkflowId 를 직접 지정한다
- Web UI 와
temporal workflow show 로 이벤트 히스토리를 직접 읽는다
- Activity 를 하나 붙여 히스토리가 8개 → 11개로 늘어나는 것을 before/after 로 실측한다
- Worker 미기동·Task Queue 오타라는 두 함정이 에러 없이 조용히 워크플로우를 매달아 두는 것을 재현한다
선행 스텝: 없음 (실습 프로젝트 셋업 → project/)
예상 소요: 90분
1-0. 실습 준비
Temporal Server 를 먼저 띄웁니다. 이 코스는 temporalio/auto-setup:1.22.4 + PostgreSQL 15 조합을 씁니다.
cd temporal-course
docker compose up -d
결과
[+] Running 4/4
✔ Network temporal-course_default Created 0.1s
✔ Container temporal-postgresql Started 0.6s
✔ Container temporal Started 1.2s
✔ Container temporal-ui Started 1.4s
컨테이너 세 개가 뜹니다. temporal-postgresql 은 히스토리 저장소, temporal 은 gRPC 서버(7233), temporal-ui 는 Web UI(8233) 입니다.
기동에는 15~30초쯤 걸립니다. auto-setup 이미지가 첫 부팅 때 DB 스키마를 만들기 때문입니다. docker compose logs temporal | tail -1 에 Temporal server started. 가 뜨면 준비 완료입니다. CLI 로 연결을 확인합니다.
temporal operator namespace list
결과
Name UUID State Retention
default a7f3b1c2-9e4d-4a8b-b1c6-3f5e7d9a2c40 Registered 72h0m0s
temporal-system 32049b68-7872-4094-8e63-d0dd59896a83 Registered 168h0m0s
default 네임스페이스가 Registered 로 보이면 서버와 CLI 가 정상 연결된 것입니다. temporal-system 은 Temporal 내부용이므로 건드리지 않습니다. temporal --version 은 temporal version 0.11.0 (server 1.22.4, ui 2.21.3) 을 출력해야 합니다.
💡 실무 팁 — CLI 가 서버를 못 찾을 때
temporal CLI 는 기본으로 127.0.0.1:7233 을 봅니다. 다른 주소면 --address 를 매번 붙이는 대신
temporal env set local.address 127.0.0.1:7233 으로 환경을 등록하고 temporal --env local ... 로 쓰세요.
운영 클러스터를 다룰 때 --env prod / --env stg 로 분리해 두면 "실수로 운영에 명령을 날리는" 사고를 줄일 수 있습니다.
Web UI 도 열어 둡니다. 이후 절에서 계속 씁니다.
1-1. 구성 요소 한눈에 — Server 는 내 코드를 실행하지 않는다
Temporal 을 처음 접하면 대부분 이렇게 오해합니다. "워크플로우 코드를 서버에 올리면 서버가 실행해 주는 거겠지." 틀렸습니다.
Temporal Server 는 여러분의 코드를 한 줄도 실행하지 않습니다. 서버가 하는 일은 세 가지뿐입니다.
- 이벤트 히스토리를 저장한다
- 할 일(Task)을 Task Queue 에 얹어 둔다
- 누군가 가져가면 준다
실제로 코드를 실행하는 것은 여러분이 띄운 Worker 프로세스입니다. 그림으로 보면 이렇습니다.
┌──────────────────────────────────────────────────────────────┐
│ Temporal Server (남의 코드) │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Frontend │ │ History │ │ Matching │ │
│ │ (gRPC) │ │ (이벤트 저장) │ │ (Task Queue) │ │
│ └─────┬──────┘ └──────┬───────┘ └────────┬─────────┘ │
│ └─────────────────┴────────────────────┘ │
│ [ PostgreSQL 15 ] │
│ 워크플로우별 이벤트 히스토리 │
└───────────────┬──────────────────────────┬───────────────────┘
│ │
① StartWorkflowExecution ② long-poll 로 Task 를 가져감
(워크플로우 시작 요청) (ORDER_TASK_QUEUE)
▼ ▼
┌────────────────────┐ ┌──────────────────────────────┐
│ Client (내 코드) │ │ Worker (내 코드) │
│ WorkflowClient │ │ OrderWorkflowImpl.java ←실행│
│ .newWorkflowStub() │ │ PaymentActivityImpl.java←실행│
│ .processOrder() │ │ WorkerFactory / Worker │
│ (Spring 컨트롤러 등)│ │ │
└────────────────────┘ └──────────────────────────────┘
프로세스 A 프로세스 B
- Server: 남이 만든 것. 내가 코드를 배포하지 않습니다. 히스토리 저장 + Task 중개만 합니다.
- Worker: 내가 만든 것. 워크플로우 구현체와 액티비티 구현체가 여기에 등록되고 여기서 실행됩니다.
- Client: 내가 만든 것. 워크플로우를 시작하거나 결과를 조회합니다. 보통 API 서버 안에 있습니다.
이 구조에서 곧바로 따라 나오는 결론이 하나 있습니다. Worker 가 없으면 워크플로우는 시작만 되고 아무것도 진행되지 않습니다. 에러도 나지 않습니다. 서버는 "할 일을 큐에 얹어 뒀는데 아무도 안 가져가네" 상태로 조용히 기다립니다. 이것이 이 스텝의 첫 번째 함정이며, 1-8 에서 재현합니다.
💡 비유 — Temporal Server 는 항공 관제탑입니다
관제탑은 비행기를 조종하지 않습니다. 어디로 갈지 지시하고 기록을 남길 뿐, 실제로 조종간을 잡는 것은 조종사(Worker)입니다.
관제탑만 있고 조종사가 없으면 비행기는 활주로에 그대로 서 있습니다. 사고가 난 것도 아니고, 그냥 아무 일도 안 일어납니다.
1-2. 첫 Workflow 인터페이스와 구현
가장 단순한 형태로 시작합니다. 액티비티 없이 문자열만 반환하는 워크플로우입니다.
Temporal Java SDK 1.22.3 에서 워크플로우는 인터페이스 + 구현 클래스 쌍으로 정의합니다.
package com.example.order;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
@WorkflowInterface
public interface OrderWorkflow {
@WorkflowMethod
String processOrder(OrderRequest req);
}
@WorkflowInterface — 이 인터페이스가 워크플로우 계약임을 표시합니다. 이게 없으면 Worker 등록 시점에 예외가 납니다.
@WorkflowMethod — 워크플로우의 진입점입니다. 인터페이스당 정확히 하나만 있을 수 있습니다.
DTO 는 Java 21 record 로 정의합니다.
public record OrderRequest(String orderId, String customerId, String sku,
int qty, long amount, String address) {}
구현 클래스입니다.
public class OrderWorkflowImpl implements OrderWorkflow {
private static final Logger log = Workflow.getLogger(OrderWorkflowImpl.class);
@Override
public String processOrder(OrderRequest req) {
log.info("[{}] 워크플로우 시작 sku={} qty={}", req.orderId(), req.sku(), req.qty());
return "order-" + req.orderId() + " COMPLETED";
}
}
로거를 LoggerFactory.getLogger 가 아니라 Workflow.getLogger 로 가져온 것에 주목하세요. 이유는 Step 02 에서 리플레이를 다루며 자세히 설명합니다. 지금은 "워크플로우 코드 안에서는 Workflow.getLogger 를 쓴다"고만 기억하면 됩니다.
⚠️ 워크플로우 구현 클래스에는 기본 생성자(인자 없는 생성자) 가 있어야 합니다.
Worker 가 워크플로우 인스턴스를 리플렉션으로 만들기 때문입니다. 생성자에 의존성을 주입하려 하면 실행 시점에 실패합니다.
액티비티는 반대로 인스턴스를 직접 만들어 등록하므로 생성자 주입이 자유롭습니다(1-7).
Task Queue 이름은 상수로 뽑습니다. 이유는 1-9 에서 아플 정도로 설명합니다.
public final class Constants {
public static final String ORDER_TASK_QUEUE = "ORDER_TASK_QUEUE";
private Constants() {}
}
1-3. Worker 기동
Worker 는 별도의 main 을 가진 독립 프로세스입니다.
public class OrderWorker {
public static void main(String[] args) {
// ① 서버(127.0.0.1:7233)로의 gRPC 연결
WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs();
// ② 그 연결 위의 클라이언트
WorkflowClient client = WorkflowClient.newInstance(service);
// ③ Worker 를 만들어 낼 팩토리
WorkerFactory factory = WorkerFactory.newInstance(client);
// ④ 특정 Task Queue 를 폴링할 Worker
Worker worker = factory.newWorker(Constants.ORDER_TASK_QUEUE);
// ⑤ 실행 가능한 워크플로우 타입 등록 (구현 "클래스"를 넘긴다)
worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class);
// ⑥ 폴링 시작. 이 호출은 블로킹하지 않는다
factory.start();
System.out.println("Worker started. Task Queue = " + Constants.ORDER_TASK_QUEUE);
}
}
각 호출이 무엇인지 정리합니다.
| 호출 | 역할 |
|---|
WorkflowServiceStubs.newLocalServiceStubs() | 127.0.0.1:7233 로 gRPC 채널 생성. 운영에서는 newServiceStubs(options) 로 주소·TLS 지정 |
WorkflowClient.newInstance(service) | 채널 위에 네임스페이스·데이터 컨버터를 얹은 클라이언트 |
WorkerFactory.newInstance(client) | Worker 들이 공유하는 스레드풀·캐시를 관리 |
factory.newWorker(queue) | 그 Task Queue 를 long-poll 할 Worker 하나 |
registerWorkflowImplementationTypes(...) | 클래스를 등록. Worker 가 매 실행마다 새 인스턴스를 만든다 |
factory.start() | 폴러 스레드 기동 |
실행합니다.
결과 (Worker 콘솔 로그 전문)
09:12:01.845 [main] INFO i.t.s.WorkflowServiceStubsImpl - Created GRPC client for channel: ManagedChannelOrphanWrapper{delegate=ManagedChannelImpl{logId=1, target=127.0.0.1:7233}}
09:12:02.311 [main] INFO i.t.s.WorkflowServiceStubsImpl - Channel 127.0.0.1:7233 is READY
09:12:02.402 [main] INFO i.t.internal.worker.Poller - start: Poller{name=Workflow Poller taskQueue="ORDER_TASK_QUEUE", namespace="default", identity=41233@macbook}
09:12:02.404 [main] INFO i.t.internal.worker.Poller - start: Poller{name=Activity Poller taskQueue="ORDER_TASK_QUEUE", namespace="default", identity=41233@macbook}
09:12:02.406 [main] INFO i.t.internal.worker.Poller - start: Poller{name=Local Activity Poller taskQueue="ORDER_TASK_QUEUE", namespace="default", identity=41233@macbook}
09:12:02.411 [main] INFO i.t.i.w.WorkerFactoryImpl - Started Worker{namespace=default, taskQueue=ORDER_TASK_QUEUE}
Worker started. Task Queue = ORDER_TASK_QUEUE
Poller 가 세 개 뜬 것에 주목하세요. Workflow Task, Activity Task, Local Activity 를 각각 별도 폴러가 long-poll 합니다. 아직 아무것도 안 했는데도 Worker 는 이미 서버에 "일 없나요?" 하고 물어보며 대기 중입니다.
Worker 가 정말 붙었는지 서버 쪽에서도 확인합니다.
temporal task-queue describe --task-queue ORDER_TASK_QUEUE
결과
Workflow Poller Info:
Identity 41233@macbook
Last Access Time 10 seconds ago
Rate Per Second 100000
Activity Poller Info:
Identity 41233@macbook
Last Access Time 10 seconds ago
Rate Per Second 100000
폴러가 등록되어 있습니다. 이 출력이 비어 있으면 Worker 가 안 떴거나 큐 이름이 다르다는 뜻입니다 — 진단 방법으로 계속 쓰게 됩니다.
1-4. Workflow 실행
Client 는 별도 프로세스입니다. Worker 를 띄운 터미널은 그대로 두고 새 터미널을 엽니다.
public class OrderStarter {
public static void main(String[] args) {
WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs();
WorkflowClient client = WorkflowClient.newInstance(service);
OrderRequest req = new OrderRequest(
"1001", "cust-77", "SKU-BLACK-TEE", 2, 39000L, "서울시 강남구 테헤란로 1");
WorkflowOptions options = WorkflowOptions.newBuilder()
.setTaskQueue(Constants.ORDER_TASK_QUEUE)
.setWorkflowId("order-" + req.orderId()) // ← 비즈니스 키를 그대로
.build();
OrderWorkflow workflow = client.newWorkflowStub(OrderWorkflow.class, options);
String result = workflow.processOrder(req); // 동기 실행: 끝날 때까지 블로킹
System.out.println("결과: " + result);
}
}
client.newWorkflowStub(OrderWorkflow.class, options) 가 돌려주는 것은 워크플로우 구현체가 아니라 동적 프록시입니다. workflow.processOrder(req) 를 호출하면 로컬에서 메서드가 실행되는 게 아니라, gRPC 로 StartWorkflowExecution 요청이 서버에 전송됩니다.
./gradlew runStarter 를 실행합니다.
결과 (Client 콘솔 / 같은 시각 Worker 콘솔)
# Client
09:12:04.118 [main] INFO i.t.s.WorkflowServiceStubsImpl - Channel 127.0.0.1:7233 is READY
결과: order-1001 COMPLETED
# Worker
09:12:04.640 [workflow-method-order-1001-8f2a1c] INFO c.e.order.OrderWorkflowImpl - [1001] 워크플로우 시작 sku=SKU-BLACK-TEE qty=2
스레드 이름이 workflow-method-order-1001-... 입니다. Worker 프로세스에서 실행됐다는 증거입니다. Client 프로세스에는 워크플로우 로그가 한 줄도 없습니다.
비동기 실행
동기 호출은 워크플로우가 끝날 때까지 블로킹합니다. 워크플로우가 며칠씩 도는 것이 Temporal 의 정상적인 용법이므로, 실무에서는 대개 비동기로 시작합니다.
OrderWorkflow workflow = client.newWorkflowStub(OrderWorkflow.class, options);
// 시작만 하고 즉시 반환. WorkflowExecution(workflowId + runId)을 돌려준다
WorkflowExecution exec = WorkflowClient.start(workflow::processOrder, req);
System.out.println("started workflowId=" + exec.getWorkflowId() + " runId=" + exec.getRunId());
// 나중에 결과가 필요해지면 다시 붙어서 기다린다
OrderWorkflow attached = client.newWorkflowStub(OrderWorkflow.class, exec.getWorkflowId());
String result = WorkflowStub.fromTyped(attached).getResult(String.class);
System.out.println("결과: " + result);
결과
started workflowId=order-1001 runId=5c2f8e1a-6b3d-4c9f-8a71-2d0e4f6a9b13
결과: order-1001 COMPLETED
WorkflowClient.start 는 메서드 참조(workflow::processOrder)를 받습니다. 스텁이 프록시이기 때문에 가능한 트릭입니다.
💡 실무 팁 — WorkflowId 는 비즈니스 키로 지정하세요
지정하지 않으면 SDK 가 UUID 를 붙입니다. 그러면 "주문 1001 의 워크플로우가 어떻게 됐지?" 를 조회할 방법이 없어져, 별도 매핑 테이블을 만들게 됩니다.
order-1001 처럼 비즈니스 키를 쓰면 조회가 temporal workflow describe -w order-1001 한 줄로 끝나고,
덤으로 중복 실행 방지가 공짜로 따라옵니다. Temporal 은 같은 WorkflowId 로 동시에 두 개가 Running 일 수 없도록 보장하기 때문입니다.
이 정책은 WorkflowIdReusePolicy 로 조절합니다(Step 12 에서 상세히). 결제·주문처럼 멱등성이 중요한 도메인에서는 이것만으로도 큰 이득입니다.
order-1001 이 아직 Running 인 상태에서 ./gradlew runStarter 를 한 번 더 실행하면 바로 확인됩니다.
결과
Exception in thread "main" io.temporal.client.WorkflowExecutionAlreadyStarted:
workflowId=order-1001, runId=5c2f8e1a-6b3d-4c9f-8a71-2d0e4f6a9b13
1-5. Web UI 에서 이벤트 히스토리 직접 확인 ★
여기가 이 스텝의 핵심입니다. Temporal 을 이해한다는 것은 사실상 이벤트 히스토리를 읽을 줄 안다는 뜻입니다.
브라우저에서 http://localhost:8233 을 엽니다. 좌측 상단 네임스페이스가 default 인지 확인하고 Workflows 메뉴를 클릭하면 실행 목록이 나옵니다.
Status Workflow ID Type Start End Run ID
COMPLETED order-1001 OrderWorkflow 2026-03-11 09:12:04 2026-03-11 09:12:04 5c2f8e1a…
order-1001 행을 클릭하면 실행 상세로 들어갑니다. 하단 Event History 탭을 열고 표시 모드를 Compact 가 아니라 History(전체) 로 바꿉니다. Compact 는 요약이라 실제 이벤트가 감춰집니다.
Event History 탭 내용 (액티비티 없는 최초 버전)
| ID | Time | Type | 요약 |
|---|
| 1 | 09:12:04.512 | WorkflowExecutionStarted | workflowType=OrderWorkflow, taskQueue=ORDER_TASK_QUEUE, input=OrderRequest{...} |
| 2 | 09:12:04.512 | WorkflowTaskScheduled | taskQueue=ORDER_TASK_QUEUE, startToCloseTimeout=10s |
| 3 | 09:12:04.631 | WorkflowTaskStarted | identity=41233@macbook, requestId=... |
| 4 | 09:12:04.688 | WorkflowTaskCompleted | scheduledEventId=2, startedEventId=3 |
| 5 | 09:12:04.688 | WorkflowExecutionCompleted | result="order-1001 COMPLETED" |
단 5개입니다. 여기서 곧바로 읽어 낼 것이 몇 가지 있습니다.
- 이벤트 1 은 Client 가
StartWorkflowExecution 을 호출한 순간 서버가 기록한 것입니다. 입력값 전체가 히스토리에 저장됩니다.
- 이벤트 2 는 서버가 "누가 이 워크플로우 코드를 좀 돌려 주세요" 라고 Task Queue 에 얹은 것입니다.
- 이벤트 3 은 Worker 가 그것을 가져간 시각입니다. 2번과 3번 사이의 119ms 가 Worker 가 폴링해서 집어 간 시간입니다.
- 이벤트 4 는 Worker 가 코드를 실행하고 결과를 돌려준 것입니다. 그리고 그 결과로 생긴 것이 이벤트 5 입니다.
이벤트 1 을 클릭해 펼치면 상세가 나옵니다.
{
"workflowType": { "name": "OrderWorkflow" },
"taskQueue": { "name": "ORDER_TASK_QUEUE", "kind": "Normal" },
"input": [ { "orderId": "1001", "customerId": "cust-77", "sku": "SKU-BLACK-TEE",
"qty": 2, "amount": 39000, "address": "서울시 강남구 테헤란로 1" } ],
"workflowExecutionTimeout": "0s",
"workflowTaskTimeout": "10s",
"identity": "41255@macbook",
"attempt": 1
}
workflowExecutionTimeout: 0s 는 "타임아웃 없음(무제한)" 입니다. Temporal 의 기본은 워크플로우가 영원히 살아 있어도 된다입니다.
💡 Web UI 의 Compact 뷰를 믿지 마세요
Compact 뷰는 액티비티 하나를 한 줄로 접어 보여줍니다. 편하지만, WorkflowTaskScheduled/Started/Completed 3종 세트가 감춰집니다.
워크플로우가 멈춘 원인을 찾을 때는 반드시 전체 History 뷰로 보세요. 문제는 대개 감춰진 Workflow Task 쪽에 있습니다.
1-6. CLI 로 같은 것 보기
Web UI 로 본 것을 CLI 로도 봅니다. 운영에서는 CLI 쪽을 훨씬 많이 씁니다.
결과
Status WorkflowId Name StartTime CloseTime
COMPLETED order-1001 OrderWorkflow 2026-03-11T09:12:04Z 2026-03-11T09:12:04Z
temporal workflow describe -w order-1001
결과
Execution Info:
Workflow Id order-1001
Run Id 5c2f8e1a-6b3d-4c9f-8a71-2d0e4f6a9b13
Type OrderWorkflow
Namespace default
Task Queue ORDER_TASK_QUEUE
Start Time 2026-03-11 09:12:04 +0000 UTC
Status COMPLETED
History Length 5
History Size 612
History Length 5 — Web UI 에서 센 것과 정확히 같습니다. History Size 612 는 바이트입니다.
temporal workflow show -w order-1001
결과
Progress:
ID Time Type
1 2026-03-11T09:12:04Z WorkflowExecutionStarted
2 2026-03-11T09:12:04Z WorkflowTaskScheduled
3 2026-03-11T09:12:04Z WorkflowTaskStarted
4 2026-03-11T09:12:04Z WorkflowTaskCompleted
5 2026-03-11T09:12:04Z WorkflowExecutionCompleted
Result:
Status: COMPLETED
Output: ["order-1001 COMPLETED"]
이벤트 상세가 필요하면 temporal workflow show -w order-1001 --output json | jq '.events[1]' 처럼 JSON 으로 뽑습니다.
결과
{
"eventId": "2",
"eventType": "WorkflowTaskScheduled",
"workflowTaskScheduledEventAttributes": {
"taskQueue": { "name": "ORDER_TASK_QUEUE", "kind": "Normal" },
"startToCloseTimeout": "10s", "attempt": 1
}
}
💡 실무 팁 — 히스토리를 파일로 떠 두세요
temporal workflow show -w order-1001 --output json > order-1001.json 으로 저장해 두면
나중에 리플레이 테스트(Step 11)의 입력으로 그대로 쓸 수 있습니다.
운영 장애 때 "이 히스토리로 새 코드가 리플레이되는가"를 검증하는 것이 버저닝의 핵심 안전장치입니다(Step 10).
1-7. Activity 를 하나 붙여 보기
지금까지는 액티비티가 없었습니다. 결제 액티비티 하나를 붙여 히스토리가 어떻게 달라지는지 봅니다.
액티비티도 인터페이스 + 구현입니다.
@ActivityInterface
public interface PaymentActivity {
@ActivityMethod String charge(String orderId, long amount);
@ActivityMethod void refund(String paymentId);
}
public class PaymentActivityImpl implements PaymentActivity {
private static final Logger log = LoggerFactory.getLogger(PaymentActivityImpl.class);
@Override
public String charge(String orderId, long amount) {
log.info("[{}] 결제 요청 amount={}", orderId, amount);
return "pay-" + orderId; // 실제로는 PG 사 HTTP 호출
}
@Override
public void refund(String paymentId) {
log.info("환불 처리 paymentId={}", paymentId);
}
}
액티비티 구현은 Workflow.getLogger 가 아니라 일반 LoggerFactory 를 씁니다. 액티비티는 리플레이되지 않으므로 로그가 중복될 일이 없습니다(Step 02 에서 이유를 설명합니다).
워크플로우에서 호출합니다.
public class OrderWorkflowImpl implements OrderWorkflow {
private static final Logger log = Workflow.getLogger(OrderWorkflowImpl.class);
private final PaymentActivity payment = Workflow.newActivityStub(
PaymentActivity.class,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.build());
@Override
public String processOrder(OrderRequest req) {
log.info("[{}] 워크플로우 시작 sku={}", req.orderId(), req.sku());
String paymentId = payment.charge(req.orderId(), req.amount());
log.info("[{}] 결제 완료 paymentId={}", req.orderId(), paymentId);
return "order-" + req.orderId() + " COMPLETED";
}
}
Workflow.newActivityStub 도 프록시입니다. payment.charge(...) 를 호출해도 PaymentActivityImpl.charge 가 직접 실행되지 않습니다. 대신 "이 액티비티를 스케줄해 달라"는 Command 가 만들어집니다(Step 02 의 2-5).
setStartToCloseTimeout 은 필수입니다. 빠뜨리면 Worker 등록 시점이 아니라 워크플로우 실행 시점에 예외가 납니다.
Worker 에 액티비티도 등록합니다. 워크플로우는 클래스를 넘기지만 액티비티는 인스턴스를 넘깁니다.
worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class);
worker.registerActivitiesImplementations(new PaymentActivityImpl()); // ← 인스턴스
Worker 를 재시작하고, WorkflowId 를 order-1002 로 바꿔 실행합니다.
Worker 콘솔
09:20:11.104 [workflow-method-order-1002-3a91f2] INFO c.e.order.OrderWorkflowImpl - [1002] 워크플로우 시작 sku=SKU-BLACK-TEE
09:20:11.208 [Activity Executor taskQueue="ORDER_TASK_QUEUE": 1] INFO c.e.o.PaymentActivityImpl - [1002] 결제 요청 amount=39000
09:20:11.331 [workflow-method-order-1002-3a91f2] INFO c.e.order.OrderWorkflowImpl - [1002] 결제 완료 paymentId=pay-1002
스레드 이름이 다릅니다. 워크플로우 코드는 workflow-method-... 스레드에서, 액티비티는 Activity Executor ... 스레드에서 실행됩니다. 완전히 다른 실행 컨텍스트입니다.
before / after 히스토리 비교
BEFORE — 액티비티 없음 (order-1001) — 1-6 에서 본 그대로 5 이벤트입니다.
1 WorkflowExecutionStarted / 2 WorkflowTaskScheduled / 3 WorkflowTaskStarted
4 WorkflowTaskCompleted / 5 WorkflowExecutionCompleted
AFTER — 결제 액티비티 1개 추가 (order-1002)
temporal workflow show -w order-1002
결과
Progress:
ID Time Type
1 2026-03-11T09:20:11Z WorkflowExecutionStarted
2 2026-03-11T09:20:11Z WorkflowTaskScheduled
3 2026-03-11T09:20:11Z WorkflowTaskStarted
4 2026-03-11T09:20:11Z WorkflowTaskCompleted
5 2026-03-11T09:20:11Z ActivityTaskScheduled ← 추가
6 2026-03-11T09:20:11Z ActivityTaskStarted ← 추가
7 2026-03-11T09:20:11Z ActivityTaskCompleted ← 추가
8 2026-03-11T09:20:11Z WorkflowTaskScheduled ← 추가
9 2026-03-11T09:20:11Z WorkflowTaskStarted ← 추가
10 2026-03-11T09:20:11Z WorkflowTaskCompleted ← 추가
11 2026-03-11T09:20:11Z WorkflowExecutionCompleted
Result:
Status: COMPLETED
Output: ["order-1002 COMPLETED"]
5개 → 11개. 액티비티 하나를 붙였는데 6개가 늘었습니다. 내역은 이렇습니다.
| 늘어난 이벤트 | 의미 |
|---|
5. ActivityTaskScheduled | 워크플로우가 "결제를 스케줄해 달라"는 Command 를 냈고 서버가 기록 |
6. ActivityTaskStarted | Worker 의 Activity 폴러가 그것을 가져감 |
7. ActivityTaskCompleted | 액티비티가 pay-1002 를 반환 |
8~10. WorkflowTask* 3종 | 결제 결과를 워크플로우 코드에 전달하려고 워크플로우 코드를 한 번 더 돌림 |
11. WorkflowExecutionCompleted | 그 실행에서 워크플로우가 종료됨 |
여기서 중요한 것은 8~10 입니다. 액티비티가 끝날 때마다 워크플로우 코드가 다시 한 번 실행됩니다. 액티비티 결과를 받아 다음 진도를 나가야 하기 때문입니다. "액티비티 N개 = Workflow Task N+1개" 라는 대략의 감각을 여기서 얻어 두세요.
이벤트 7 의 상세를 봅니다.
temporal workflow show -w order-1002 --output json | jq '.events[6].activityTaskCompletedEventAttributes'
결과
{
"result": [ "pay-1002" ],
"scheduledEventId": "5",
"startedEventId": "6",
"identity": "41233@macbook"
}
액티비티의 반환값이 히스토리에 저장되어 있습니다. 이 사실이 Step 02 의 리플레이를 이해하는 열쇠입니다.
1-8. 함정 (1) — Worker 를 안 띄우면 에러가 아니라 영원한 Running
Worker 프로세스를 ^C 로 종료한 뒤, 그 상태에서 워크플로우를 시작합니다.
./gradlew runStarter # order-1003
결과 — 아무 일도 안 일어납니다. 예외도 없고, 프로세스가 그냥 매달려 있습니다.
09:31:20.118 [main] INFO i.t.s.WorkflowServiceStubsImpl - Channel 127.0.0.1:7233 is READY
(...무한 대기...)
⚠️ 함정 — 연결 실패가 아니라 "정상적으로 아무 일도 안 일어나는" 상태입니다
초보자가 가장 많이 겪는 상황입니다. 코드도 맞고 서버도 살아 있고 로그도 깨끗한데 결과가 안 옵니다.
Temporal 은 "Worker 가 없다"를 에러로 취급하지 않습니다. Worker 는 배포 중일 수도, 스케일아웃 중일 수도 있으니
서버는 Task 를 큐에 얹어 두고 무한정 기다립니다. 이 설계 덕분에 배포 중에 요청이 유실되지 않지만,
반대로 "영원히 안 끝나는 워크플로우"가 조용히 쌓입니다.
Web UI 로 진단합니다. Workflows 목록에서 order-1003 은 RUNNING 이고, 실행 상세로 들어가면 Pending Activities 는 비어 있고 Event History 는 이렇습니다.
| ID | Time | Type | 요약 |
|---|
| 1 | 09:31:20.118 | WorkflowExecutionStarted | taskQueue=ORDER_TASK_QUEUE |
| 2 | 09:31:20.118 | WorkflowTaskScheduled | taskQueue=ORDER_TASK_QUEUE |
2개에서 멈춰 있습니다. WorkflowTaskStarted 가 없다는 것이 결정적 단서입니다. 서버는 Task 를 얹었는데 아무도 가져가지 않았습니다.
CLI 로도 같습니다.
temporal workflow describe -w order-1003
결과
Execution Info:
Workflow Id order-1003
Type OrderWorkflow
Task Queue ORDER_TASK_QUEUE
Status RUNNING
History Length 2
Pending Workflow Task:
State Scheduled
Attempt 1
Pending Workflow Task: State Scheduled — 스케줄만 되고 시작이 안 됐습니다.
진단의 결정타는 Task Queue 를 직접 보는 것입니다.
temporal task-queue describe --task-queue ORDER_TASK_QUEUE
결과
Workflow Poller Info:
(no pollers)
Activity Poller Info:
(no pollers)
폴러가 하나도 없습니다. 1-3 에서 봤던 출력과 비교하면 명확합니다. ./gradlew runWorker 로 Worker 를 다시 띄웁니다.
Worker 콘솔 — 기동하자마자 매달려 있던 워크플로우가 알아서 진행됩니다.
09:33:47.402 [main] INFO i.t.internal.worker.Poller - start: Poller{name=Workflow Poller taskQueue="ORDER_TASK_QUEUE", ...}
09:33:47.611 [workflow-method-order-1003-c02b18] INFO c.e.order.OrderWorkflowImpl - [1003] 워크플로우 시작 sku=SKU-BLACK-TEE
09:33:47.702 [Activity Executor taskQueue="ORDER_TASK_QUEUE": 1] INFO c.e.o.PaymentActivityImpl - [1003] 결제 요청 amount=39000
블로킹돼 있던 Client 도 이제서야 결과: order-1003 COMPLETED 를 받습니다.
2분 27초 동안 매달려 있다가 아무 손실 없이 재개됐습니다. 이것이 Temporal 의 내구성입니다 — 다만 그 내구성이 "영원한 Running" 이라는 함정과 동전의 양면이라는 점을 기억하세요.
💡 진단 3단계 체크리스트
temporal workflow describe -w <id> → History Length 2 이고 Pending Workflow Task 가 Scheduled 인가?
temporal task-queue describe --task-queue <queue> → 폴러가 있는가?
- 폴러가 없다면 → Worker 미기동이거나 큐 이름이 다름(다음 절).
1-9. 함정 (2) — Task Queue 이름 오타
Worker 는 정상 기동돼 있습니다. Client 쪽 큐 이름만 살짝 틀려 봅니다.
WorkflowOptions options = WorkflowOptions.newBuilder()
.setTaskQueue("ORDER_TASK_QEUEU") // ← 오타. QUEUE → QEUEU
.setWorkflowId("order-1004")
.build();
./gradlew runStarter 를 실행하면 — 컴파일도 되고 실행도 되고 예외도 없이 그냥 매달립니다. Worker 콘솔에도 아무것도 찍히지 않습니다. Web UI 를 보면 이렇습니다.
| ID | Time | Type | 요약 |
|---|
| 1 | 09:40:02.118 | WorkflowExecutionStarted | taskQueue=ORDER_TASK_QEUEU |
| 2 | 09:40:02.118 | WorkflowTaskScheduled | taskQueue=ORDER_TASK_QEUEU |
증상이 1-8 과 완전히 똑같습니다. History Length 2, Pending Workflow Task Scheduled. 그래서 "Worker 를 띄웠는데도 안 되네" 하며 헤매게 됩니다.
temporal task-queue describe --task-queue ORDER_TASK_QEUEU # 오타 난 큐
temporal task-queue describe --task-queue ORDER_TASK_QUEUE # 정상 큐
결과
# ORDER_TASK_QEUEU
Workflow Poller Info:
(no pollers)
# ORDER_TASK_QUEUE
Workflow Poller Info:
Identity 41233@macbook
Last Access Time 3 seconds ago
오타 난 큐에는 폴러가 없고, 정상 큐의 폴러는 멀쩡합니다. 큐가 다를 뿐입니다.
⚠️ 함정 — Task Queue 는 서버에 미리 등록하는 자원이 아닙니다
Kafka 토픽처럼 "미리 만들어 둔 것 중에서 고르는" 구조가 아닙니다. Temporal 의 Task Queue 는 문자열을 던지면 그 순간 존재하는 것으로 취급됩니다.
그래서 오타를 내면 "그런 큐 없음" 에러가 아니라 ORDER_TASK_QEUEU 라는 새 큐가 조용히 생기고, 거기엔 폴러가 없어서 영원히 대기합니다.
큐 이름 불일치는 컴파일 타임에도, 런타임에도, 로그에서도 잡히지 않습니다.
해결: Task Queue 이름을 문자열 리터럴로 절대 쓰지 말고 상수 하나로 통일하세요.
public final class Constants {
public static final String ORDER_TASK_QUEUE = "ORDER_TASK_QUEUE";
}
Worker 와 Client 가 같은 상수를 참조하면 오타는 컴파일 에러가 되어 미리 잡힙니다.
Spring 을 쓴다면 @Value("${temporal.task-queue}") 로 설정 파일 한 곳에서 주입하는 것도 같은 효과입니다.
매달린 order-1004 는 temporal workflow terminate -w order-1004 --reason "task queue typo" 로 정리합니다.
정리
| 개념 | 핵심 |
|---|
| Temporal Server | 내 코드를 실행하지 않는다. 히스토리 저장 + Task 중개만 |
| Worker | 내가 띄우는 프로세스. 워크플로우·액티비티가 여기서 실행됨 |
| Client | 워크플로우를 시작·조회. 보통 API 서버 안에 있음 |
@WorkflowInterface | 워크플로우 계약. @WorkflowMethod 는 인터페이스당 정확히 1개 |
| 워크플로우 구현체 | 클래스로 등록(registerWorkflowImplementationTypes). 기본 생성자 필수 |
| 액티비티 구현체 | 인스턴스로 등록(registerActivitiesImplementations). 생성자 주입 자유 |
Workflow.newActivityStub | 프록시. 호출해도 실제 실행이 아니라 Command 생성 |
startToCloseTimeout | 액티비티 옵션 필수. 없으면 실행 시점 예외 |
| WorkflowId | 비즈니스 키(order-1001)로 지정 → 조회 편의 + 중복 실행 방지 |
| 동기 vs 비동기 | stub.method() 는 블로킹, WorkflowClient.start(...) 는 즉시 반환 |
| 히스토리 5 → 11 | 액티비티 1개 = ActivityTask* 3개 + WorkflowTask* 3개 = 6개 증가 |
| 함정 ① Worker 미기동 | 에러가 아니라 영원한 RUNNING. History Length 2 에서 멈춤 |
| 함정 ② 큐 이름 오타 | 컴파일·실행·로그 어디서도 안 잡힘. 상수로 통일이 유일한 방어 |
| 진단 도구 | temporal task-queue describe 의 폴러 유무가 결정적 단서 |
연습문제
Exercise.java 에 6문제가 있습니다. 정답은 Solution.java. 직접 Worker 를 띄우고 히스토리를 확인하세요.
@WorkflowMethod 없는 인터페이스를 등록하면 어떤 예외가 나는지 확인하고 고치기
OrderWorkflow 에 InventoryActivity.reserve 를 추가하고 히스토리 이벤트 수를 예측한 뒤 실측으로 검증하기
- 동기 실행을
WorkflowClient.start 기반 비동기로 바꾸고, 나중에 결과를 가져오기
- WorkflowId 를 지정하지 않았을 때 생성되는 ID 를 확인하고, 비즈니스 키 지정의 이점 서술하기
- Task Queue 이름을 일부러 틀린 뒤
task-queue describe 로 진단하고 상수로 리팩터링하기
- 액티비티 옵션에서
setStartToCloseTimeout 을 제거하면 언제(등록 시점? 실행 시점?) 무슨 예외가 나는지 확인하기
다음 단계
첫 워크플로우를 돌리고 히스토리를 읽었습니다. 그런데 아직 근본적인 질문이 남아 있습니다. Temporal 은 어떻게 워크플로우의 "진행 상태"를 기억하는가? 그 답은 "기억하지 않는다"입니다 — 상태 대신 일어난 일의 목록만 저장하고, 필요할 때마다 코드를 처음부터 다시 돌립니다.
다음 스텝에서는 이벤트 소싱과 리플레이를 다룹니다. Worker 를 실행 도중에 죽였다가 살려서 워크플로우가 이어지는 것을, 그리고 액티비티는 다시 호출되지 않는 것을 직접 재현합니다.
→ Step 02 — 핵심 개념과 실행 모델
실습 파일
이 스텝은 Java 파일 세 개로 진행합니다. 먼저 Practice.java 의 main 을 두 개 터미널에서(Worker → Starter 순으로) 돌려 1-2 ~ 1-9 의 모든 실습을 재현하고, 그다음 Exercise.java 의 6문제를 직접 풀어 본 뒤, Solution.java 로 정답과 해설을 대조합니다. 세 파일 모두 하나의 최상위 public 클래스 안에 nested class 로 워크플로우·액티비티·Worker·Starter 를 전부 담고 있어, 별도 프로젝트 구성 없이 파일 하나만 컴파일하면 돌아갑니다.
Practice.java
강의 본문(1-2 ~ 1-9)의 모든 코드를 절 번호 주석과 함께 한 파일에 모아 둔 실습 스크립트입니다.
- 진입점이 두 개입니다.
Practice$WorkerMain 을 먼저 띄우고, 별도 터미널에서 Practice$StarterMain 을 실행합니다. 파일 상단 주석에 ./gradlew runWorker / ./gradlew runStarter 대응 명령이 적혀 있습니다.
StarterMain 은 인자로 시나리오를 받습니다. sync(1-4 동기), async(1-4 비동기), dup(1-4 중복 실행 → WorkflowExecutionAlreadyStarted), typo(1-9 큐 이름 오타) 네 가지입니다. 인자 없이 실행하면 sync 입니다.
[1-7] 구간의 WITH_ACTIVITY 상수를 false → true 로 바꾸면 결제 액티비티가 붙습니다. BEFORE(5 이벤트) 를 먼저 실행해 보고 나서 true 로 바꿔 AFTER(11 이벤트)를 확인하세요. 이 순서를 지켜야 본문의 before/after 비교가 재현됩니다.
typo 시나리오는 의도적으로 매달립니다. 30초 뒤 스스로 타임아웃하고 진단 명령(temporal task-queue describe ...)을 콘솔에 출력하도록 만들어 뒀습니다. Ctrl+C 로 끊어도 됩니다. 남은 워크플로우는 파일 주석의 temporal workflow terminate -w order-1004 로 정리하세요.
- 모든 WorkflowId 가
order-100X 로 고정이라 두 번 돌리면 WorkflowExecutionAlreadyStarted 가 날 수 있습니다. StarterMain 은 실행 전에 같은 ID 의 기존 실행을 terminate 하는 정리 로직을 갖고 있어 반복 실행이 안전합니다.
package com.example.order;
/*
* ============================================================================
* Step 01 — 환경 구축과 첫 워크플로우 : Practice.java
* ============================================================================
*
* 실행 방법 (터미널 2개 필요)
*
* [터미널 1] Worker 기동
* ./gradlew runWorker
* (= java -cp build/libs/* com.example.order.Practice$WorkerMain)
*
* [터미널 2] Client 실행
* ./gradlew runStarter # 시나리오 sync (1-4 동기 실행)
* ./gradlew runStarter --args="async" # 시나리오 async (1-4 비동기 실행)
* ./gradlew runStarter --args="dup" # 시나리오 dup (1-4 중복 실행 방지)
* ./gradlew runStarter --args="typo" # 시나리오 typo (1-9 큐 이름 오타 함정)
*
* 사전 조건
* docker compose up -d
* temporal operator namespace list # default 가 Registered 인지 확인
*
* 버전
* Temporal Server 1.22.4 / Java SDK 1.22.3 / temporal CLI 0.11.0 / Java 21
*
* 뒷정리
* temporal workflow terminate -w order-1004 --reason "task queue typo"
* ============================================================================
*/
import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;
import io.temporal.activity.ActivityOptions;
import io.temporal.api.common.v1.WorkflowExecution;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import io.temporal.client.WorkflowStub;
import io.temporal.serviceclient.WorkflowServiceStubs;
import io.temporal.worker.Worker;
import io.temporal.worker.WorkerFactory;
import io.temporal.workflow.Workflow;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
public class Practice {
// ------------------------------------------------------------------
// [1-2] Task Queue 이름은 반드시 상수로. 1-9 의 함정에 대한 유일한 방어책이다.
// Worker 와 Client 가 같은 상수를 참조하면 오타는 컴파일 에러가 된다.
// ------------------------------------------------------------------
public static final String ORDER_TASK_QUEUE = "ORDER_TASK_QUEUE";
// [1-9] 함정 재현용. 일부러 틀린 이름. QUEUE -> QEUEU
public static final String TYPO_TASK_QUEUE = "ORDER_TASK_QEUEU";
// ------------------------------------------------------------------
// [1-7] 이 값을 false 로 두고 먼저 실행 → 히스토리 5 이벤트 (BEFORE)
// true 로 바꾸고 Worker 재시작 후 실행 → 히스토리 11 이벤트 (AFTER)
// ★ 반드시 false 를 먼저 돌려 보고 나서 true 로 바꿀 것.
// ------------------------------------------------------------------
public static final boolean WITH_ACTIVITY = false;
// ==================================================================
// [1-2] DTO — Java 21 record
// ==================================================================
public record OrderRequest(
String orderId,
String customerId,
String sku,
int qty,
long amount,
String address
) {}
// ==================================================================
// [1-2] Workflow 인터페이스
// @WorkflowInterface : 이 인터페이스가 워크플로우 계약임을 표시
// @WorkflowMethod : 진입점. 인터페이스당 정확히 하나
// ==================================================================
@WorkflowInterface
public interface OrderWorkflow {
@WorkflowMethod
String processOrder(OrderRequest req);
}
// ==================================================================
// [1-7] Activity 인터페이스
// ==================================================================
@ActivityInterface
public interface PaymentActivity {
@ActivityMethod
String charge(String orderId, long amount);
@ActivityMethod
void refund(String paymentId);
}
// ==================================================================
// [1-7] Activity 구현
// - 액티비티는 리플레이되지 않으므로 일반 LoggerFactory 를 쓴다.
// - Worker 에는 "인스턴스"로 등록되므로 생성자 주입이 자유롭다.
// ==================================================================
public static class PaymentActivityImpl implements PaymentActivity {
private static final Logger log = LoggerFactory.getLogger(PaymentActivityImpl.class);
@Override
public String charge(String orderId, long amount) {
log.info("[{}] 결제 요청 amount={}", orderId, amount);
// 실제로는 여기서 PG 사 HTTP 호출
return "pay-" + orderId;
}
@Override
public void refund(String paymentId) {
log.info("환불 처리 paymentId={}", paymentId);
}
}
// ==================================================================
// [1-2][1-7] Workflow 구현
// - 반드시 기본 생성자가 있어야 한다(Worker 가 리플렉션으로 생성).
// - 로거는 Workflow.getLogger 를 쓴다. 이유는 Step 02 (2-8) 참고.
// LoggerFactory 를 쓰면 리플레이할 때마다 로그가 다시 찍힌다.
// ==================================================================
public static class OrderWorkflowImpl implements OrderWorkflow {
private static final Logger log = Workflow.getLogger(OrderWorkflowImpl.class);
// [1-7] Activity 스텁. 이것은 프록시이며, 호출해도 실제 실행이 아니라
// ScheduleActivityTask Command 가 만들어진다.
// setStartToCloseTimeout 은 필수. 빠뜨리면 "실행 시점"에 예외가 난다.
private final PaymentActivity payment = Workflow.newActivityStub(
PaymentActivity.class,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.build());
@Override
public String processOrder(OrderRequest req) {
log.info("[{}] 워크플로우 시작 sku={} qty={}", req.orderId(), req.sku(), req.qty());
if (WITH_ACTIVITY) {
// [1-7] 액티비티 1개 → 히스토리에 6 이벤트 추가
// ActivityTaskScheduled / Started / Completed (3개)
// + 결과를 워크플로우에 전달하기 위한 WorkflowTask 3종 (3개)
String paymentId = payment.charge(req.orderId(), req.amount());
log.info("[{}] 결제 완료 paymentId={}", req.orderId(), paymentId);
}
return "order-" + req.orderId() + " COMPLETED";
}
}
// ==================================================================
// [1-3] Worker 기동
// ==================================================================
public static class WorkerMain {
public static void main(String[] args) {
// ① 서버(127.0.0.1:7233)로의 gRPC 연결
WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs();
// ② 그 연결 위의 클라이언트
WorkflowClient client = WorkflowClient.newInstance(service);
// ③ Worker 들이 공유하는 스레드풀/캐시를 관리하는 팩토리
WorkerFactory factory = WorkerFactory.newInstance(client);
// ④ 특정 Task Queue 를 long-poll 할 Worker
Worker worker = factory.newWorker(ORDER_TASK_QUEUE);
// ⑤ 워크플로우는 "클래스"를 등록한다 (매 실행마다 새 인스턴스가 생성됨)
worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class);
// ⑤-2 액티비티는 "인스턴스"를 등록한다 (공유됨. 상태를 두면 안 된다)
if (WITH_ACTIVITY) {
worker.registerActivitiesImplementations(new PaymentActivityImpl());
}
// ⑥ 폴러 스레드 기동. 블로킹하지 않으므로 main 이 끝나지 않게 유지해야 한다.
factory.start();
System.out.println("Worker started. Task Queue = " + ORDER_TASK_QUEUE
+ " / WITH_ACTIVITY = " + WITH_ACTIVITY);
System.out.println("확인: temporal task-queue describe --task-queue " + ORDER_TASK_QUEUE);
// 폴러가 데몬 스레드가 아니므로 factory.start() 만으로 프로세스가 유지된다.
// 명시적으로 대기하고 싶다면 아래 주석을 해제한다.
// Runtime.getRuntime().addShutdownHook(new Thread(factory::shutdown));
}
}
// ==================================================================
// [1-4][1-9] Client — 시나리오별 실행
// ==================================================================
public static class StarterMain {
public static void main(String[] args) {
String scenario = (args.length > 0) ? args[0] : "sync";
WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs();
WorkflowClient client = WorkflowClient.newInstance(service);
switch (scenario) {
case "sync" -> runSync(client);
case "async" -> runAsync(client);
case "dup" -> runDuplicate(client);
case "typo" -> runTypo(client);
default -> System.out.println("알 수 없는 시나리오: " + scenario
+ " (sync | async | dup | typo)");
}
System.exit(0);
}
// ----------------------------------------------------------
// [1-4] 동기 실행 — 워크플로우가 끝날 때까지 블로킹
// ----------------------------------------------------------
static void runSync(WorkflowClient client) {
String workflowId = "order-1001";
terminateIfRunning(client, workflowId); // 반복 실행 안전장치
OrderRequest req = sample("1001");
WorkflowOptions options = WorkflowOptions.newBuilder()
.setTaskQueue(ORDER_TASK_QUEUE)
.setWorkflowId(workflowId) // 비즈니스 키를 그대로 WorkflowId 로
.build();
OrderWorkflow workflow = client.newWorkflowStub(OrderWorkflow.class, options);
long t0 = System.currentTimeMillis();
String result = workflow.processOrder(req); // ← 여기서 블로킹
long elapsed = System.currentTimeMillis() - t0;
System.out.println("결과: " + result + " (" + elapsed + "ms)");
System.out.println("확인: temporal workflow show -w " + workflowId);
}
// ----------------------------------------------------------
// [1-4] 비동기 실행 — 시작만 하고 즉시 반환
// 워크플로우가 며칠씩 도는 것이 정상이므로 실무에서는 대개 이쪽을 쓴다.
// ----------------------------------------------------------
static void runAsync(WorkflowClient client) {
String workflowId = "order-1002";
terminateIfRunning(client, workflowId);
OrderRequest req = sample("1002");
WorkflowOptions options = WorkflowOptions.newBuilder()
.setTaskQueue(ORDER_TASK_QUEUE)
.setWorkflowId(workflowId)
.build();
OrderWorkflow workflow = client.newWorkflowStub(OrderWorkflow.class, options);
// ★ 반드시 메서드 참조(workflow::processOrder)여야 한다.
// 람다로 감싸면 SDK 가 워크플로우 타입을 추출하지 못해 실행 시 실패한다.
WorkflowExecution exec = WorkflowClient.start(workflow::processOrder, req);
System.out.println("started workflowId=" + exec.getWorkflowId()
+ " runId=" + exec.getRunId());
// 나중에 결과가 필요해지면 다시 붙어서 기다린다.
OrderWorkflow attached = client.newWorkflowStub(OrderWorkflow.class, workflowId);
String result = WorkflowStub.fromTyped(attached).getResult(String.class);
System.out.println("결과: " + result);
System.out.println("확인: temporal workflow show -w " + workflowId);
}
// ----------------------------------------------------------
// [1-4] 중복 실행 방지 — 같은 WorkflowId 로 두 번 시작
// 두 번째 호출에서 WorkflowExecutionAlreadyStarted 가 난다.
// WorkflowId 를 비즈니스 키로 지정하면 멱등성이 공짜로 따라온다.
// ----------------------------------------------------------
static void runDuplicate(WorkflowClient client) {
String workflowId = "order-1005";
terminateIfRunning(client, workflowId);
OrderRequest req = sample("1005");
WorkflowOptions options = WorkflowOptions.newBuilder()
.setTaskQueue(ORDER_TASK_QUEUE)
.setWorkflowId(workflowId)
.build();
OrderWorkflow w1 = client.newWorkflowStub(OrderWorkflow.class, options);
WorkflowExecution first = WorkflowClient.start(w1::processOrder, req);
System.out.println("1회차 시작 성공 runId=" + first.getRunId());
try {
OrderWorkflow w2 = client.newWorkflowStub(OrderWorkflow.class, options);
WorkflowClient.start(w2::processOrder, req);
System.out.println("2회차도 성공했다?! — 1회차가 이미 종료된 상태일 수 있습니다.");
} catch (Exception e) {
System.out.println("2회차 실패 (기대한 동작): "
+ e.getClass().getSimpleName() + " — " + e.getMessage());
}
}
// ----------------------------------------------------------
// [1-9] 함정 — Task Queue 이름 오타
// 컴파일도 되고 실행도 되고 예외도 없다. 그냥 영원히 매달린다.
// 30초 뒤 스스로 포기하고 진단 명령을 출력하도록 만들어 뒀다.
// ----------------------------------------------------------
static void runTypo(WorkflowClient client) {
String workflowId = "order-1004";
terminateIfRunning(client, workflowId);
OrderRequest req = sample("1004");
WorkflowOptions options = WorkflowOptions.newBuilder()
.setTaskQueue(TYPO_TASK_QUEUE) // ← 오타난 큐
.setWorkflowId(workflowId)
.build();
OrderWorkflow workflow = client.newWorkflowStub(OrderWorkflow.class, options);
WorkflowClient.start(workflow::processOrder, req);
System.out.println("시작했습니다. 30초 기다립니다... (아무 일도 안 일어날 겁니다)");
System.out.println(" Worker 콘솔에는 아무것도 안 찍힙니다.");
System.out.println(" 다른 터미널에서 아래를 실행해 보세요:");
System.out.println(" temporal workflow describe -w " + workflowId);
System.out.println(" → History Length 2 / Pending Workflow Task: Scheduled");
System.out.println(" temporal task-queue describe --task-queue " + TYPO_TASK_QUEUE);
System.out.println(" → (no pollers) ← 결정적 단서");
System.out.println(" temporal task-queue describe --task-queue " + ORDER_TASK_QUEUE);
System.out.println(" → 폴러는 멀쩡히 있음. 큐가 다를 뿐이다.");
try {
Thread.sleep(30_000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
System.out.println();
System.out.println("역시 아무 일도 안 일어났습니다. 뒷정리:");
System.out.println(" temporal workflow terminate -w " + workflowId
+ " --reason \"task queue typo\"");
}
// ----------------------------------------------------------
// 유틸 — 같은 WorkflowId 의 기존 실행이 살아 있으면 종료시킨다.
// 실습을 반복해서 돌릴 수 있게 하려는 것이며, 운영 코드에서 따라 하면 안 된다.
// ----------------------------------------------------------
static void terminateIfRunning(WorkflowClient client, String workflowId) {
try {
WorkflowStub stub = client.newUntypedWorkflowStub(workflowId);
stub.terminate("practice rerun");
System.out.println("(기존 실행 " + workflowId + " 을 정리했습니다)");
} catch (Exception ignored) {
// 실행이 없거나 이미 종료됨 — 정상
}
}
static OrderRequest sample(String orderId) {
return new OrderRequest(
orderId, "cust-77", "SKU-BLACK-TEE", 2, 39000L, "서울시 강남구 테헤란로 1");
}
}
}
Exercise.java
6문제의 문제지입니다. 각 문제는 // TODO: 여기에 작성 자리를 비워 두었고, 여러분이 코드를 채운 뒤 Worker 를 띄워 히스토리로 검증하는 구조입니다.
- 문제 1·6 은 일부러 실패하는 코드로 시작합니다.
@WorkflowMethod 가 빠져 있고 setStartToCloseTimeout 이 없습니다. 먼저 그대로 실행해 예외 메시지를 확인한 다음 고치세요. "무슨 예외가 언제 나는가"가 문제의 절반입니다.
- 문제 2 는 히스토리 이벤트 수를 먼저 종이에 예측해 적고 실행하라고 요구합니다.
EXPECTED_EVENT_COUNT 상수에 예측값을 적어 두면, 실행 후 실제 값과 비교해 출력해 줍니다.
- 문제 3 의
WorkflowClient.start 는 메서드 참조를 받습니다. 람다(() -> workflow.processOrder(req))로 쓰면 컴파일은 되지만 SDK 가 워크플로우 타입을 추출하지 못해 실행 시 실패합니다. 반드시 workflow::processOrder 형태를 쓰세요.
- 문제 5 는 큐 이름을
WRONG_TASK_QUEUE 상수로 미리 틀리게 만들어 뒀습니다. 진단 → 상수 통합 리팩터링까지가 문제 범위이며, 답을 맞히는 것보다 task-queue describe 로 폴러 유무를 확인하는 습관을 들이는 게 목적입니다.
- 파일 끝의
cleanup() 은 이 파일이 만든 order-2001 ~ order-2006 워크플로우를 전부 terminate 합니다. 문제를 반복해서 풀 때 실행하세요.
package com.example.order;
/*
* ============================================================================
* Step 01 — 환경 구축과 첫 워크플로우 : Exercise.java (문제지)
* ============================================================================
*
* 6문제입니다. `// TODO: 여기에 작성` 자리를 채우세요.
* 정답은 Solution.java 에 있습니다. 먼저 직접 풀어 보세요.
*
* 실행 방법
* [터미널 1] java com.example.order.Exercise$WorkerMain
* [터미널 2] java com.example.order.Exercise$StarterMain <문제번호>
* 예) java com.example.order.Exercise$StarterMain 2
*
* 뒷정리
* java com.example.order.Exercise$Cleanup
* (order-2001 ~ order-2006 을 전부 terminate 합니다)
* ============================================================================
*/
import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;
import io.temporal.activity.ActivityOptions;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import io.temporal.client.WorkflowStub;
import io.temporal.serviceclient.WorkflowServiceStubs;
import io.temporal.worker.Worker;
import io.temporal.worker.WorkerFactory;
import io.temporal.workflow.Workflow;
import io.temporal.workflow.WorkflowInterface;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
public class Exercise {
// ------------------------------------------------------------------
// 문제 5 에서 쓰는 상수. 일부러 틀리게 만들어 뒀습니다.
// ------------------------------------------------------------------
public static final String ORDER_TASK_QUEUE = "ORDER_TASK_QUEUE";
public static final String WRONG_TASK_QUEUE = "ORDER_TASKQUEUE"; // ← 언더스코어 하나 빠짐
// ------------------------------------------------------------------
// 문제 2 — 예측값을 여기에 적으세요.
// 액티비티 2개짜리 워크플로우의 최종 히스토리 이벤트 수는 몇 개일까요?
// 실행 후 실제 값과 비교해서 출력해 줍니다.
// ------------------------------------------------------------------
public static final int EXPECTED_EVENT_COUNT = 0; // TODO: 여기에 예측값을 작성
public record OrderRequest(
String orderId, String customerId, String sku, int qty, long amount, String address) {}
// ==================================================================
// 문제 1 — @WorkflowMethod 가 빠져 있습니다.
// (a) 이대로 Worker 를 띄우면 어떤 예외가, 어느 시점에 나는지 확인하세요.
// (b) 고치세요.
// ==================================================================
@WorkflowInterface
public interface Q1Workflow {
// TODO: 여기에 작성 — 이 메서드에 필요한 애너테이션을 붙이세요
String processOrder(OrderRequest req);
}
public static class Q1WorkflowImpl implements Q1Workflow {
private static final Logger log = Workflow.getLogger(Q1WorkflowImpl.class);
@Override
public String processOrder(OrderRequest req) {
log.info("[{}] Q1 실행", req.orderId());
return "order-" + req.orderId() + " COMPLETED";
}
}
// ==================================================================
// 문제 2 — InventoryActivity.reserve 를 추가하고 이벤트 수를 예측/검증
// PaymentActivity.charge 다음에 InventoryActivity.reserve 를 호출하세요.
// 호출 전에 EXPECTED_EVENT_COUNT 에 예측값을 적어 두세요.
// ==================================================================
@ActivityInterface
public interface PaymentActivity {
@ActivityMethod String charge(String orderId, long amount);
}
@ActivityInterface
public interface InventoryActivity {
@ActivityMethod String reserve(String orderId, String sku, int qty);
}
public static class PaymentActivityImpl implements PaymentActivity {
private static final Logger log = LoggerFactory.getLogger(PaymentActivityImpl.class);
@Override public String charge(String orderId, long amount) {
log.info("[{}] 결제 요청 amount={}", orderId, amount);
return "pay-" + orderId;
}
}
public static class InventoryActivityImpl implements InventoryActivity {
private static final Logger log = LoggerFactory.getLogger(InventoryActivityImpl.class);
@Override public String reserve(String orderId, String sku, int qty) {
log.info("[{}] 재고 예약 sku={} qty={}", orderId, sku, qty);
return "resv-" + orderId;
}
}
@WorkflowInterface
public interface Q2Workflow {
@io.temporal.workflow.WorkflowMethod
String processOrder(OrderRequest req);
}
public static class Q2WorkflowImpl implements Q2Workflow {
private static final Logger log = Workflow.getLogger(Q2WorkflowImpl.class);
private final ActivityOptions opts = ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.build();
private final PaymentActivity payment =
Workflow.newActivityStub(PaymentActivity.class, opts);
// TODO: 여기에 작성 — InventoryActivity 스텁을 만드세요
@Override
public String processOrder(OrderRequest req) {
String paymentId = payment.charge(req.orderId(), req.amount());
log.info("[{}] 결제 완료 {}", req.orderId(), paymentId);
// TODO: 여기에 작성 — inventory.reserve(...) 를 호출하고 결과를 로그로 남기세요
return "order-" + req.orderId() + " COMPLETED";
}
}
// ==================================================================
// 문제 6 — setStartToCloseTimeout 이 빠져 있습니다.
// (a) 이대로 실행하면 언제(등록 시점? 실행 시점?) 무슨 예외가 나는지 확인하세요.
// (b) 고치세요.
// ==================================================================
@WorkflowInterface
public interface Q6Workflow {
@io.temporal.workflow.WorkflowMethod
String processOrder(OrderRequest req);
}
public static class Q6WorkflowImpl implements Q6Workflow {
private final PaymentActivity payment = Workflow.newActivityStub(
PaymentActivity.class,
ActivityOptions.newBuilder()
// TODO: 여기에 작성 — 필요한 타임아웃을 지정하세요
.build());
@Override
public String processOrder(OrderRequest req) {
payment.charge(req.orderId(), req.amount());
return "order-" + req.orderId() + " COMPLETED";
}
}
// ==================================================================
// Worker
// ==================================================================
public static class WorkerMain {
public static void main(String[] args) {
WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs();
WorkflowClient client = WorkflowClient.newInstance(service);
WorkerFactory factory = WorkerFactory.newInstance(client);
Worker worker = factory.newWorker(ORDER_TASK_QUEUE);
worker.registerWorkflowImplementationTypes(
Q1WorkflowImpl.class, Q2WorkflowImpl.class, Q6WorkflowImpl.class);
worker.registerActivitiesImplementations(
new PaymentActivityImpl(), new InventoryActivityImpl());
factory.start();
System.out.println("Exercise Worker started. queue=" + ORDER_TASK_QUEUE);
}
}
// ==================================================================
// Client
// ==================================================================
public static class StarterMain {
public static void main(String[] args) {
String no = (args.length > 0) ? args[0] : "1";
WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs();
WorkflowClient client = WorkflowClient.newInstance(service);
switch (no) {
case "1" -> q1(client);
case "2" -> q2(client);
case "3" -> q3(client);
case "4" -> q4(client);
case "5" -> q5(client);
case "6" -> q6(client);
default -> System.out.println("문제 번호는 1~6 입니다.");
}
System.exit(0);
}
// 문제 1 — @WorkflowMethod 누락
static void q1(WorkflowClient client) {
System.out.println("Worker 콘솔의 예외 메시지를 확인하세요.");
System.out.println("힌트: 이 예외는 워크플로우를 '시작하기 전에' 납니다.");
Q1Workflow w = client.newWorkflowStub(Q1Workflow.class, opts("order-2001"));
System.out.println(w.processOrder(sample("2001")));
}
// 문제 2 — 이벤트 수 예측/검증
static void q2(WorkflowClient client) {
Q2Workflow w = client.newWorkflowStub(Q2Workflow.class, opts("order-2002"));
System.out.println(w.processOrder(sample("2002")));
long actual = client.newUntypedWorkflowStub("order-2002")
.getResult(String.class) != null ? countEvents(client, "order-2002") : -1;
System.out.println("예측: " + EXPECTED_EVENT_COUNT + " / 실제: " + actual);
System.out.println(EXPECTED_EVENT_COUNT == actual ? "정답!" : "다시 세어 보세요.");
System.out.println("확인: temporal workflow show -w order-2002");
}
// 문제 3 — 동기 → 비동기로 바꾸기
static void q3(WorkflowClient client) {
Q2Workflow w = client.newWorkflowStub(Q2Workflow.class, opts("order-2003"));
// TODO: 여기에 작성
// 아래 동기 호출을 WorkflowClient.start 기반 비동기로 바꾸고,
// 시작 직후 workflowId/runId 를 출력한 뒤,
// WorkflowStub.fromTyped(...).getResult(String.class) 로 결과를 받으세요.
// ★ 반드시 메서드 참조(w::processOrder)를 쓸 것. 람다는 실행 시 실패합니다.
String result = w.processOrder(sample("2003"));
System.out.println("결과: " + result);
}
// 문제 4 — WorkflowId 를 지정하지 않으면?
static void q4(WorkflowClient client) {
// TODO: 여기에 작성
// setWorkflowId(...) 를 "빼고" WorkflowOptions 를 만들어 실행하고,
// 생성된 WorkflowId 가 어떤 형태인지 출력하세요.
// 그리고 비즈니스 키를 지정했을 때의 이점 세 가지를 주석으로 적으세요.
//
// 이점 1:
// 이점 2:
// 이점 3:
System.out.println("문제 4 를 구현하세요.");
}
// 문제 5 — 큐 이름 오타 진단 후 상수로 리팩터링
static void q5(WorkflowClient client) {
WorkflowOptions wrong = WorkflowOptions.newBuilder()
.setTaskQueue(WRONG_TASK_QUEUE) // ← 틀린 큐
.setWorkflowId("order-2005")
.build();
Q2Workflow w = client.newWorkflowStub(Q2Workflow.class, wrong);
WorkflowClient.start(w::processOrder, sample("2005"));
System.out.println("시작했습니다. 아무 일도 안 일어날 겁니다.");
System.out.println("아래 두 명령의 출력 차이를 확인하세요:");
System.out.println(" temporal task-queue describe --task-queue " + WRONG_TASK_QUEUE);
System.out.println(" temporal task-queue describe --task-queue " + ORDER_TASK_QUEUE);
// TODO: 여기에 작성
// 위 wrong 옵션을 ORDER_TASK_QUEUE 상수를 쓰도록 고치고,
// "왜 이런 실수가 컴파일·런타임·로그 어디서도 안 잡히는지" 주석으로 설명하세요.
}
// 문제 6 — setStartToCloseTimeout 누락
static void q6(WorkflowClient client) {
System.out.println("이 실행은 실패합니다. 예외 메시지와 '언제' 났는지를 확인하세요.");
Q6Workflow w = client.newWorkflowStub(Q6Workflow.class, opts("order-2006"));
try {
System.out.println(w.processOrder(sample("2006")));
} catch (Exception e) {
System.out.println("실패: " + e.getClass().getName());
System.out.println("원인: " + e.getMessage());
}
}
// ---- 유틸 ----
static WorkflowOptions opts(String workflowId) {
return WorkflowOptions.newBuilder()
.setTaskQueue(ORDER_TASK_QUEUE)
.setWorkflowId(workflowId)
.build();
}
static OrderRequest sample(String orderId) {
return new OrderRequest(
orderId, "cust-77", "SKU-BLACK-TEE", 2, 39000L, "서울시 강남구 테헤란로 1");
}
static long countEvents(WorkflowClient client, String workflowId) {
// 히스토리 길이는 CLI 로 확인하는 것이 정확합니다.
// temporal workflow describe -w <id> → History Length
// 여기서는 편의상 -1 을 돌려주고 CLI 확인을 유도합니다.
return -1;
}
}
// ==================================================================
// 뒷정리 — order-2001 ~ order-2006 전부 terminate
// ==================================================================
public static class Cleanup {
public static void main(String[] args) {
WorkflowClient client = WorkflowClient.newInstance(
WorkflowServiceStubs.newLocalServiceStubs());
for (int i = 2001; i <= 2006; i++) {
String id = "order-" + i;
try {
WorkflowStub stub = client.newUntypedWorkflowStub(id);
stub.terminate("exercise cleanup");
System.out.println("terminated " + id);
} catch (Exception e) {
System.out.println("skip " + id + " (실행 없음 또는 이미 종료)");
}
}
System.exit(0);
}
}
}
Solution.java
6문제의 정답 코드와, "왜 그 답인가"를 설명하는 긴 주석이 함께 들어 있습니다. 문제를 풀어 본 뒤에 여세요.
- 정답 1 의 예외는
IllegalArgumentException: Missing @WorkflowMethod 이며, Worker 등록 시점(registerWorkflowImplementationTypes)에 납니다. 워크플로우를 시작하기도 전에 잡힌다는 점이 중요합니다 — 이 계열의 실수는 Temporal 이 일찍 잡아 줍니다.
- 정답 2 의 정답은 17 이벤트입니다. 액티비티 2개 = (
ActivityTask* 3 + WorkflowTask* 3) × 2 = 12, 여기에 기본 5개를 더해 17. 주석에서 이 산식을 이벤트별로 분해해 설명합니다.
- 정답 3 은
WorkflowClient.start 가 왜 메서드 참조여야 하는지 설명합니다. SDK 는 프록시 위의 메서드 호출을 가로채서 워크플로우 타입과 인자를 추출하는데, 람다로 감싸면 그 가로채기가 일어나는 시점이 달라져 IllegalArgumentException: Only workflow methods can be used 가 납니다.
- 정답 4 는 지정하지 않은 WorkflowId 가
8b3f1e70-... 형태의 UUID 임을 보여 주고, 비즈니스 키의 이점 세 가지(조회 편의 / 중복 실행 방지 / 운영 중 사람이 읽을 수 있음)를 정리합니다. WorkflowIdReusePolicy 의 세 값은 Step 12 예고로만 언급합니다.
- 정답 6 이 특히 중요합니다.
setStartToCloseTimeout 누락은 등록 시점이 아니라 워크플로우 실행 중에 IllegalStateException: Both StartToCloseTimeout and ScheduleToCloseTimeout aren't specified 로 터집니다. 즉 테스트를 안 돌려 보면 배포까지 통과합니다. 이 코스가 반복해서 말하는 "조용히 틀리는 코드"의 첫 사례입니다.
package com.example.order;
/*
* ============================================================================
* Step 01 — 환경 구축과 첫 워크플로우 : Solution.java (정답 + 해설)
* ============================================================================
*
* Exercise.java 를 먼저 풀어 본 뒤에 여세요.
*
* 실행 방법
* [터미널 1] java com.example.order.Solution$WorkerMain
* [터미널 2] java com.example.order.Solution$StarterMain <문제번호>
* ============================================================================
*/
import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;
import io.temporal.activity.ActivityOptions;
import io.temporal.api.common.v1.WorkflowExecution;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import io.temporal.client.WorkflowStub;
import io.temporal.serviceclient.WorkflowServiceStubs;
import io.temporal.worker.Worker;
import io.temporal.worker.WorkerFactory;
import io.temporal.workflow.Workflow;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
public class Solution {
/* ==================================================================
* 정답 5 — Task Queue 이름은 상수 하나로 통일한다
* ------------------------------------------------------------------
* 왜 이 실수가 무서운가:
*
* (1) 컴파일 타임에 안 잡힌다 — 그냥 String 이기 때문이다.
* (2) 런타임에도 안 잡힌다 — Temporal 의 Task Queue 는 Kafka 토픽처럼
* "미리 등록해 두고 고르는" 자원이 아니다. 문자열을 던지면 그 순간
* 그 이름의 큐가 존재하는 것으로 취급된다. 그래서 "그런 큐 없음"
* 에러가 아니라, 폴러가 하나도 없는 새 큐가 조용히 생긴다.
* (3) 로그에도 안 남는다 — Worker 는 자기 큐만 폴링하므로 다른 큐에
* Task 가 쌓이는 것을 알 방법이 없다. 콘솔은 완벽히 깨끗하다.
*
* 증상은 "Worker 미기동"과 완전히 동일하다:
* History Length 2 / Pending Workflow Task: Scheduled / 영원한 RUNNING
*
* 유일한 방어책은 Worker 와 Client 가 "같은 상수"를 참조하게 만드는 것이다.
* 그러면 오타는 컴파일 에러가 되어 미리 잡힌다.
* Spring 이라면 @Value("${temporal.task-queue}") 로 설정 한 곳에서 주입해도 된다.
* ================================================================== */
public static final String ORDER_TASK_QUEUE = "ORDER_TASK_QUEUE";
/* ==================================================================
* 정답 2 — 액티비티 2개짜리 워크플로우의 최종 히스토리는 17 이벤트
* ------------------------------------------------------------------
* 산식:
* 기본 골격 5개
* 1 WorkflowExecutionStarted
* 2 WorkflowTaskScheduled
* 3 WorkflowTaskStarted
* 4 WorkflowTaskCompleted
* N WorkflowExecutionCompleted (맨 마지막)
*
* 액티비티 1개당 6개
* ActivityTaskScheduled / ActivityTaskStarted / ActivityTaskCompleted (3)
* + 결과를 워크플로우 코드에 전달하기 위한
* WorkflowTaskScheduled / Started / Completed (3)
*
* 5 + (6 x 2) = 17
*
* 실제 히스토리 (temporal workflow show -w order-2002):
* 1 WorkflowExecutionStarted
* 2 WorkflowTaskScheduled
* 3 WorkflowTaskStarted
* 4 WorkflowTaskCompleted
* 5 ActivityTaskScheduled (charge)
* 6 ActivityTaskStarted
* 7 ActivityTaskCompleted
* 8 WorkflowTaskScheduled
* 9 WorkflowTaskStarted
* 10 WorkflowTaskCompleted
* 11 ActivityTaskScheduled (reserve)
* 12 ActivityTaskStarted
* 13 ActivityTaskCompleted
* 14 WorkflowTaskScheduled
* 15 WorkflowTaskStarted
* 16 WorkflowTaskCompleted
* 17 WorkflowExecutionCompleted
*
* 여기서 얻어야 할 감각:
* "액티비티를 순차로 N개 호출하면 Workflow Task 가 N+1번 돈다."
* Workflow Task 가 돌 때마다 워크플로우 코드는 처음부터 다시 실행된다(리플레이).
* 그래서 액티비티를 잘게 쪼갤수록 히스토리가 급격히 커지고, 리플레이 비용도 커진다.
* 병렬 실행(Async.function)으로 묶으면 Workflow Task 수를 줄일 수 있다 — Step 04.
* ================================================================== */
public static final int EXPECTED_EVENT_COUNT = 17;
public record OrderRequest(
String orderId, String customerId, String sku, int qty, long amount, String address) {}
/* ==================================================================
* 정답 1 — @WorkflowMethod 를 붙인다
* ------------------------------------------------------------------
* 누락 시 예외:
* java.lang.IllegalArgumentException:
* Missing @WorkflowMethod annotation on interface com.example.order.Exercise$Q1Workflow
*
* 중요한 것은 "언제" 나느냐다. 이 예외는
* worker.registerWorkflowImplementationTypes(Q1WorkflowImpl.class)
* 즉 Worker "등록 시점"에 난다. 워크플로우를 시작하기도 전이며,
* Worker 프로세스가 아예 기동에 실패한다.
*
* 이런 계열의 실수는 Temporal 이 일찍, 시끄럽게 잡아 준다. 다행이다.
* 반대로 정답 6 의 실수는 늦게, 조용히 터진다 — 그쪽이 훨씬 위험하다.
* ================================================================== */
@WorkflowInterface
public interface Q1Workflow {
@WorkflowMethod // ← 정답
String processOrder(OrderRequest req);
}
public static class Q1WorkflowImpl implements Q1Workflow {
private static final Logger log = Workflow.getLogger(Q1WorkflowImpl.class);
@Override public String processOrder(OrderRequest req) {
log.info("[{}] Q1 실행", req.orderId());
return "order-" + req.orderId() + " COMPLETED";
}
}
// ------------------------------------------------------------------
// 액티비티 (정답 2 에서 사용)
// ------------------------------------------------------------------
@ActivityInterface
public interface PaymentActivity {
@ActivityMethod String charge(String orderId, long amount);
}
@ActivityInterface
public interface InventoryActivity {
@ActivityMethod String reserve(String orderId, String sku, int qty);
}
public static class PaymentActivityImpl implements PaymentActivity {
private static final Logger log = LoggerFactory.getLogger(PaymentActivityImpl.class);
@Override public String charge(String orderId, long amount) {
log.info("[{}] 결제 요청 amount={}", orderId, amount);
return "pay-" + orderId;
}
}
public static class InventoryActivityImpl implements InventoryActivity {
private static final Logger log = LoggerFactory.getLogger(InventoryActivityImpl.class);
@Override public String reserve(String orderId, String sku, int qty) {
log.info("[{}] 재고 예약 sku={} qty={}", orderId, sku, qty);
return "resv-" + orderId;
}
}
// ------------------------------------------------------------------
// 정답 2 — 액티비티 2개
// ------------------------------------------------------------------
@WorkflowInterface
public interface Q2Workflow {
@WorkflowMethod String processOrder(OrderRequest req);
}
public static class Q2WorkflowImpl implements Q2Workflow {
private static final Logger log = Workflow.getLogger(Q2WorkflowImpl.class);
private final ActivityOptions opts = ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.build();
private final PaymentActivity payment =
Workflow.newActivityStub(PaymentActivity.class, opts);
// 정답: 같은 옵션으로 두 번째 스텁을 만든다.
// 스텁은 인터페이스 단위이므로 액티비티 인터페이스마다 하나씩 필요하다.
private final InventoryActivity inventory =
Workflow.newActivityStub(InventoryActivity.class, opts);
@Override
public String processOrder(OrderRequest req) {
String paymentId = payment.charge(req.orderId(), req.amount());
log.info("[{}] 결제 완료 {}", req.orderId(), paymentId);
String reservationId = inventory.reserve(req.orderId(), req.sku(), req.qty());
log.info("[{}] 재고 예약 완료 {}", req.orderId(), reservationId);
return "order-" + req.orderId() + " COMPLETED";
}
}
/* ==================================================================
* 정답 6 — setStartToCloseTimeout 은 필수다
* ------------------------------------------------------------------
* 누락 시 예외:
* java.lang.IllegalStateException:
* Both StartToCloseTimeout and ScheduleToCloseTimeout aren't specified
*
* 이 스텝에서 가장 중요한 교훈이 여기 있다. 이 예외가 나는 시점은
* - Worker 등록 시점 ← 아니다
* - 컴파일 시점 ← 아니다
* - "워크플로우가 실제로 그 액티비티 호출 줄에 도달했을 때" ← 여기다
*
* 즉, 액티비티 호출을 실제로 태우는 테스트가 없으면
* 컴파일도 통과하고 Worker 도 멀쩡히 뜨고 배포까지 성공한다.
* 운영에서 그 코드 경로에 처음 진입하는 주문이 터진다.
*
* 게다가 워크플로우는 즉시 실패로 끝나지 않는다. Workflow Task 가 실패하면
* Temporal 은 그것을 "일시적 오류"로 보고 무한 재시도한다. 히스토리에는
* WorkflowTaskFailed 가 계속 쌓이고, 워크플로우 상태는 RUNNING 인 채로
* 영원히 진행되지 않는다:
*
* temporal workflow describe -w order-2006
* Status RUNNING
* Pending Workflow Task:
* State Started
* Attempt 47 ← 계속 늘어난다
* Last Failure IllegalStateException: Both StartToCloseTimeout ...
*
* "에러가 나면 실패로 끝난다"는 직관이 여기서 깨진다.
* Temporal 에서 Workflow Task 실패는 "재시도해야 할 일시적 문제"로 취급된다.
* 코드를 고쳐서 Worker 를 재배포하면 그 순간 이어서 진행된다 — 이것이 설계 의도다.
*
* 정리: 액티비티 옵션에는 반드시
* - startToCloseTimeout (액티비티 1회 시도의 최대 실행 시간) 또는
* - scheduleToCloseTimeout (재시도 포함 전체 시한)
* 중 최소 하나를 지정한다. 실무에서는 startToCloseTimeout 을 기본으로 두고
* 필요하면 scheduleToCloseTimeout 을 함께 지정한다 — Step 04 에서 4종 타임아웃 정리.
* ================================================================== */
@WorkflowInterface
public interface Q6Workflow {
@WorkflowMethod String processOrder(OrderRequest req);
}
public static class Q6WorkflowImpl implements Q6Workflow {
private final PaymentActivity payment = Workflow.newActivityStub(
PaymentActivity.class,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10)) // ← 정답
.build());
@Override public String processOrder(OrderRequest req) {
payment.charge(req.orderId(), req.amount());
return "order-" + req.orderId() + " COMPLETED";
}
}
// ==================================================================
// Worker
// ==================================================================
public static class WorkerMain {
public static void main(String[] args) {
WorkflowClient client = WorkflowClient.newInstance(
WorkflowServiceStubs.newLocalServiceStubs());
WorkerFactory factory = WorkerFactory.newInstance(client);
Worker worker = factory.newWorker(ORDER_TASK_QUEUE);
worker.registerWorkflowImplementationTypes(
Q1WorkflowImpl.class, Q2WorkflowImpl.class, Q6WorkflowImpl.class);
worker.registerActivitiesImplementations(
new PaymentActivityImpl(), new InventoryActivityImpl());
factory.start();
System.out.println("Solution Worker started. queue=" + ORDER_TASK_QUEUE);
}
}
// ==================================================================
// Client
// ==================================================================
public static class StarterMain {
public static void main(String[] args) {
String no = (args.length > 0) ? args[0] : "1";
WorkflowClient client = WorkflowClient.newInstance(
WorkflowServiceStubs.newLocalServiceStubs());
switch (no) {
case "1" -> q1(client);
case "2" -> q2(client);
case "3" -> q3(client);
case "4" -> q4(client);
case "5" -> q5(client);
case "6" -> q6(client);
default -> System.out.println("문제 번호는 1~6 입니다.");
}
System.exit(0);
}
static void q1(WorkflowClient client) {
Q1Workflow w = client.newWorkflowStub(Q1Workflow.class, opts("order-3001"));
System.out.println("결과: " + w.processOrder(sample("3001")));
System.out.println("확인: temporal workflow show -w order-3001 → 5 이벤트");
}
static void q2(WorkflowClient client) {
Q2Workflow w = client.newWorkflowStub(Q2Workflow.class, opts("order-3002"));
System.out.println("결과: " + w.processOrder(sample("3002")));
System.out.println("예상 히스토리 길이: " + EXPECTED_EVENT_COUNT);
System.out.println("확인: temporal workflow describe -w order-3002");
System.out.println(" → History Length 17 이어야 정답");
}
/* --------------------------------------------------------------
* 정답 3 — WorkflowClient.start 는 반드시 "메서드 참조"로
* --------------------------------------------------------------
* WorkflowClient.start(w::processOrder, req) ← 정답
* WorkflowClient.start(() -> w.processOrder(req)) ← 실행 시 실패
*
* 이유:
* newWorkflowStub 이 돌려주는 것은 동적 프록시다. SDK 는 그 프록시 위의
* 메서드 호출을 가로채서 (a) 워크플로우 타입 이름과 (b) 인자를 추출한다.
* 메서드 참조를 넘기면 SDK 가 통제된 시점에 한 번 호출해 그 정보를 수집한다.
* 람다로 감싸면 호출 시점과 문맥이 달라져 수집이 실패하고
* IllegalArgumentException: Only workflow methods can be used ...
* 가 난다. 컴파일은 통과하므로 이것도 "조용히 틀리는" 부류다.
*
* 비동기가 기본이어야 하는 이유:
* 워크플로우는 며칠~몇 달을 도는 것이 정상이다. HTTP 요청 스레드가
* 그동안 블로킹되면 안 된다. API 서버는 start 로 시작만 하고 즉시
* 202 Accepted 를 돌려주고, 결과는 Query(Step 07)나 콜백으로 받는다.
* -------------------------------------------------------------- */
static void q3(WorkflowClient client) {
Q2Workflow w = client.newWorkflowStub(Q2Workflow.class, opts("order-3003"));
WorkflowExecution exec = WorkflowClient.start(w::processOrder, sample("3003"));
System.out.println("started workflowId=" + exec.getWorkflowId()
+ " runId=" + exec.getRunId());
// 여기서 다른 일을 해도 된다. 결과가 필요해지면 그때 붙는다.
Q2Workflow attached = client.newWorkflowStub(Q2Workflow.class, exec.getWorkflowId());
String result = WorkflowStub.fromTyped(attached).getResult(String.class);
System.out.println("결과: " + result);
}
/* --------------------------------------------------------------
* 정답 4 — WorkflowId 를 지정하지 않으면 UUID 가 붙는다
* --------------------------------------------------------------
* 출력 예:
* started workflowId=8b3f1e70-2c94-4d51-a7f6-19e0b3c8d245
*
* 비즈니스 키(order-1001)를 쓰는 이점 세 가지:
*
* 이점 1) 조회가 공짜다.
* temporal workflow describe -w order-1001 한 줄로 끝난다.
* UUID 를 쓰면 "주문 1001 ↔ 워크플로우 UUID" 매핑 테이블을 따로
* 만들고 관리해야 한다. 그 테이블이 또 다른 정합성 문제를 만든다.
*
* 이점 2) 중복 실행 방지가 공짜다.
* Temporal 은 같은 WorkflowId 로 "동시에 두 개가 RUNNING" 인 상태를
* 허용하지 않는다. 결제 API 가 네트워크 재시도로 두 번 들어와도
* 두 번째는 WorkflowExecutionAlreadyStarted 로 거절된다.
* 애플리케이션 레벨 멱등키를 따로 만들 필요가 없다.
* 세부 정책은 WorkflowIdReusePolicy 로 조절한다:
* ALLOW_DUPLICATE (기본, 이전 실행 종료 후 재사용 허용)
* ALLOW_DUPLICATE_FAILED_ONLY (이전이 실패했을 때만 재사용)
* REJECT_DUPLICATE (한 번 쓴 ID 는 영영 재사용 불가)
* → Step 12 에서 상세히 다룬다.
*
* 이점 3) 장애 대응 때 사람이 읽을 수 있다.
* 새벽 3시에 "주문 1001 이 안 끝난다"는 문의를 받았을 때,
* UUID 를 역추적할 필요 없이 곧바로 히스토리를 열 수 있다.
* Web UI 목록에서도 order-1001 이 바로 보인다.
*
* 주의: WorkflowId 는 네임스페이스 안에서 유일해야 한다.
* "order-1001" 같은 키는 주문 도메인 전체에서 충돌하지 않아야 하므로,
* 접두사(order-, refund-, settle-)로 도메인을 구분하는 관례가 흔하다.
* -------------------------------------------------------------- */
static void q4(WorkflowClient client) {
WorkflowOptions noId = WorkflowOptions.newBuilder()
.setTaskQueue(ORDER_TASK_QUEUE) // setWorkflowId 를 뺐다
.build();
Q2Workflow w = client.newWorkflowStub(Q2Workflow.class, noId);
WorkflowExecution exec = WorkflowClient.start(w::processOrder, sample("3004"));
System.out.println("자동 생성된 workflowId = " + exec.getWorkflowId());
System.out.println(" → UUID 형태입니다. 주문번호와 아무 관계가 없습니다.");
System.out.println(" → 이 워크플로우를 나중에 찾으려면 이 UUID 를 어딘가 저장해야 합니다.");
Q2Workflow attached = client.newWorkflowStub(Q2Workflow.class, exec.getWorkflowId());
System.out.println("결과: "
+ WorkflowStub.fromTyped(attached).getResult(String.class));
}
// 정답 5 — 상수를 쓰면 끝. 해설은 파일 상단 ORDER_TASK_QUEUE 주석 참고.
static void q5(WorkflowClient client) {
Q2Workflow w = client.newWorkflowStub(Q2Workflow.class, opts("order-3005"));
System.out.println("결과: " + w.processOrder(sample("3005")));
System.out.println("폴러 확인: temporal task-queue describe --task-queue "
+ ORDER_TASK_QUEUE);
}
static void q6(WorkflowClient client) {
Q6Workflow w = client.newWorkflowStub(Q6Workflow.class, opts("order-3006"));
System.out.println("결과: " + w.processOrder(sample("3006")));
System.out.println("타임아웃을 지정했으므로 정상 완료됩니다.");
}
// ---- 유틸 ----
static WorkflowOptions opts(String workflowId) {
return WorkflowOptions.newBuilder()
.setTaskQueue(ORDER_TASK_QUEUE)
.setWorkflowId(workflowId)
.build();
}
static OrderRequest sample(String orderId) {
return new OrderRequest(
orderId, "cust-77", "SKU-BLACK-TEE", 2, 39000L, "서울시 강남구 테헤란로 1");
}
}
// ==================================================================
// 뒷정리
// ==================================================================
public static class Cleanup {
public static void main(String[] args) {
WorkflowClient client = WorkflowClient.newInstance(
WorkflowServiceStubs.newLocalServiceStubs());
for (int i = 3001; i <= 3006; i++) {
String id = "order-" + i;
try {
client.newUntypedWorkflowStub(id).terminate("solution cleanup");
System.out.println("terminated " + id);
} catch (Exception e) {
System.out.println("skip " + id);
}
}
System.exit(0);
}
}
}