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 -1Temporal 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 --versiontemporal 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 도 열어 둡니다. 이후 절에서 계속 씁니다.

http://localhost:8233

1-1. 구성 요소 한눈에 — Server 는 내 코드를 실행하지 않는다

Temporal 을 처음 접하면 대부분 이렇게 오해합니다. "워크플로우 코드를 서버에 올리면 서버가 실행해 주는 거겠지." 틀렸습니다.

Temporal Server 는 여러분의 코드를 한 줄도 실행하지 않습니다. 서버가 하는 일은 세 가지뿐입니다.

  1. 이벤트 히스토리를 저장한다
  2. 할 일(Task)을 Task Queue 에 얹어 둔다
  3. 누군가 가져가면 준다

실제로 코드를 실행하는 것은 여러분이 띄운 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()폴러 스레드 기동

실행합니다.

./gradlew runWorker

결과 (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 탭 내용 (액티비티 없는 최초 버전)

IDTimeType요약
109:12:04.512WorkflowExecutionStartedworkflowType=OrderWorkflow, taskQueue=ORDER_TASK_QUEUE, input=OrderRequest{...}
209:12:04.512WorkflowTaskScheduledtaskQueue=ORDER_TASK_QUEUE, startToCloseTimeout=10s
309:12:04.631WorkflowTaskStartedidentity=41233@macbook, requestId=...
409:12:04.688WorkflowTaskCompletedscheduledEventId=2, startedEventId=3
509:12:04.688WorkflowExecutionCompletedresult="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 쪽을 훨씬 많이 씁니다.

temporal workflow list

결과

  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. ActivityTaskStartedWorker 의 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-1003RUNNING 이고, 실행 상세로 들어가면 Pending Activities 는 비어 있고 Event History 는 이렇습니다.

IDTimeType요약
109:31:20.118WorkflowExecutionStartedtaskQueue=ORDER_TASK_QUEUE
209:31:20.118WorkflowTaskScheduledtaskQueue=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단계 체크리스트

  1. temporal workflow describe -w <id>History Length 2 이고 Pending Workflow TaskScheduled 인가?
  2. temporal task-queue describe --task-queue <queue> → 폴러가 있는가?
  3. 폴러가 없다면 → 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 를 보면 이렇습니다.

IDTimeType요약
109:40:02.118WorkflowExecutionStartedtaskQueue=ORDER_TASK_QEUEU
209:40:02.118WorkflowTaskScheduledtaskQueue=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-1004temporal 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 를 띄우고 히스토리를 확인하세요.

  1. @WorkflowMethod 없는 인터페이스를 등록하면 어떤 예외가 나는지 확인하고 고치기
  2. OrderWorkflowInventoryActivity.reserve 를 추가하고 히스토리 이벤트 수를 예측한 뒤 실측으로 검증하기
  3. 동기 실행을 WorkflowClient.start 기반 비동기로 바꾸고, 나중에 결과를 가져오기
  4. WorkflowId 를 지정하지 않았을 때 생성되는 ID 를 확인하고, 비즈니스 키 지정의 이점 서술하기
  5. Task Queue 이름을 일부러 틀린 뒤 task-queue describe 로 진단하고 상수로 리팩터링하기
  6. 액티비티 옵션에서 setStartToCloseTimeout 을 제거하면 언제(등록 시점? 실행 시점?) 무슨 예외가 나는지 확인하기

다음 단계

첫 워크플로우를 돌리고 히스토리를 읽었습니다. 그런데 아직 근본적인 질문이 남아 있습니다. Temporal 은 어떻게 워크플로우의 "진행 상태"를 기억하는가? 그 답은 "기억하지 않는다"입니다 — 상태 대신 일어난 일의 목록만 저장하고, 필요할 때마다 코드를 처음부터 다시 돌립니다.

다음 스텝에서는 이벤트 소싱과 리플레이를 다룹니다. Worker 를 실행 도중에 죽였다가 살려서 워크플로우가 이어지는 것을, 그리고 액티비티는 다시 호출되지 않는 것을 직접 재현합니다.

Step 02 — 핵심 개념과 실행 모델


실습 파일

이 스텝은 Java 파일 세 개로 진행합니다. 먼저 Practice.javamain 을 두 개 터미널에서(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 상수를 falsetrue 로 바꾸면 결제 액티비티가 붙습니다. 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 상수에 예측값을 적어 두면, 실행 후 실제 값과 비교해 출력해 줍니다.
  • 문제 3WorkflowClient.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. 주석에서 이 산식을 이벤트별로 분해해 설명합니다.
  • 정답 3WorkflowClient.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);
        }
    }
}