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

학습 목표

  • Workflow / Activity / Worker / Task Queue 의 관계를 그림으로 이해하고, Task Queue 가 큐가 아니라 long-poll 매칭 지점임을 확인한다
  • Temporal 이 "현재 상태"가 아니라 일어난 일의 목록만 저장한다는 사실을 히스토리로 증명한다
  • 워크플로우 실행 중 Worker 를 kill 했다가 재기동해 리플레이가 실제로 일어나는 것을 재현한다
  • 리플레이 때 액티비티가 다시 호출되지 않는 것을 액티비티 로그로 증명한다
  • Workflow Task 와 Activity Task 의 차이를 표로 구분하고, 워크플로우 코드가 만들어내는 것이 Command 임을 히스토리에서 짚는다
  • 이벤트 13종의 의미를 정리하고, Sticky Execution 캐시가 리플레이 비용을 어떻게 줄이는지 측정한다
  • log.info 가 리플레이마다 중복 출력되는 것을 재현하고 Workflow.getLogger 로 해결한다

선행 스텝: Step 01 — 환경 구축과 첫 워크플로우 예상 소요: 90분


2-0. 실습 준비

Step 01 의 환경을 그대로 씁니다. 서버가 떠 있는지만 확인합니다.

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

이 스텝은 액티비티 세 개(chargereserverequestShipment)를 순차 호출하는 워크플로우로 실습합니다. 각 액티비티 사이에 5초 Workflow.sleep 을 넣어, 워크플로우가 도는 도중에 Worker 를 죽일 시간을 확보합니다.


2-1. Workflow / Activity / Worker / Task Queue 의 관계

Step 01 에서 "Server 는 내 코드를 실행하지 않는다"고 했습니다. 그렇다면 실행은 어떻게 흘러갈까요. 전체 그림입니다.

                        ┌─────────────────────────────────────────────┐
                        │            Temporal Server                   │
                        │   ┌──────────────────────────────────────┐   │
   ① StartWorkflow      │   │  History Service                     │   │
  ─────────────────────►│   │   order-1001 의 이벤트 목록           │   │
                        │   │   [1] WorkflowExecutionStarted        │   │
   Client               │   │   [2] WorkflowTaskScheduled  ...      │   │
                        │   └──────────────┬───────────────────────┘   │
                        │   ┌──────────────▼───────────────────────┐   │
                        │   │  Matching Service                    │   │
                        │   │   Task Queue: "ORDER_TASK_QUEUE"     │   │
                        │   │   ┌────────────────┐ ┌─────────────┐ │   │
                        │   │   │ Workflow Task  │ │Activity Task│ │   │
                        │   │   └───────┬────────┘ └──────┬──────┘ │   │
                        │   └───────────┼─────────────────┼────────┘   │
                        └───────────────┼─────────────────┼────────────┘
                       ② long-poll ─────┤                 ├───── ② long-poll
                          (60초 대기)    ▼                 ▼
                  ┌──────────────────────────────────────────────────────┐
                  │                   Worker 프로세스                      │
                  │  Workflow Poller ──► workflow-method 스레드           │
                  │                        └─ OrderWorkflowImpl 실행      │
                  │                             (히스토리 리플레이 + 전진)  │
                  │  Activity Poller ──► Activity Executor 스레드         │
                  │                        └─ PaymentActivityImpl 실행    │
                  │                             (부수효과 발생 지점)        │
                  │  Sticky Cache: { order-1001 → 워크플로우 인스턴스 }     │
                  └──────────────────────────────────────────────────────┘
                       ③ RespondWorkflowTaskCompleted(Commands)
                       ③ RespondActivityTaskCompleted(result) ──► 다시 Server 로

Task Queue 는 큐가 아닙니다

이름 때문에 오해를 삽니다. Kafka 토픽이나 RabbitMQ 큐처럼 "메시지가 쌓였다가 컨슈머가 가져가는 저장소"를 떠올리게 되는데, Temporal 의 Task Queue 는 저장소가 아니라 매칭 지점입니다.

동작은 이렇습니다.

  1. Worker 가 PollWorkflowTaskQueue gRPC 를 호출합니다. 이 호출은 최대 60초까지 응답하지 않고 매달려 있습니다(long-poll).
  2. 서버에 Task 가 생기면 Matching Service 가 대기 중인 폴러 중 하나에게 즉시 전달합니다.
  3. 60초 안에 Task 가 안 생기면 빈 응답을 돌려주고, Worker 는 곧바로 다시 폴링합니다.

즉 Task 는 큐에 쌓였다가 소비되는 게 아니라, 폴러와 곧바로 매칭됩니다. 이 차이가 실무에서 세 가지 결과를 낳습니다.

결과설명
지연이 거의 없다폴러가 이미 대기 중이므로 Task 생성 즉시 전달. Step 01 에서 이벤트 2→3 사이가 119ms 였던 이유
큐 길이를 볼 수 없다"몇 건이 밀렸나"를 직접 조회할 수 없습니다. 백로그는 temporal task-queue describeBacklog 나 메트릭으로 간접 확인합니다
폴러가 없으면 무한 대기Step 01 의 함정 그대로. 쌓아 두고 기다립니다

폴러 상태를 다시 봅니다.

temporal task-queue describe --task-queue ORDER_TASK_QUEUE

결과

Workflow Poller Info:
  Identity          41233@macbook
  Last Access Time  2 seconds ago
  Rate Per Second   100000

Activity Poller Info:
  Identity          41233@macbook
  Last Access Time  2 seconds ago
  Rate Per Second   100000

Last Access Time 2 seconds ago — 폴러가 계속 갱신되고 있습니다. Worker 가 60초 long-poll 을 돌고 있다는 뜻입니다.

💡 Workflow Task 와 Activity Task 는 같은 큐 이름을 쓰지만 별개의 매칭 라인입니다 위 출력에서 Workflow Poller InfoActivity Poller Info 가 따로 나오는 것이 그 증거입니다. 그래서 "액티비티가 워커를 다 잡아먹어서 워크플로우가 안 돈다"는 문제가 생기면 액티비티만 별도 Task Queue 로 분리하는 처방이 나옵니다(Step 12).


2-2. 이벤트 소싱 — Temporal 은 "현재 상태"를 저장하지 않습니다

일반적인 배치나 상태 머신은 이렇게 만듭니다.

UPDATE orders SET status = 'PAID' WHERE order_id = 1001;

현재 상태를 저장합니다. 이전이 무엇이었는지는 사라집니다.

Temporal 은 정반대입니다. 일어난 일만 순서대로 append 합니다. 상태 컬럼이 아예 없습니다.

실습용 워크플로우를 실행합니다.

@Override
public String processOrder(OrderRequest req) {
    log.info("[{}] 워크플로우 시작", req.orderId());

    String paymentId = payment.charge(req.orderId(), req.amount());
    Workflow.sleep(Duration.ofSeconds(5));

    String reservationId = inventory.reserve(req.orderId(), req.sku(), req.qty());
    Workflow.sleep(Duration.ofSeconds(5));

    String shipmentId = shipping.requestShipment(req.orderId(), req.address());

    return "order-" + req.orderId() + " COMPLETED";
}
./gradlew runStarter --args="order-2001"
temporal workflow show -w order-2001

결과

Progress:
  ID          Time                     Type
   1  2026-03-11T10:04:12Z  WorkflowExecutionStarted
   2  2026-03-11T10:04:12Z  WorkflowTaskScheduled
   3  2026-03-11T10:04:12Z  WorkflowTaskStarted
   4  2026-03-11T10:04:12Z  WorkflowTaskCompleted
   5  2026-03-11T10:04:12Z  ActivityTaskScheduled        (charge)
   6  2026-03-11T10:04:12Z  ActivityTaskStarted
   7  2026-03-11T10:04:12Z  ActivityTaskCompleted
   8- 10                    WorkflowTaskScheduled/Started/Completed
  11  2026-03-11T10:04:12Z  TimerStarted                 (5s)
  12  2026-03-11T10:04:17Z  TimerFired
  13- 15                    WorkflowTaskScheduled/Started/Completed
  16  2026-03-11T10:04:17Z  ActivityTaskScheduled        (reserve)
  17  2026-03-11T10:04:17Z  ActivityTaskStarted
  18  2026-03-11T10:04:17Z  ActivityTaskCompleted
  19- 21                    WorkflowTaskScheduled/Started/Completed
  22  2026-03-11T10:04:17Z  TimerStarted                 (5s)
  23  2026-03-11T10:04:22Z  TimerFired
  24- 26                    WorkflowTaskScheduled/Started/Completed
  27  2026-03-11T10:04:22Z  ActivityTaskScheduled        (requestShipment)
  28  2026-03-11T10:04:22Z  ActivityTaskStarted
  29  2026-03-11T10:04:22Z  ActivityTaskCompleted
  30- 32                    WorkflowTaskScheduled/Started/Completed
  33  2026-03-11T10:04:22Z  WorkflowExecutionCompleted

Result:
  Status: COMPLETED
  Output: ["order-2001 COMPLETED"]

33개 이벤트. 이 목록 어디에도 status = PAID 같은 것은 없습니다. 그런데도 이 목록만 있으면 워크플로우의 현재 상태를 완전히 복원할 수 있습니다.

  • 이벤트 7 이 있으니 → 결제는 끝났고 결과는 pay-2001
  • 이벤트 12 가 있으니 → 첫 5초 대기는 끝났고
  • 이벤트 18 이 있으니 → 재고 예약도 끝났고 결과는 resv-2001
  • 이벤트 29 가 있으니 → 배송 요청도 끝났고
  • 이벤트 33 이 있으니 → 워크플로우 종료

paymentId 라는 지역 변수의 값조차 히스토리에서 복원됩니다. 이벤트 7 의 result 가 그 값이기 때문입니다.

temporal workflow show -w order-2001 --output json | jq '.events[6].activityTaskCompletedEventAttributes.result'

결과

[ "pay-2001" ]

💡 비유 — 은행 통장 통장에는 "잔액 380,000원"만 적혀 있지 않습니다. 입금·출금 내역이 순서대로 적혀 있고, 잔액은 그것을 다 더한 결과입니다. 내역이 있으면 잔액은 언제든 다시 계산할 수 있지만, 잔액만 있으면 "왜 이 금액인지"를 영영 알 수 없습니다. Temporal 의 히스토리는 통장 내역이고, 워크플로우의 지역 변수들은 그 내역을 다 더한 잔액입니다.

이 설계가 주는 것이 바로 다음 절의 리플레이입니다.


2-3. 리플레이 — Worker 를 죽였다 살려 봅니다 ★

여기가 Temporal 의 심장입니다. 말로 설명하는 대신 직접 재현합니다.

실험 절차

  1. Worker 를 띄운다
  2. 워크플로우를 시작한다 (액티비티 3개 + 5초 sleep 2개 = 약 10초 소요)
  3. 첫 액티비티가 끝나고 5초 sleep 중일 때 Worker 를 kill -9
  4. Worker 를 다시 띄운다
  5. 워크플로우가 이어서 진행되는지, 그리고 결제 액티비티가 다시 호출되는지 확인한다

실행

터미널 1 ./gradlew runWorker 로 Worker 를 띄우고, 터미널 2 에서 ./gradlew runStarter --args="order-2002" 로 워크플로우를 시작합니다.

터미널 1 Worker 콘솔

10:11:15.108 [workflow-method-order-2002-7b31a4] INFO  c.e.order.OrderWorkflowImpl - [2002] 워크플로우 시작
10:11:15.219 [Activity Executor taskQueue="ORDER_TASK_QUEUE": 1] INFO  c.e.o.PaymentActivityImpl - [2002] 결제 요청 amount=39000  ★
10:11:15.334 [workflow-method-order-2002-7b31a4] INFO  c.e.order.OrderWorkflowImpl - [2002] 결제 완료 paymentId=pay-2002

★ 표시한 줄이 실제 결제가 일어난 시점입니다. 이제 5초 sleep 중입니다. 지금 Worker 를 죽입니다.

kill -9 $(pgrep -f OrderWorker)

프로세스가 즉시 사라집니다. [1] Killed 외에 아무 로그도 없고 graceful shutdown 조차 없습니다. 그 상태에서 히스토리를 봅니다.

temporal workflow show -w order-2002

결과

Progress:
  ID          Time                     Type
   1  2026-03-11T10:11:15Z  WorkflowExecutionStarted
   2- 4                     WorkflowTaskScheduled/Started/Completed
   5  2026-03-11T10:11:15Z  ActivityTaskScheduled        (charge)
   6  2026-03-11T10:11:15Z  ActivityTaskStarted
   7  2026-03-11T10:11:15Z  ActivityTaskCompleted        result=["pay-2002"]
   8-10                     WorkflowTaskScheduled/Started/Completed
  11  2026-03-11T10:11:15Z  TimerStarted                 (5s)
  12  2026-03-11T10:11:20Z  TimerFired
  13  2026-03-11T10:11:20Z  WorkflowTaskScheduled        ← 여기서 멈춤

Status: RUNNING

13개에서 멈춰 있습니다. 타이머는 만료됐고(12), 그래서 서버가 "코드를 이어서 돌려 주세요" 하고 Workflow Task 를 얹었는데(13), 가져갈 Worker 가 없습니다. Step 01 의 함정과 정확히 같은 상태입니다 — 다만 이번엔 일시적입니다.

./gradlew runWorkerWorker 를 다시 띄웁니다.

터미널 1 Worker 콘솔 — 재기동 직후

10:12:41.402 [main] INFO  i.t.internal.worker.Poller - start: Poller{name=Workflow Poller taskQueue="ORDER_TASK_QUEUE", identity=42188@macbook}
10:12:41.688 [workflow-method-order-2002-c19d02] INFO  c.e.order.OrderWorkflowImpl - [2002] 재고 예약 완료 resv-2002
10:12:46.912 [workflow-method-order-2002-c19d02] INFO  c.e.order.OrderWorkflowImpl - [2002] 배송 요청 완료 ship-2002

터미널 2 Client 는 87초 매달려 있다가 결과: order-2002 COMPLETED 를 받습니다.

여기서 무슨 일이 벌어졌나

Worker 가 재기동되며 order-2002 의 Workflow Task 를 가져왔습니다. 그런데 이 Worker 는 방금 태어났으므로 paymentId 변수에 무엇이 들어 있었는지 전혀 모릅니다. 메모리에 아무것도 없습니다.

그래서 SDK 는 이렇게 합니다.

① 서버에서 order-2002 의 히스토리 전체(13개 이벤트)를 받아온다
② OrderWorkflowImpl.processOrder() 를 "처음부터" 다시 실행한다
③ 코드가 payment.charge(...) 에 도달하면
     → 실제로 액티비티를 호출하지 않는다
     → 히스토리의 이벤트 7 (ActivityTaskCompleted, result="pay-2002") 을 찾아 그 값을 그대로 반환한다
④ 코드가 Workflow.sleep(5초) 에 도달하면
     → 실제로 5초를 자지 않는다
     → 히스토리의 이벤트 12 (TimerFired) 를 보고 "이미 만료됨"으로 처리하고 즉시 통과한다
⑤ 코드가 inventory.reserve(...) 에 도달한다
     → 히스토리에 대응하는 이벤트가 없다 → "여기가 새 지점이다"
     → 여기서부터 진짜로 실행한다

②~④를 리플레이, ⑤부터를 신규 실행이라고 합니다.

액티비티 로그가 다시 찍히지 않는다는 증거

재기동 후 Worker 콘솔을 다시 보세요.

10:12:41.688 [workflow-method-order-2002-c19d02] INFO  c.e.order.OrderWorkflowImpl - [2002] 재고 예약 완료 resv-2002
10:12:46.912 [workflow-method-order-2002-c19d02] INFO  c.e.order.OrderWorkflowImpl - [2002] 배송 요청 완료 ship-2002

PaymentActivityImpl 의 로그가 한 줄도 없습니다. 첫 기동 때 있었던

[Activity Executor ...] INFO  c.e.o.PaymentActivityImpl - [2002] 결제 요청 amount=39000

이 줄이 재기동 후에는 찍히지 않았습니다. 워크플로우 코드는 payment.charge(...) 줄을 분명히 다시 지나갔는데도 말입니다.

결제는 정확히 한 번만 일어났습니다. 히스토리로도 확인됩니다.

temporal workflow show -w order-2002 | grep -c "ActivityTaskStarted"   # → 3
temporal workflow describe -w order-2002

액티비티 3개(charge / reserve / requestShipment) 각각 1번씩입니다. charge 가 두 번 실행됐다면 4가 나왔을 것입니다.

결과

Execution Info:
  Workflow Id       order-2002
  Run Id            b47e2f91-8d05-4c63-a1f7-3e9b0d5c8271
  Type              OrderWorkflow
  Task Queue        ORDER_TASK_QUEUE
  Status            COMPLETED
  History Length    33
  History Size      5127

History Length 33 — Worker 를 죽이지 않고 돌린 order-2001정확히 같은 33개입니다. Worker 가 한 번 죽었다는 사실은 히스토리에 흔적조차 남기지 않았습니다. 걸린 시간만 10초 → 91초로 늘었을 뿐입니다.

💡 리플레이가 요구하는 것 — 결정성(determinism) 리플레이는 "같은 코드에 같은 히스토리를 먹이면 같은 순서의 Command 가 나온다"는 전제 위에 서 있습니다. 그래서 워크플로우 코드에 new Random(), System.currentTimeMillis(), UUID.randomUUID(), Thread.sleep(), HTTP 호출 같은 것을 쓰면 리플레이가 깨집니다. 이 제약과 우회 API(Workflow.currentTimeMillis(), Workflow.randomUUID(), Workflow.sideEffect())는 Step 03 의 주제 전체입니다.


2-4. Workflow Task vs Activity Task

같은 "Task" 라는 이름이 붙어 있지만 완전히 다른 것입니다.

항목Workflow TaskActivity Task
무엇을 실행하나워크플로우 코드 (OrderWorkflowImpl)액티비티 코드 (PaymentActivityImpl)
실행 스레드workflow-method-...Activity Executor ...
하는 일히스토리를 리플레이해 다음 대기 지점까지 코드를 밀어붙이고, 그 사이 발생한 Command 목록을 반환실제 부수효과(HTTP 호출, DB 쓰기)를 일으키고 결과를 반환
리플레이되나된다 (여러 번 실행됨)안 된다 (정확히 한 번만)
기본 타임아웃workflowTaskTimeout 10초기본값 없음 — startToCloseTimeout 직접 지정 필수
재시도 방식실패 시 서버가 무한 재시도. 백오프하며 영원히 시도RetryOptions 정책대로 (기본: 지수 백오프, 무한)
실패의 의미"코드에 버그가 있거나 배포 중" → 고치면 이어서 진행"외부 시스템이 불안정" → 재시도로 극복
코드 제약있다. 결정적이어야 함. 랜덤·현재시각·IO 금지없다. 평범한 Java 코드. 뭘 해도 됨
결과 크기 제한Command 목록. 페이로드 2MB반환값 2MB

"다음 대기 지점까지 밀어붙인다"의 의미

Workflow Task 하나가 처리하는 범위를 그림으로 보면 이렇습니다.

processOrder() {
    log.info("시작");                      ─┐
    payment.charge(...);                    ├─ Workflow Task #1 이 여기까지 실행
}                                          ─┘   → Command: [ScheduleActivityTask(charge)]
                                                → 여기서 코드가 "멈춘다"(코루틴 블록)

  ... 액티비티 실행 (Activity Task) ...

processOrder() {                           ─┐
    log.info("시작");          ← 리플레이     │
    payment.charge(...);       ← 히스토리값   ├─ Workflow Task #2 : 처음부터 다시 실행
    Workflow.sleep(5초);                     │   → Command: [StartTimer(5s)]
}                                          ─┘   → 다시 멈춘다

  ... 5초 경과 (TimerFired) ...

processOrder() {                           ─┐
    log.info("시작");          ← 리플레이     │
    payment.charge(...);       ← 히스토리값   ├─ Workflow Task #3
    Workflow.sleep(5초);       ← 히스토리값   │   → Command: [ScheduleActivityTask(reserve)]
    inventory.reserve(...);                 │
}                                          ─┘

Workflow Task 가 돌 때마다 워크플로우 코드는 처음부터 다시 실행됩니다. order-2001 의 경우 Workflow Task 가 8번 돌았으므로 processOrder()8번 호출됐습니다. 그런데 액티비티는 각각 1번씩만 실행됐습니다. 이 비대칭이 Temporal 의 전부라고 해도 과언이 아닙니다.

⚠️ 위 그림에서 "멈춘다"는 것은 스레드가 블로킹된다는 뜻이 아닙니다. Temporal Java SDK 는 워크플로우 코드를 코루틴(deterministic scheduler 위의 협조적 스레드)으로 돌립니다. payment.charge(...) 는 코루틴을 yield 시키고, Workflow Task 는 그 지점에서 "더 진행할 게 없음"을 감지해 Command 목록을 반환하고 종료합니다.


2-5. Command — 워크플로우 코드가 만들어내는 것

Step 01 에서 "payment.charge(...) 를 호출해도 실제 실행이 아니라 Command 가 만들어진다"고 했습니다. 이제 그것을 히스토리에서 직접 봅니다.

워크플로우 코드는 부수효과를 일으키지 않습니다. 대신 "이걸 해 주세요"라는 요청 목록(Command) 을 만들어 서버에 반환합니다.

코드만들어지는 Command서버가 기록하는 Event
payment.charge(...)ScheduleActivityTaskActivityTaskScheduled
Workflow.sleep(5s)StartTimerTimerStarted
return "..."CompleteWorkflowExecutionWorkflowExecutionCompleted
throw ApplicationFailureFailWorkflowExecutionWorkflowExecutionFailed
Workflow.newChildWorkflowStub(...) 호출StartChildWorkflowExecutionStartChildWorkflowExecutionInitiated
Workflow.continueAsNew(...)ContinueAsNewWorkflowExecutionWorkflowExecutionContinuedAsNew
Async.function(...) 취소RequestCancelActivityTaskActivityTaskCancelRequested

Command 는 Worker → Server 방향, Event 는 Server 가 기록하는 결과입니다. Command 는 히스토리에 직접 나타나지 않고, 서버가 그것을 Event 로 변환해 append 합니다.

히스토리에서 확인하기

규칙은 단순합니다. WorkflowTaskCompleted 바로 다음에 오는 이벤트들이, 그 Workflow Task 가 반환한 Command 의 결과입니다.

order-2001 의 히스토리를 다시 봅니다.

   4  WorkflowTaskCompleted  → Command = ScheduleActivityTask(charge)
   5  ActivityTaskScheduled  ← 그 결과
  10  WorkflowTaskCompleted  → Command = StartTimer(5s)
  11  TimerStarted           ← 그 결과
  15  WorkflowTaskCompleted  → Command = ScheduleActivityTask(reserve)
  16  ActivityTaskScheduled
  21  WorkflowTaskCompleted  → Command = StartTimer(5s)
  22  TimerStarted
  26  WorkflowTaskCompleted  → Command = ScheduleActivityTask(requestShipment)
  27  ActivityTaskScheduled
  32  WorkflowTaskCompleted  → Command = CompleteWorkflowExecution
  33  WorkflowExecutionCompleted

WorkflowTaskCompleted 이벤트의 상세는 이렇습니다.

temporal workflow show -w order-2001 --output json | jq '.events[3].workflowTaskCompletedEventAttributes'

결과

{
  "scheduledEventId": "2",
  "startedEventId": "3",
  "identity": "42011@macbook",
  "binaryChecksum": "@temporal_sdk_java_1.22.3"
}

binaryChecksum 이 보입니다. 어느 버전의 Worker 가 이 Workflow Task 를 처리했는지가 히스토리에 남습니다. Step 10 의 버저닝에서 이것이 결정적으로 쓰입니다.

Command 를 여러 개 한꺼번에 반환할 수도 있습니다. 액티비티 3개를 Async.function 으로 병렬 호출하면:

   4  WorkflowTaskCompleted        ← Workflow Task 하나가
   5  ActivityTaskScheduled        ← Command 3개를
   6  ActivityTaskScheduled        ← 한 번에
   7  ActivityTaskScheduled        ← 반환했다

이러면 Workflow Task 횟수가 줄어 히스토리도 작아지고 리플레이 비용도 줄어듭니다. 순차 호출 대비 Workflow Task 가 4회 → 2회로 줄어듭니다(Step 04).


2-6. 히스토리 읽는 법 — 이벤트 13종

실무에서 마주치는 이벤트는 사실상 아래 13종입니다. 이것만 읽을 줄 알면 대부분의 장애를 진단할 수 있습니다.

이벤트의미이걸 보면 알 수 있는 것
WorkflowExecutionStarted워크플로우가 시작됨. 항상 이벤트 1입력값 전체, task queue, 각종 타임아웃, 부모 워크플로우
WorkflowTaskScheduled서버가 "코드를 돌려 달라"고 Task 를 얹음여기서 멈춰 있으면 → Worker 가 없다
WorkflowTaskStartedWorker 가 그 Task 를 가져감identity 로 어느 Worker 인스턴스인지
WorkflowTaskCompletedWorker 가 Command 목록을 반환함binaryChecksum 으로 Worker 버전
WorkflowTaskFailed워크플로우 코드가 예외를 던짐결정성 위반이나 버그. 서버가 무한 재시도
WorkflowTaskTimedOutWorker 가 10초 안에 응답 못 함워크플로우 코드에서 블로킹 IO 를 했을 가능성
ActivityTaskScheduled액티비티 실행 요청이 큐에 얹힘액티비티 타입, 입력값, 재시도 정책, 타임아웃
ActivityTaskStartedWorker 가 액티비티를 가져감attempt 로 몇 번째 시도인지
ActivityTaskCompleted액티비티 성공반환값. 리플레이 때 이 값이 재사용됨
ActivityTaskFailed액티비티가 예외를 던짐실패 원인, 스택 트레이스. 재시도 정책에 따라 재스케줄
TimerStartedWorkflow.sleep 등으로 타이머 시작타이머 ID, 만료 시각
TimerFired타이머 만료리플레이 때 "이미 지났음"으로 처리됨
WorkflowExecutionSignaled외부에서 Signal 이 들어옴Signal 이름, 페이로드 (Step 07)
WorkflowExecutionCompleted정상 종료최종 반환값
WorkflowExecutionContinuedAsNew히스토리를 리셋하고 새 Run 으로 이어감새 Run 의 입력값 (Step 08)

읽는 요령 세 가지

WorkflowTask* 3종 세트는 "구분선"으로 읽습니다. Scheduled → Started → Completed 가 한 세트이고, 그 뒤에 나오는 것이 워크플로우 코드가 낸 결정입니다. 히스토리를 볼 때 이 3종 세트를 기준으로 블록을 나누면 훨씬 잘 읽힙니다.

scheduledEventId 로 짝을 찾습니다. ActivityTaskCompleted 만 봐서는 어떤 액티비티인지 모릅니다. scheduledEventId 를 따라가면 대응하는 ActivityTaskScheduled 가 나오고, 거기 액티비티 이름과 입력값이 있습니다.

temporal workflow show -w order-2001 --output json | jq '.events[4].activityTaskScheduledEventAttributes'

결과

{
  "activityId": "5",
  "activityType": { "name": "charge" },
  "taskQueue": { "name": "ORDER_TASK_QUEUE", "kind": "Normal" },
  "input": [ "2001", 39000 ],
  "startToCloseTimeout": "10s",
  "retryPolicy": { "initialInterval": "1s", "backoffCoefficient": 1.0,
                   "maximumInterval": "100s", "maximumAttempts": 0 }
}

maximumAttempts: 0무한 재시도입니다. Temporal 액티비티의 기본값입니다 — Step 05 에서 다룹니다.

③ 마지막 이벤트가 WorkflowTaskScheduled 면 무조건 "Worker 문제" 입니다. order-2002 를 kill 했을 때 마지막이 13번 WorkflowTaskScheduled 였던 것을 기억하세요. 장애 진단의 첫 단계는 항상 "마지막 이벤트가 무엇인가"입니다.


2-7. Sticky Execution 과 캐시

2-4 에서 "Workflow Task 마다 코드를 처음부터 다시 실행한다"고 했습니다. 그대로라면 히스토리가 1만 이벤트로 커졌을 때 Workflow Task 한 번마다 1만 이벤트를 리플레이해야 합니다. 감당이 안 됩니다.

그래서 Temporal 은 Sticky Execution 이라는 최적화를 씁니다.

[첫 Workflow Task]
   Worker → 서버: "order-2001 처리했습니다. 그리고 제 전용 큐는 <sticky-42011-a7f3> 입니다"
   Worker: 워크플로우 인스턴스를 메모리 캐시에 보관
              { "order-2001" → OrderWorkflowImpl 인스턴스 (코루틴 상태 포함) }

[다음 Workflow Task]
   서버 → <sticky-42011-a7f3> 큐로 전달 (그 Worker 에게만)
   Worker: 캐시에 있네! → 리플레이 없이 "마지막 지점부터 이어서" 실행
              → 히스토리는 "새로 추가된 이벤트만" 받아온다

[캐시 미스 — Worker 재기동 / LRU 축출 / sticky 큐 타임아웃]
   서버 → 원래 ORDER_TASK_QUEUE 로 전달
   Worker: 캐시에 없다 → 히스토리 전체를 받아 full replay

정상 경로에서는 리플레이가 거의 일어나지 않습니다. 리플레이는 예외 경로(재기동·캐시 축출·워커 교체)에서만 발생합니다.

측정

히스토리가 큰 워크플로우로 비교합니다. 액티비티를 200번 순차 호출하는 워크플로우(약 1,205 이벤트)를 만들어 두 조건에서 잽니다.

캐시 히트 (정상)

14:22:08.114 [workflow-method-order-2010-...] DEBUG i.t.i.r.ReplayWorkflowTaskHandler - Cache hit for order-2010, incremental history: 3 events
14:22:08.126 [workflow-method-order-2010-...] DEBUG i.t.i.r.ReplayWorkflowTaskHandler - WorkflowTask completed in 12ms

캐시 미스 (Worker 재기동 직후)

14:25:31.402 [workflow-method-order-2010-...] DEBUG i.t.i.r.ReplayWorkflowTaskHandler - Cache miss for order-2010, full history: 1205 events
14:25:34.618 [workflow-method-order-2010-...] DEBUG i.t.i.r.ReplayWorkflowTaskHandler - WorkflowTask completed in 3216ms

Workflow Task 처리 시간 12ms → 3,216ms. 268배 차이. 리플레이해야 할 이벤트가 3개에서 1,205개로 늘었기 때문입니다.

캐시 설정은 WorkerFactoryOptions 로 조절합니다.

WorkerFactoryOptions factoryOptions = WorkerFactoryOptions.newBuilder()
        .setWorkflowCacheSize(600)                  // 캐시할 워크플로우 인스턴스 수 (기본 600)
        .setMaxWorkflowThreadCount(600)             // 워크플로우 코루틴 스레드 수 (기본 600)
        .build();
WorkerFactory factory = WorkerFactory.newInstance(client, factoryOptions);

💡 실무 팁 — 캐시 크기와 스레드 수는 함께 움직입니다 캐시된 워크플로우 인스턴스는 각각 살아 있는 코루틴 스레드를 붙들고 있습니다. 그래서 workflowCacheSize 를 늘리면 maxWorkflowThreadCount 도 함께 늘려야 합니다. 안 그러면 Timeout expired while waiting for a free workflow thread 로 Workflow Task 가 타임아웃합니다. 반대로 캐시를 너무 크게 잡으면 메모리를 먹습니다. 히스토리가 큰 워크플로우가 많다면 Continue-As-New 로 히스토리 자체를 잘라 내는 것(Step 08)이 근본 해법입니다. 히스토리 이벤트 20,412개 → 8개로 줄이면 Workflow Task 처리 시간이 3.2초 → 12ms 가 됩니다.

캐시 상태는 메트릭(temporal_sticky_cache_hit / _miss / _total_forced_eviction)으로 관찰합니다. missforced_eviction 이 계속 오르면 캐시가 부족하다는 신호입니다.


2-8. 함정 (1) — "결제가 두 번 됐다"는 오해

리플레이를 모르면 반드시 이 오해에 빠집니다. 로그가 이렇게 보이기 때문입니다.

Worker 를 세 번 재기동하면서 워크플로우를 돌린 뒤, 워크플로우 로그만 뽑아 봅니다.

10:11:15.108 [workflow-method-order-2003-...] INFO  c.e.order.OrderWorkflowImpl - [2003] 결제 시작 amount=39000
10:12:41.688 [workflow-method-order-2003-...] INFO  c.e.order.OrderWorkflowImpl - [2003] 결제 시작 amount=39000
10:13:52.201 [workflow-method-order-2003-...] INFO  c.e.order.OrderWorkflowImpl - [2003] 결제 시작 amount=39000

"결제 시작" 이 세 번 찍혔습니다. 로그만 보면 39,000원이 세 번 빠져나간 것처럼 보입니다. 결제팀에서 문의가 오고, 장애 대응이 시작됩니다.

하지만 결제는 한 번만 일어났습니다. 증명합니다.

증거 ① 액티비티 로그는 한 번뿐입니다.

10:11:15.219 [Activity Executor taskQueue="ORDER_TASK_QUEUE": 1] INFO  c.e.o.PaymentActivityImpl - [2003] 결제 요청 amount=39000

같은 시간대 로그 전체를 뒤져도 PaymentActivityImpl 로그는 이 한 줄입니다.

증거 ② 히스토리에 스케줄된 액티비티가 각각 하나뿐입니다.

temporal workflow show -w order-2003 --output json \
  | jq '[.events[] | select(.eventType=="ActivityTaskScheduled")
         | .activityTaskScheduledEventAttributes.activityType.name]'

결과

[ "charge", "reserve", "requestShipment" ]

증거 ③ 액티비티 시도 횟수가 전부 1 입니다.

temporal workflow show -w order-2003 --output json \
  | jq '[.events[] | select(.eventType=="ActivityTaskStarted")
         | .activityTaskStartedEventAttributes.attempt]'

결과

[ 1, 1, 1 ]

재실행이 없었습니다. attempt 가 2 이상이면 그것은 리플레이가 아니라 재시도이며, 완전히 다른 일입니다.

⚠️ 함정 — 워크플로우 로그의 반복은 "코드가 다시 돌았다"이지 "부수효과가 다시 일어났다"가 아닙니다 리플레이 중 payment.charge(...) 줄은 분명히 다시 지나갑니다. 하지만 그 호출은 액티비티를 실행하지 않고 히스토리의 이벤트 7 에서 결과를 꺼내 반환합니다. 부수효과(PG 사 HTTP 호출)는 PaymentActivityImpl.charge 안에 있고, 그 메서드는 재실행되지 않습니다.

이것이 워크플로우 코드와 액티비티 코드를 반드시 분리해야 하는 이유입니다. 워크플로우 코드 안에 직접 HTTP 호출을 넣으면 — 결정성 위반이기도 하지만 — 리플레이할 때마다 진짜로 호출됩니다. 세 번 재기동했다면 진짜로 세 번 결제됩니다.

판단 기준: "이 줄이 100번 반복 실행돼도 괜찮은가?" 를 물어보세요. 안 괜찮다면 그것은 액티비티로 빼야 합니다.


2-9. 함정 (2) — log.info 는 리플레이마다 다시 찍힙니다

2-8 의 로그 중복은 사실 고칠 수 있습니다. 위 예제의 워크플로우가 이렇게 돼 있었다고 해 봅시다.

public class OrderWorkflowImpl implements OrderWorkflow {

    // ❌ 잘못된 로거
    private static final Logger log = LoggerFactory.getLogger(OrderWorkflowImpl.class);

    @Override
    public String processOrder(OrderRequest req) {
        log.info("[{}] 결제 시작 amount={}", req.orderId(), req.amount());
        String paymentId = payment.charge(req.orderId(), req.amount());
        log.info("[{}] 결제 완료 {}", req.orderId(), paymentId);
        Workflow.sleep(Duration.ofSeconds(5));
        log.info("[{}] 재고 예약 시작", req.orderId());
        String reservationId = inventory.reserve(req.orderId(), req.sku(), req.qty());
        log.info("[{}] 재고 예약 완료 {}", req.orderId(), reservationId);
        return "order-" + req.orderId() + " COMPLETED";
    }
}

Worker 를 두 번 재기동하며 실행한 콘솔 출력 전문입니다.

--- Worker 1회차 기동 ---
10:20:03.101 [workflow-method-order-2004-1a] INFO  c.e.order.OrderWorkflowImpl - [2004] 결제 시작 amount=39000
10:20:03.244 [Activity Executor ...: 1]     INFO  c.e.o.PaymentActivityImpl    - [2004] 결제 요청 amount=39000
10:20:03.388 [workflow-method-order-2004-1a] INFO  c.e.order.OrderWorkflowImpl - [2004] 결제 시작 amount=39000     ← 중복 (1회 리플레이)
10:20:03.389 [workflow-method-order-2004-1a] INFO  c.e.order.OrderWorkflowImpl - [2004] 결제 완료 pay-2004
--- kill -9 ---

--- Worker 2회차 기동 ---
10:21:47.612 [workflow-method-order-2004-2b] INFO  c.e.order.OrderWorkflowImpl - [2004] 결제 시작 amount=39000     ← 중복 (2회)
10:21:47.613 [workflow-method-order-2004-2b] INFO  c.e.order.OrderWorkflowImpl - [2004] 결제 완료 pay-2004         ← 중복
10:21:47.614 [workflow-method-order-2004-2b] INFO  c.e.order.OrderWorkflowImpl - [2004] 재고 예약 시작
10:21:47.760 [Activity Executor ...: 1]     INFO  c.e.o.InventoryActivityImpl  - [2004] 재고 예약 sku=SKU-BLACK-TEE
10:21:47.902 [workflow-method-order-2004-2b] INFO  c.e.order.OrderWorkflowImpl - [2004] 결제 시작 amount=39000     ← 중복 (3회)
10:21:47.903 [workflow-method-order-2004-2b] INFO  c.e.order.OrderWorkflowImpl - [2004] 결제 완료 pay-2004         ← 중복
10:21:47.904 [workflow-method-order-2004-2b] INFO  c.e.order.OrderWorkflowImpl - [2004] 재고 예약 시작              ← 중복
10:21:47.905 [workflow-method-order-2004-2b] INFO  c.e.order.OrderWorkflowImpl - [2004] 재고 예약 완료 resv-2004

"결제 시작" 이 4번, "결제 완료" 가 3번, "재고 예약 시작" 이 2번 찍혔습니다. 실제로는 각각 한 번씩 일어난 일입니다.

로거를 바꿉니다. 딱 한 줄입니다.

// ✅ 올바른 로거
private static final Logger log = Workflow.getLogger(OrderWorkflowImpl.class);

같은 시나리오를 다시 돌린 콘솔 출력 전문입니다.

--- Worker 1회차 기동 ---
10:24:11.101 [workflow-method-order-2005-3c] INFO  c.e.order.OrderWorkflowImpl - [2005] 결제 시작 amount=39000
10:24:11.244 [Activity Executor ...: 1]     INFO  c.e.o.PaymentActivityImpl    - [2005] 결제 요청 amount=39000
10:24:11.389 [workflow-method-order-2005-3c] INFO  c.e.order.OrderWorkflowImpl - [2005] 결제 완료 pay-2005
--- kill -9 ---

--- Worker 2회차 기동 ---
10:25:52.614 [workflow-method-order-2005-4d] INFO  c.e.order.OrderWorkflowImpl - [2005] 재고 예약 시작
10:25:52.760 [Activity Executor ...: 1]     INFO  c.e.o.InventoryActivityImpl  - [2005] 재고 예약 sku=SKU-BLACK-TEE
10:25:52.905 [workflow-method-order-2005-4d] INFO  c.e.order.OrderWorkflowImpl - [2005] 재고 예약 완료 resv-2005

중복이 완전히 사라졌습니다. 로그 8줄 → 4줄(워크플로우 로그 기준). 각 사건이 정확히 한 번씩만 기록됩니다.

Workflow.getLogger 가 돌려주는 것은 ReplayAwareLogger 라는 래퍼입니다. 내부적으로 Workflow.isReplaying() 을 확인해, 리플레이 중이면 로그를 버립니다.

// SDK 내부 동작을 흉내 내면 이렇습니다
if (!Workflow.isReplaying()) {
    delegate.info(message, args);
}

⚠️ 함정 — 워크플로우 코드의 로그·메트릭·알림은 전부 이 문제를 갖습니다 로거만이 아닙니다. 워크플로우 코드 안에서 하는 모든 관찰 가능성 작업이 리플레이마다 반복됩니다.

  • meterRegistry.counter("order.paid").increment() → 카운터가 뻥튀기됩니다
  • slackClient.send("주문 완료") → 슬랙 알림이 여러 번 갑니다 (게다가 결정성 위반이기도 합니다)
  • System.out.println(...) → 그대로 중복 출력됩니다

처방 두 가지:

  1. 로그는 Workflow.getLogger(...) 를 씁니다. 워크플로우 클래스에서 LoggerFactory.getLogger 를 쓰지 않는다를 팀 규칙으로 만드세요. ArchUnit 이나 Checkstyle 로 강제할 수 있습니다.
  2. 로그 외의 부수효과(메트릭·알림·외부 호출)는 전부 액티비티로 뺍니다. 액티비티는 리플레이되지 않으므로 정확히 한 번 실행됩니다.

예외적으로 워크플로우 코드 안에서 직접 처리해야 한다면 Workflow.isReplaying() 으로 직접 가드합니다.

if (!Workflow.isReplaying()) {
    meterRegistry.counter("order.started").increment();
}

다만 이 방식은 캐시 미스 여부에 따라 실행 횟수가 달라질 수 있어 정확한 카운트를 보장하지 않습니다. 정확도가 필요하면 반드시 액티비티로 빼세요.

반대로 액티비티 코드에서는 Workflow.getLogger 를 쓰면 안 됩니다. 액티비티는 워크플로우 컨텍스트 밖에서 실행되므로 예외가 납니다.

public class PaymentActivityImpl implements PaymentActivity {
    // ✅ 액티비티는 평범한 로거
    private static final Logger log = LoggerFactory.getLogger(PaymentActivityImpl.class);
}
어디서어떤 로거이유
워크플로우 구현체Workflow.getLogger(...)리플레이 중 출력 억제
액티비티 구현체LoggerFactory.getLogger(...)리플레이 안 됨. 평범한 코드
Worker / Client mainLoggerFactory.getLogger(...)워크플로우 컨텍스트 밖

정리

개념핵심
Task Queue큐가 아니라 long-poll 매칭 지점. 저장하지 않고 폴러에 즉시 전달
Workflow / Activity 폴러같은 큐 이름이지만 별개의 매칭 라인. 분리 배치 가능
이벤트 소싱"현재 상태"를 저장하지 않음. 일어난 일의 목록만 append
상태 복원지역 변수 값까지 히스토리에서 복원 가능(ActivityTaskCompleted.result)
리플레이Worker 재기동 시 코드를 처음부터 다시 실행. 히스토리에 결과가 있는 액티비티는 재호출하지 않음
리플레이 증거재기동 후 PaymentActivityImpl 로그가 안 찍힘. ActivityTaskStarted 도 1회뿐
Workflow Task코드를 다음 대기 지점까지 밀고 Command 목록을 반환. 리플레이됨. 코드 제약 있음
Activity Task실제 부수효과. 정확히 한 번. 코드 제약 없음. 타임아웃 지정 필수
Command → EventCommand 는 Worker→Server, Event 는 서버 기록. WorkflowTaskCompleted 다음 이벤트들이 그 Command 의 결과
히스토리 읽기WorkflowTask* 3종을 구분선으로. scheduledEventId 로 짝 찾기
진단 1번 규칙마지막 이벤트가 WorkflowTaskScheduledWorker 문제
Sticky Execution워크플로우 인스턴스를 메모리 캐시 → 리플레이 생략. 미스 시 full replay
캐시 미스 비용1,205 이벤트 full replay = 12ms → 3,216ms (268배)
함정 ①워크플로우 로그 반복은 "코드 재실행"이지 "부수효과 재발생"이 아님
함정 ②LoggerFactory 로거는 리플레이마다 재출력. Workflow.getLogger 를 쓸 것
판단 기준"이 줄이 100번 반복돼도 괜찮은가?" 아니면 액티비티로

연습문제

Exercise.java 에 6문제가 있습니다. 정답은 Solution.java. 직접 Worker 를 kill 하고 히스토리를 확인하세요.

  1. 액티비티 3개 + 타이머 2개짜리 워크플로우의 최종 이벤트 수를 예측한 뒤 실측으로 검증하기
  2. 실행 중 Worker 를 kill 하고, 재기동 후 액티비티 로그가 다시 찍히지 않는 것을 확인하기
  3. 워크플로우 로거를 LoggerFactoryWorkflow.getLogger 로 바꾸고 로그 줄 수 변화 세기
  4. 주어진 히스토리 발췌를 보고 각 WorkflowTaskCompleted 가 어떤 Command 를 반환했는지 맞히기
  5. Workflow.isReplaying() 으로 리플레이 횟수를 세어 출력하고, 캐시 히트/미스에 따라 값이 달라지는 것 관찰하기
  6. 워크플로우 코드 안에 직접 Thread.sleep + 카운터 증가를 넣고 왜 위험한지 히스토리로 증명하기

다음 단계

리플레이가 "같은 코드 + 같은 히스토리 = 같은 Command 순서"라는 전제 위에 있다는 것을 확인했습니다. 그렇다면 그 전제를 깨뜨리는 코드는 무엇일까요. new Random(), System.currentTimeMillis(), HashMap 순회, 그리고 우리가 무심코 쓰는 수많은 것들입니다.

다음 스텝에서는 결정성 제약의 전체 목록과 SDK 가 제공하는 우회 API(Workflow.currentTimeMillis, Workflow.randomUUID, Workflow.sideEffect)를 다루고, 결정성 위반이 NonDeterministicException 으로 터지는 순간을 직접 재현합니다.

Step 03 — 워크플로우 정의와 결정성


실습 파일

이 스텝은 Java 파일 세 개로 진행합니다. 이번 스텝의 실습은 Worker 를 죽였다 살리는 것이 핵심이라, 세 파일 모두 "액티비티 사이에 5초 타이머를 넣어 kill 할 시간을 확보"하는 구조를 공유합니다. Practice.java 로 2-2 ~ 2-9 를 재현하고, Exercise.java 의 6문제를 푼 뒤, Solution.java 로 대조합니다.

Practice.java

강의 본문(2-2 ~ 2-9)의 모든 코드를 절 번호 주석과 함께 담은 실습 스크립트입니다.

  • [2-3] 리플레이 재현이 이 파일의 중심입니다. WorkerMain 을 띄우고 StarterMain replay 를 실행한 뒤, Worker 콘솔에 "결제 요청" 이 찍히고 5초 sleep 에 들어간 순간 다른 터미널에서 kill -9 $(pgrep -f Practice) 를 실행하세요. 타이밍을 놓치면 워크플로우가 그냥 끝나 버려 실습이 안 됩니다. 그래서 타이머를 5초로 넉넉하게 잡았습니다.
  • [2-9] 로거 함정은 BAD_LOGGER 상수로 전환합니다. trueLoggerFactory(중복 발생), falseWorkflow.getLogger(정상)입니다. 두 값을 각각 돌려 콘솔 출력 줄 수를 직접 세어 비교하는 것이 이 절의 실습입니다. 값을 바꾸면 Worker 재시작이 필요합니다.
  • REPLAY_COUNT 라는 AtomicInteger 를 워크플로우 클래스에 두고 processOrder 진입마다 증가시킵니다. 이 값이 워크플로우가 몇 번 재실행됐는지를 보여 줍니다. Workflow.isReplaying() 결과도 함께 출력하므로, "리플레이 중인 실행"과 "새 실행"을 구분해 볼 수 있습니다.
  • StarterMain 시나리오는 basic(2-2 이벤트 소싱), replay(2-3 kill 실험), logger(2-9 로그 중복), sticky(2-7 캐시 측정) 네 가지입니다. sticky 는 액티비티를 200번 호출해 히스토리를 1,205 이벤트까지 키우므로 완주에 3~4분 걸립니다.
  • 액티비티 구현체는 전부 호출될 때마다 INVOKE_COUNT 를 증가시키고 그 값을 로그에 찍습니다. 리플레이 후 이 카운터가 늘지 않는 것이 2-3 의 결정적 증거이며, 파일 끝의 printInvokeCounts() 로 한눈에 확인할 수 있습니다.
package com.example.order;

/*
 * ============================================================================
 * Step 02 — 핵심 개념과 실행 모델 : Practice.java
 * ============================================================================
 *
 * 실행 방법 (터미널 2개 필요)
 *
 *   [터미널 1] Worker
 *     ./gradlew runWorker
 *
 *   [터미널 2] Client
 *     ./gradlew runStarter --args="basic"    # 2-2 이벤트 소싱 (33 이벤트)
 *     ./gradlew runStarter --args="replay"   # 2-3 리플레이 kill 실험  ★핵심
 *     ./gradlew runStarter --args="logger"   # 2-9 로그 중복
 *     ./gradlew runStarter --args="sticky"   # 2-7 캐시 측정 (3~4분 소요)
 *
 * ★ replay 시나리오 사용법
 *   1. Worker 콘솔에 "결제 요청" 이 찍히면 5초 타이머가 시작된 것이다.
 *   2. 그 5초 안에 다른 터미널에서:
 *        kill -9 $(pgrep -f Practice)
 *   3. Worker 를 다시 띄운다:
 *        ./gradlew runWorker
 *   4. 확인할 것:
 *        - PaymentActivityImpl 로그가 "다시 안 찍힌다"       ← 핵심
 *        - 워크플로우가 재고 예약부터 이어서 진행된다
 *        - temporal workflow describe -w order-2002 → History Length 33
 *
 * 버전: Temporal Server 1.22.4 / Java SDK 1.22.3 / CLI 0.11.0 / Java 21
 * ============================================================================
 */

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.worker.WorkerFactoryOptions;
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;
import java.util.concurrent.atomic.AtomicInteger;

public class Practice {

    public static final String ORDER_TASK_QUEUE = "ORDER_TASK_QUEUE";

    // ------------------------------------------------------------------
    // [2-9] 로거 전환 스위치
    //   true  → LoggerFactory 사용. 리플레이마다 로그가 중복 출력된다.
    //   false → Workflow.getLogger 사용. 리플레이 중 출력이 억제된다.
    //   두 값을 각각 돌려 콘솔 출력 줄 수를 직접 세어 비교할 것.
    //   값을 바꾸면 Worker 를 재시작해야 한다.
    // ------------------------------------------------------------------
    public static final boolean BAD_LOGGER = false;

    // ------------------------------------------------------------------
    // 관찰용 카운터
    //   REPLAY_COUNT  : processOrder() 가 몇 번 호출됐는가 (= Workflow Task 수)
    //   INVOKE_COUNT_*: 각 액티비티가 실제로 몇 번 실행됐는가
    //   ★ 리플레이 후 REPLAY_COUNT 는 늘지만 INVOKE_COUNT 는 안 는다.
    //     이 비대칭이 Step 02 의 전부다.
    // ------------------------------------------------------------------
    public static final AtomicInteger REPLAY_COUNT = new AtomicInteger();
    public static final AtomicInteger INVOKE_CHARGE = new AtomicInteger();
    public static final AtomicInteger INVOKE_RESERVE = new AtomicInteger();
    public static final AtomicInteger INVOKE_SHIP = new AtomicInteger();

    public record OrderRequest(
            String orderId, String customerId, String sku, int qty, long amount, String address) {}

    // ==================================================================
    // 액티비티 인터페이스
    // ==================================================================
    @ActivityInterface
    public interface PaymentActivity {
        @ActivityMethod String charge(String orderId, long amount);
        @ActivityMethod void refund(String paymentId);
    }

    @ActivityInterface
    public interface InventoryActivity {
        @ActivityMethod String reserve(String orderId, String sku, int qty);
        @ActivityMethod void release(String reservationId);
    }

    @ActivityInterface
    public interface ShippingActivity {
        @ActivityMethod String requestShipment(String orderId, String address);
        @ActivityMethod void cancelShipment(String shipmentId);
    }

    // ==================================================================
    // [2-3] 액티비티 구현 — 여기가 "부수효과가 실제로 일어나는" 유일한 지점이다.
    //   리플레이 때 이 메서드들은 호출되지 않는다.
    //   INVOKE_COUNT 가 그 증거가 된다.
    // ==================================================================
    public static class PaymentActivityImpl implements PaymentActivity {
        private static final Logger log = LoggerFactory.getLogger(PaymentActivityImpl.class);

        @Override public String charge(String orderId, long amount) {
            int n = INVOKE_CHARGE.incrementAndGet();
            log.info("[{}] 결제 요청 amount={}  (실제 실행 {}회째)", orderId, amount, n);
            return "pay-" + orderId;
        }

        @Override public void refund(String paymentId) {
            log.info("환불 처리 paymentId={}", paymentId);
        }
    }

    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) {
            int n = INVOKE_RESERVE.incrementAndGet();
            log.info("[{}] 재고 예약 sku={} qty={}  (실제 실행 {}회째)", orderId, sku, qty, n);
            return "resv-" + orderId;
        }

        @Override public void release(String reservationId) {
            log.info("예약 해제 reservationId={}", reservationId);
        }
    }

    public static class ShippingActivityImpl implements ShippingActivity {
        private static final Logger log = LoggerFactory.getLogger(ShippingActivityImpl.class);

        @Override public String requestShipment(String orderId, String address) {
            int n = INVOKE_SHIP.incrementAndGet();
            log.info("[{}] 배송 요청 address={}  (실제 실행 {}회째)", orderId, address, n);
            return "ship-" + orderId;
        }

        @Override public void cancelShipment(String shipmentId) {
            log.info("배송 취소 shipmentId={}", shipmentId);
        }
    }

    // ==================================================================
    // [2-2][2-3] 메인 워크플로우
    //   액티비티 3개 + 5초 타이머 2개 = 최종 33 이벤트
    //   타이머를 넣은 이유는 "Worker 를 kill 할 시간"을 벌기 위해서다.
    // ==================================================================
    @WorkflowInterface
    public interface OrderWorkflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class OrderWorkflowImpl implements OrderWorkflow {

        // [2-9] 이 한 줄이 함정의 전부다.
        //   Workflow.getLogger  → ReplayAwareLogger. 리플레이 중 출력 억제.
        //   LoggerFactory       → 평범한 로거. 리플레이마다 다시 찍힘.
        private static final Logger log = BAD_LOGGER
                ? LoggerFactory.getLogger(OrderWorkflowImpl.class)
                : Workflow.getLogger(OrderWorkflowImpl.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);
        private final ShippingActivity shipping =
                Workflow.newActivityStub(ShippingActivity.class, opts);

        @Override
        public String processOrder(OrderRequest req) {
            // [2-4] 이 줄은 Workflow Task 가 돌 때마다 실행된다.
            //   order-2001 처럼 액티비티 3개 + 타이머 2개면 총 6번 실행된다.
            int n = REPLAY_COUNT.incrementAndGet();
            System.out.printf("  >> processOrder 진입 #%d  (isReplaying=%s)%n",
                    n, Workflow.isReplaying());

            log.info("[{}] 결제 시작 amount={}", req.orderId(), req.amount());

            // [2-5] 이 호출은 실제 실행이 아니라 ScheduleActivityTask Command 를 만든다.
            //   서버가 그것을 ActivityTaskScheduled 이벤트로 기록한다.
            String paymentId = payment.charge(req.orderId(), req.amount());
            log.info("[{}] 결제 완료 {}", req.orderId(), paymentId);

            // [2-5] StartTimer Command → TimerStarted 이벤트
            //   ★ 여기가 kill 타이밍이다. 5초 안에 kill -9 를 실행할 것.
            Workflow.sleep(Duration.ofSeconds(5));

            log.info("[{}] 재고 예약 시작", req.orderId());
            String reservationId = inventory.reserve(req.orderId(), req.sku(), req.qty());
            log.info("[{}] 재고 예약 완료 {}", req.orderId(), reservationId);

            Workflow.sleep(Duration.ofSeconds(5));

            log.info("[{}] 배송 요청 시작", req.orderId());
            String shipmentId = shipping.requestShipment(req.orderId(), req.address());
            log.info("[{}] 배송 요청 완료 {}", req.orderId(), shipmentId);

            // [2-5] CompleteWorkflowExecution Command → WorkflowExecutionCompleted 이벤트
            return "order-" + req.orderId() + " COMPLETED";
        }
    }

    // ==================================================================
    // [2-7] Sticky 캐시 측정용 — 액티비티를 200번 순차 호출해 히스토리를 키운다.
    //   최종 히스토리 약 1,205 이벤트.
    //   Worker 를 재기동하면 full replay 가 일어나 처리 시간이 급증한다.
    // ==================================================================
    @WorkflowInterface
    public interface LongOrderWorkflow {
        @WorkflowMethod String processMany(OrderRequest req, int times);
    }

    public static class LongOrderWorkflowImpl implements LongOrderWorkflow {
        private static final Logger log = Workflow.getLogger(LongOrderWorkflowImpl.class);

        private final PaymentActivity payment = Workflow.newActivityStub(
                PaymentActivity.class,
                ActivityOptions.newBuilder()
                        .setStartToCloseTimeout(Duration.ofSeconds(10))
                        .build());

        @Override
        public String processMany(OrderRequest req, int times) {
            for (int i = 0; i < times; i++) {
                payment.charge(req.orderId() + "-" + i, req.amount());
                if (i % 50 == 0) {
                    log.info("[{}] 진행 {}/{}", req.orderId(), i, times);
                }
            }
            return "order-" + req.orderId() + " x" + times + " COMPLETED";
        }
    }

    // ==================================================================
    // Worker
    // ==================================================================
    public static class WorkerMain {
        public static void main(String[] args) {
            WorkflowClient client = WorkflowClient.newInstance(
                    WorkflowServiceStubs.newLocalServiceStubs());

            // [2-7] 캐시 크기와 워크플로우 스레드 수는 함께 움직여야 한다.
            //   캐시된 인스턴스는 각각 살아 있는 코루틴 스레드를 붙들고 있기 때문이다.
            WorkerFactoryOptions factoryOptions = WorkerFactoryOptions.newBuilder()
                    .setWorkflowCacheSize(600)
                    .setMaxWorkflowThreadCount(600)
                    .build();

            WorkerFactory factory = WorkerFactory.newInstance(client, factoryOptions);
            Worker worker = factory.newWorker(ORDER_TASK_QUEUE);

            worker.registerWorkflowImplementationTypes(
                    OrderWorkflowImpl.class, LongOrderWorkflowImpl.class);
            worker.registerActivitiesImplementations(
                    new PaymentActivityImpl(),
                    new InventoryActivityImpl(),
                    new ShippingActivityImpl());

            factory.start();

            System.out.println("Worker started. queue=" + ORDER_TASK_QUEUE
                    + " / BAD_LOGGER=" + BAD_LOGGER);
            System.out.println("kill 실험: kill -9 $(pgrep -f Practice)");

            // 종료 시 카운터를 출력해 리플레이/실행 비대칭을 확인한다.
            Runtime.getRuntime().addShutdownHook(new Thread(Practice::printInvokeCounts));
        }
    }

    // ==================================================================
    // Client
    // ==================================================================
    public static class StarterMain {

        public static void main(String[] args) {
            String scenario = (args.length > 0) ? args[0] : "basic";
            WorkflowClient client = WorkflowClient.newInstance(
                    WorkflowServiceStubs.newLocalServiceStubs());

            switch (scenario) {
                case "basic"  -> basic(client);
                case "replay" -> replay(client);
                case "logger" -> logger(client);
                case "sticky" -> sticky(client);
                default -> System.out.println("basic | replay | logger | sticky");
            }
            System.exit(0);
        }

        // ----------------------------------------------------------
        // [2-2] 이벤트 소싱 — 정상 실행. 최종 33 이벤트.
        // ----------------------------------------------------------
        static void basic(WorkflowClient client) {
            String id = "order-2001";
            terminateIfRunning(client, id);

            OrderWorkflow w = client.newWorkflowStub(OrderWorkflow.class, opts(id));
            long t0 = System.currentTimeMillis();
            String result = w.processOrder(sample("2001"));
            long elapsed = System.currentTimeMillis() - t0;

            System.out.println("결과: " + result + " (" + elapsed + "ms)");
            System.out.println();
            System.out.println("확인:");
            System.out.println("  temporal workflow show -w " + id);
            System.out.println("    → 33 이벤트. 어디에도 'status=PAID' 같은 상태 컬럼이 없다.");
            System.out.println("  temporal workflow show -w " + id
                    + " --output json | jq '.events[6].activityTaskCompletedEventAttributes.result'");
            System.out.println("    → [\"pay-2001\"]  ← 지역 변수 paymentId 의 값이 히스토리에 있다");
        }

        // ----------------------------------------------------------
        // [2-3] 리플레이 kill 실험 ★ 이 스텝의 핵심
        // ----------------------------------------------------------
        static void replay(WorkflowClient client) {
            String id = "order-2002";
            terminateIfRunning(client, id);

            System.out.println("=================================================");
            System.out.println(" 리플레이 실험");
            System.out.println("=================================================");
            System.out.println(" 1. Worker 콘솔에 '결제 요청' 이 찍히면 5초 타이머 시작");
            System.out.println(" 2. 그 5초 안에 다른 터미널에서:");
            System.out.println("      kill -9 $(pgrep -f Practice)");
            System.out.println(" 3. Worker 재기동: ./gradlew runWorker");
            System.out.println(" 4. 재기동 후 PaymentActivityImpl 로그가 '안 찍히는지' 확인");
            System.out.println("=================================================");
            System.out.println();

            OrderWorkflow w = client.newWorkflowStub(OrderWorkflow.class, opts(id));
            long t0 = System.currentTimeMillis();
            String result = w.processOrder(sample("2002"));   // Worker 가 죽어도 여기서 기다린다
            long elapsed = System.currentTimeMillis() - t0;

            System.out.println();
            System.out.println("결과: " + result + " (" + elapsed + "ms)");
            System.out.println();
            System.out.println("확인:");
            System.out.println("  temporal workflow describe -w " + id);
            System.out.println("    → History Length 33. kill 하지 않은 order-2001 과 '똑같다'.");
            System.out.println("      Worker 가 죽었다는 사실은 히스토리에 흔적조차 없다.");
            System.out.println();
            System.out.println("  temporal workflow show -w " + id + " | grep -c ActivityTaskStarted");
            System.out.println("    → 3.  charge 가 두 번 실행됐다면 4가 나왔을 것이다.");
            System.out.println();
            System.out.println("  temporal workflow show -w " + id + " --output json \\");
            System.out.println("    | jq '[.events[] | select(.eventType==\"ActivityTaskStarted\")");
            System.out.println("           | .activityTaskStartedEventAttributes.attempt]'");
            System.out.println("    → [1,1,1]. 재시도 없음.");
        }

        // ----------------------------------------------------------
        // [2-9] 로그 중복
        //   BAD_LOGGER=true 로 바꾸고 Worker 재시작 후 실행 → 중복 발생
        //   BAD_LOGGER=false 로 되돌리고 재실행 → 중복 사라짐
        //   중간에 kill -9 를 섞어야 리플레이가 일어나 차이가 보인다.
        // ----------------------------------------------------------
        static void logger(WorkflowClient client) {
            String id = BAD_LOGGER ? "order-2004" : "order-2005";
            terminateIfRunning(client, id);

            System.out.println("BAD_LOGGER = " + BAD_LOGGER);
            System.out.println("Worker 콘솔의 OrderWorkflowImpl 로그 줄 수를 세어 보세요.");
            System.out.println("실행 중 kill -9 를 한 번 섞으면 차이가 확연해집니다.");
            System.out.println();
            System.out.println("세는 요령:");
            System.out.println("  ./gradlew runWorker 2>&1 | tee replay.log");
            System.out.println("  grep -c 'OrderWorkflowImpl' replay.log");
            System.out.println("    BAD_LOGGER=true  → 8줄 (중복)");
            System.out.println("    BAD_LOGGER=false → 4줄 (정상)");
            System.out.println();

            OrderWorkflow w = client.newWorkflowStub(OrderWorkflow.class, opts(id));
            System.out.println("결과: " + w.processOrder(sample(id.substring(6))));
        }

        // ----------------------------------------------------------
        // [2-7] Sticky 캐시 측정 — 3~4분 걸린다.
        //   실행이 끝난 뒤 Worker 를 재기동하고 같은 워크플로우에 Signal 을 보내면
        //   full replay 가 일어나는 것을 로그로 볼 수 있다(Signal 은 Step 07).
        //   여기서는 히스토리 크기만 키우고 로그 레벨 안내를 출력한다.
        // ----------------------------------------------------------
        static void sticky(WorkflowClient client) {
            String id = "order-2010";
            terminateIfRunning(client, id);

            System.out.println("액티비티를 200번 호출합니다. 3~4분 걸립니다.");
            System.out.println("SDK 리플레이 로그를 보려면 logback.xml 에서 아래를 DEBUG 로:");
            System.out.println("  <logger name=\"io.temporal.internal.replay\" level=\"DEBUG\"/>");
            System.out.println();

            LongOrderWorkflow w = client.newWorkflowStub(LongOrderWorkflow.class, opts(id));
            long t0 = System.currentTimeMillis();
            String result = w.processMany(sample("2010"), 200);
            long elapsed = System.currentTimeMillis() - t0;

            System.out.println("결과: " + result + " (" + elapsed + "ms)");
            System.out.println();
            System.out.println("확인: temporal workflow describe -w " + id);
            System.out.println("  → History Length 약 1205");
            System.out.println("  캐시 히트 시 Workflow Task 처리 12ms");
            System.out.println("  캐시 미스(Worker 재기동) 시 full replay 3216ms — 268배");
        }

        // ---- 유틸 ----
        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 void terminateIfRunning(WorkflowClient client, String workflowId) {
            try {
                client.newUntypedWorkflowStub(workflowId).terminate("practice rerun");
                System.out.println("(기존 실행 " + workflowId + " 정리)");
            } catch (Exception ignored) {
                // 실행 없음 — 정상
            }
        }
    }

    // ==================================================================
    // [2-3] 리플레이/실행 비대칭을 한눈에
    // ==================================================================
    public static void printInvokeCounts() {
        System.out.println();
        System.out.println("---------------------------------------------");
        System.out.println(" 워크플로우 코드 실행 횟수 : " + REPLAY_COUNT.get() + " 회");
        System.out.println(" 액티비티 실제 실행 횟수");
        System.out.println("   charge          : " + INVOKE_CHARGE.get() + " 회");
        System.out.println("   reserve         : " + INVOKE_RESERVE.get() + " 회");
        System.out.println("   requestShipment : " + INVOKE_SHIP.get() + " 회");
        System.out.println("---------------------------------------------");
        System.out.println(" 워크플로우 코드는 여러 번, 액티비티는 정확히 한 번.");
        System.out.println(" 이 비대칭이 Temporal 의 전부입니다.");
        System.out.println("---------------------------------------------");
    }
}

Exercise.java

6문제의 문제지입니다. // TODO: 여기에 작성 을 채운 뒤 Worker 를 띄우고 히스토리로 검증합니다.

  • 문제 1 은 이벤트 수를 먼저 예측하게 합니다. EXPECTED_EVENT_COUNT 에 값을 적으면 실행 후 temporal workflow describe 로 확인하라는 안내가 나옵니다. 액티비티 3개 + 타이머 2개이므로 Step 01 의 산식에 타이머 항을 더해야 합니다.
  • 문제 2·3 은 코드를 거의 안 쓰고 관찰하는 문제입니다. 대신 관찰 결과를 // 답: 주석에 적게 되어 있습니다. 문제 3 은 로그 줄 수를 세는 것이라, 콘솔을 tee replay.log 로 파일에 받아 grep -c 하면 편합니다. 파일 주석에 그 명령이 적혀 있습니다.
  • 문제 4 는 코드를 실행하지 않는 지필 문제입니다. 히스토리 발췌 20줄이 주석으로 들어 있고, 각 WorkflowTaskCompleted 옆에 어떤 Command 를 반환했는지 적으면 됩니다. 답을 다 적은 뒤 실제 워크플로우를 돌려 대조하세요.
  • 문제 5Workflow.isReplaying() 은 캐시 히트일 때와 미스일 때 결과가 다릅니다. Worker 를 재기동하지 않으면 sticky 캐시가 살아 있어 리플레이가 거의 안 일어나므로, 반드시 kill 을 섞어야 의미 있는 값이 나옵니다.
  • 문제 6 은 일부러 위험한 코드로 시작합니다. 워크플로우 코드 안에 Thread.sleep(3000)static int counter++ 가 들어 있습니다. 실행하면 WorkflowTaskTimedOut 이 나거나 카운터가 예상보다 크게 나옵니다. 왜 그런지를 히스토리로 설명하는 것이 문제입니다.
package com.example.order;

/*
 * ============================================================================
 * Step 02 — 핵심 개념과 실행 모델 : Exercise.java  (문제지)
 * ============================================================================
 *
 * 6문제입니다. `// TODO: 여기에 작성` 자리를 채우세요.
 * 정답은 Solution.java 에 있습니다.
 *
 * 실행 방법
 *   [터미널 1] java com.example.order.Exercise$WorkerMain
 *              (로그를 파일로도 받으려면: ... 2>&1 | tee replay.log)
 *   [터미널 2] java com.example.order.Exercise$StarterMain <문제번호>
 *
 * kill 실험
 *   kill -9 $(pgrep -f Exercise)
 *
 * 로그 줄 세기 (문제 3)
 *   grep -c 'Q3WorkflowImpl' replay.log
 *
 * 뒷정리
 *   java com.example.order.Exercise$Cleanup
 * ============================================================================
 */

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.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;
import java.util.concurrent.atomic.AtomicInteger;

public class Exercise {

    public static final String ORDER_TASK_QUEUE = "ORDER_TASK_QUEUE";

    // ------------------------------------------------------------------
    // 문제 1 — 예측값을 여기에 적으세요.
    //   액티비티 3개 + 5초 타이머 2개짜리 워크플로우의 최종 히스토리 이벤트 수는?
    //   힌트: Step 01 에서 "액티비티 1개 = 6 이벤트" 를 배웠습니다.
    //         타이머 1개는 몇 이벤트일까요?
    // ------------------------------------------------------------------
    public static final int EXPECTED_EVENT_COUNT = 0;   // TODO: 여기에 작성

    public static final AtomicInteger INVOKE_CHARGE = new AtomicInteger();
    public static final AtomicInteger INVOKE_RESERVE = new AtomicInteger();
    public static final AtomicInteger INVOKE_SHIP = new AtomicInteger();

    public record OrderRequest(
            String orderId, String customerId, String sku, int qty, long amount, String address) {}

    // ==================================================================
    // 액티비티
    // ==================================================================
    @ActivityInterface
    public interface PaymentActivity {
        @ActivityMethod String charge(String orderId, long amount);
    }

    @ActivityInterface
    public interface InventoryActivity {
        @ActivityMethod String reserve(String orderId, String sku, int qty);
    }

    @ActivityInterface
    public interface ShippingActivity {
        @ActivityMethod String requestShipment(String orderId, String address);
    }

    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, INVOKE_CHARGE.incrementAndGet());
            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={}  (실제 실행 {}회째)",
                    orderId, sku, INVOKE_RESERVE.incrementAndGet());
            return "resv-" + orderId;
        }
    }

    public static class ShippingActivityImpl implements ShippingActivity {
        private static final Logger log = LoggerFactory.getLogger(ShippingActivityImpl.class);
        @Override public String requestShipment(String orderId, String address) {
            log.info("[{}] 배송 요청  (실제 실행 {}회째)",
                    orderId, INVOKE_SHIP.incrementAndGet());
            return "ship-" + orderId;
        }
    }

    // ==================================================================
    // 문제 1·2 — 액티비티 3개 + 타이머 2개
    //   문제 1: 이벤트 수 예측 후 실측 검증
    //   문제 2: 실행 중 kill -9 → 재기동 → 액티비티 로그가 다시 안 찍히는 것 확인
    // ==================================================================
    @WorkflowInterface
    public interface Q12Workflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class Q12WorkflowImpl implements Q12Workflow {
        private static final Logger log = Workflow.getLogger(Q12WorkflowImpl.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);
        private final ShippingActivity shipping =
                Workflow.newActivityStub(ShippingActivity.class, opts);

        @Override
        public String processOrder(OrderRequest req) {
            log.info("[{}] 결제 시작", req.orderId());
            payment.charge(req.orderId(), req.amount());

            Workflow.sleep(Duration.ofSeconds(5));   // ★ 여기서 kill -9

            log.info("[{}] 재고 예약 시작", req.orderId());
            inventory.reserve(req.orderId(), req.sku(), req.qty());

            Workflow.sleep(Duration.ofSeconds(5));

            log.info("[{}] 배송 요청 시작", req.orderId());
            shipping.requestShipment(req.orderId(), req.address());

            return "order-" + req.orderId() + " COMPLETED";
        }
    }

    // ==================================================================
    // 문제 3 — 로거를 바꾸고 로그 줄 수 변화 세기
    //   지금은 LoggerFactory 를 쓰고 있어 리플레이마다 중복 출력됩니다.
    // ==================================================================
    @WorkflowInterface
    public interface Q3Workflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class Q3WorkflowImpl implements Q3Workflow {

        // TODO: 여기에 작성 — 이 로거를 리플레이 안전한 것으로 바꾸세요
        private static final Logger log = LoggerFactory.getLogger(Q3WorkflowImpl.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) {
            log.info("[{}] 결제 시작 amount={}", req.orderId(), req.amount());
            String paymentId = payment.charge(req.orderId(), req.amount());
            log.info("[{}] 결제 완료 {}", req.orderId(), paymentId);

            Workflow.sleep(Duration.ofSeconds(5));   // ★ 여기서 kill -9

            log.info("[{}] 재고 예약 시작", req.orderId());
            String reservationId = inventory.reserve(req.orderId(), req.sku(), req.qty());
            log.info("[{}] 재고 예약 완료 {}", req.orderId(), reservationId);

            return "order-" + req.orderId() + " COMPLETED";
        }
    }

    /* ==================================================================
     * 문제 4 — 지필 문제 (실행하지 않습니다)
     * ------------------------------------------------------------------
     * 아래 히스토리 발췌를 보고, 각 WorkflowTaskCompleted 가 어떤 Command 를
     * 반환했는지 우측에 적으세요.
     *
     * 규칙: WorkflowTaskCompleted 바로 다음에 오는 이벤트들이 그 Command 의 결과다.
     *
     *   ID  Type                          → 반환한 Command
     *   --  ----------------------------    -----------------------------
     *    1  WorkflowExecutionStarted        (해당 없음 — 서버가 기록)
     *    2  WorkflowTaskScheduled
     *    3  WorkflowTaskStarted
     *    4  WorkflowTaskCompleted           → TODO: 여기에 작성
     *    5  ActivityTaskScheduled
     *    6  ActivityTaskStarted
     *    7  ActivityTaskCompleted
     *    8  WorkflowTaskScheduled
     *    9  WorkflowTaskStarted
     *   10  WorkflowTaskCompleted           → TODO: 여기에 작성
     *   11  TimerStarted
     *   12  TimerFired
     *   13  WorkflowTaskScheduled
     *   14  WorkflowTaskStarted
     *   15  WorkflowTaskCompleted           → TODO: 여기에 작성
     *   16  ActivityTaskScheduled
     *   17  ActivityTaskStarted
     *   18  ActivityTaskCompleted
     *   19  WorkflowTaskScheduled
     *   20  WorkflowTaskStarted
     *   21  WorkflowTaskCompleted           → TODO: 여기에 작성
     *   22  WorkflowExecutionCompleted
     *
     * 추가 질문:
     *   (a) 이 워크플로우의 processOrder() 는 총 몇 번 실행됐습니까?
     *       TODO: 여기에 작성
     *   (b) 액티비티는 총 몇 번 실행됐습니까?
     *       TODO: 여기에 작성
     *   (c) 이벤트 11 의 TimerStarted 는 코드의 어느 줄에서 나왔습니까?
     *       TODO: 여기에 작성
     * ================================================================== */

    // ==================================================================
    // 문제 5 — Workflow.isReplaying() 으로 리플레이 횟수 세기
    //   Worker 를 재기동하지 않으면 sticky 캐시 때문에 리플레이가 거의 안 일어납니다.
    //   반드시 kill -9 를 섞어서 실행하세요.
    // ==================================================================
    @WorkflowInterface
    public interface Q5Workflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class Q5WorkflowImpl implements Q5Workflow {
        private static final Logger log = Workflow.getLogger(Q5WorkflowImpl.class);

        private final PaymentActivity payment = Workflow.newActivityStub(
                PaymentActivity.class,
                ActivityOptions.newBuilder()
                        .setStartToCloseTimeout(Duration.ofSeconds(10))
                        .build());

        @Override
        public String processOrder(OrderRequest req) {
            // TODO: 여기에 작성
            //   processOrder 진입 횟수와 그때의 Workflow.isReplaying() 값을
            //   System.out 으로 출력하세요.
            //   (log 가 아니라 System.out 이어야 리플레이 중에도 보입니다)

            payment.charge(req.orderId(), req.amount());
            Workflow.sleep(Duration.ofSeconds(5));   // ★ 여기서 kill -9
            payment.charge(req.orderId() + "-2", req.amount());

            return "order-" + req.orderId() + " COMPLETED";
        }
    }

    // ==================================================================
    // 문제 6 — 위험한 코드가 왜 위험한지 히스토리로 증명하기
    //   아래 워크플로우에는 두 가지 문제가 있습니다.
    //     (a) Thread.sleep(3000)
    //     (b) static int counter++
    //   그대로 실행해 보고, 무슨 일이 벌어지는지 히스토리로 확인한 뒤 고치세요.
    // ==================================================================
    @WorkflowInterface
    public interface Q6Workflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class Q6WorkflowImpl implements Q6Workflow {
        private static final Logger log = Workflow.getLogger(Q6WorkflowImpl.class);

        // 문제의 카운터
        public static int counter = 0;

        private final PaymentActivity payment = Workflow.newActivityStub(
                PaymentActivity.class,
                ActivityOptions.newBuilder()
                        .setStartToCloseTimeout(Duration.ofSeconds(10))
                        .build());

        @Override
        public String processOrder(OrderRequest req) {
            counter++;                             // (b) 문제의 줄
            System.out.println("counter = " + counter);

            payment.charge(req.orderId(), req.amount());

            try {
                Thread.sleep(3000);                // (a) 문제의 줄
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            payment.charge(req.orderId() + "-2", req.amount());

            // TODO: 여기에 작성
            //   (a) Thread.sleep(3000) 을 안전한 것으로 바꾸세요.
            //   (b) counter 를 어떻게 처리해야 할까요? 주석으로 답하세요.
            //       답:
            //   (c) Thread.sleep 이 구체적으로 무엇을 망가뜨립니까? 두 가지를 적으세요.
            //       답 1:
            //       답 2:

            return "order-" + req.orderId() + " counter=" + counter;
        }
    }

    // ==================================================================
    // 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(
                    Q12WorkflowImpl.class, Q3WorkflowImpl.class,
                    Q5WorkflowImpl.class, Q6WorkflowImpl.class);
            worker.registerActivitiesImplementations(
                    new PaymentActivityImpl(),
                    new InventoryActivityImpl(),
                    new ShippingActivityImpl());

            factory.start();
            System.out.println("Exercise Worker started. queue=" + ORDER_TASK_QUEUE);
            System.out.println("kill 실험: kill -9 $(pgrep -f Exercise)");

            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                System.out.println();
                System.out.println("액티비티 실제 실행 횟수");
                System.out.println("  charge          : " + INVOKE_CHARGE.get());
                System.out.println("  reserve         : " + INVOKE_RESERVE.get());
                System.out.println("  requestShipment : " + INVOKE_SHIP.get());
            }));
        }
    }

    // ==================================================================
    // 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" -> System.out.println("문제 4 는 지필 문제입니다. 파일 주석을 보세요.");
                case "5" -> q5(client);
                case "6" -> q6(client);
                default -> System.out.println("문제 번호는 1~6 입니다.");
            }
            System.exit(0);
        }

        static void q1(WorkflowClient client) {
            Q12Workflow w = client.newWorkflowStub(Q12Workflow.class, opts("order-4001"));
            System.out.println("결과: " + w.processOrder(sample("4001")));
            System.out.println();
            System.out.println("예측: " + EXPECTED_EVENT_COUNT + " 이벤트");
            System.out.println("실측: temporal workflow describe -w order-4001");
            System.out.println("      → History Length 를 확인하세요.");
        }

        static void q2(WorkflowClient client) {
            System.out.println("★ Worker 콘솔에 '결제 요청' 이 찍히면 5초 안에:");
            System.out.println("    kill -9 $(pgrep -f Exercise)");
            System.out.println("  그 다음 Worker 를 재기동하세요.");
            System.out.println();
            System.out.println("확인할 것:");
            System.out.println("  (a) 재기동 후 PaymentActivityImpl 로그가 다시 찍히는가?  답: ");
            System.out.println("  (b) INVOKE_CHARGE 값이 늘었는가?                     답: ");
            System.out.println("  (c) ActivityTaskStarted 의 attempt 값은?             답: ");
            System.out.println();

            Q12Workflow w = client.newWorkflowStub(Q12Workflow.class, opts("order-4002"));
            System.out.println("결과: " + w.processOrder(sample("4002")));
        }

        static void q3(WorkflowClient client) {
            System.out.println("Worker 로그를 파일로 받으세요:");
            System.out.println("  java ... Exercise$WorkerMain 2>&1 | tee replay.log");
            System.out.println("실행 중 kill -9 를 한 번 섞고, 끝나면:");
            System.out.println("  grep -c 'Q3WorkflowImpl' replay.log");
            System.out.println();
            System.out.println("  로거 수정 전 줄 수:  (여기에 적으세요)");
            System.out.println("  로거 수정 후 줄 수:  (여기에 적으세요)");
            System.out.println();

            Q3Workflow w = client.newWorkflowStub(Q3Workflow.class, opts("order-4003"));
            System.out.println("결과: " + w.processOrder(sample("4003")));
        }

        static void q5(WorkflowClient client) {
            System.out.println("★ 실행 중 kill -9 를 반드시 섞으세요. 안 그러면 리플레이가 안 일어납니다.");
            Q5Workflow w = client.newWorkflowStub(Q5Workflow.class, opts("order-4005"));
            System.out.println("결과: " + w.processOrder(sample("4005")));
        }

        static void q6(WorkflowClient client) {
            System.out.println("이 실행은 문제가 있습니다. Worker 콘솔의 counter 값과");
            System.out.println("히스토리의 WorkflowTaskTimedOut 유무를 확인하세요.");
            Q6Workflow w = client.newWorkflowStub(Q6Workflow.class, opts("order-4006"));
            System.out.println("결과: " + w.processOrder(sample("4006")));
            System.out.println();
            System.out.println("확인: temporal workflow show -w order-4006");
            System.out.println("      → WorkflowTaskTimedOut 이 있습니까?");
        }

        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 = 4001; i <= 4006; i++) {
                String id = "order-" + i;
                try {
                    client.newUntypedWorkflowStub(id).terminate("exercise cleanup");
                    System.out.println("terminated " + id);
                } catch (Exception e) {
                    System.out.println("skip " + id);
                }
            }
            System.exit(0);
        }
    }
}

Solution.java

6문제의 정답과 긴 해설 주석입니다. 문제를 풀어 본 뒤에 여세요.

  • 정답 133 이벤트입니다. 기본 5 + 액티비티 3개 × 6 + 타이머 2개 × 5 = 5 + 18 + 10 = 33. 타이머 하나가 5 이벤트(TimerStarted 1 + TimerFired 1 + 뒤따르는 WorkflowTask* 3)인 이유를 이벤트별로 분해해 설명합니다.
  • 정답 2 의 핵심은 "액티비티 로그 부재"입니다. 재기동 후 PaymentActivityImpl 로그가 없고, INVOKE_COUNT 도 그대로이며, ActivityTaskStartedattempt 도 1인 세 가지 증거를 각각 어떻게 확인하는지 명령까지 적어 뒀습니다.
  • 정답 3 은 로그 줄 수를 8줄 → 4줄로 줄입니다. Workflow.getLogger 가 돌려주는 ReplayAwareLoggerWorkflow.isReplaying() 을 확인해 리플레이 중 출력을 버린다는 내부 동작까지 설명하고, 팀 규칙으로 강제하는 ArchUnit 규칙 예시를 붙여 뒀습니다.
  • 정답 4 는 Command 매핑표입니다. ScheduleActivityTask / StartTimer / CompleteWorkflowExecution 세 가지가 각각 어느 이벤트로 나타나는지, 그리고 "WorkflowTaskCompleted 다음 이벤트를 보면 된다"는 규칙이 왜 항상 성립하는지 설명합니다.
  • 정답 6 이 가장 깁니다. Thread.sleep(3000) 은 두 가지를 동시에 망가뜨립니다. ① 워크플로우 코루틴 스레드를 진짜로 블로킹해 workflowTaskTimeout 10초를 위협하고, 리플레이 때마다 다시 3초씩 자므로 히스토리가 길어질수록 선형으로 느려집니다. ② static int counter++ 는 리플레이마다 증가해 실제 실행 횟수와 무관한 값이 됩니다. 정답은 각각 Workflow.sleep(Duration.ofSeconds(3)) 과 "액티비티로 이동"이며, 왜 Workflow.sleep 은 안전한지(타이머 Command 를 만들 뿐 스레드를 안 잡는다)를 히스토리로 보여 줍니다.
package com.example.order;

/*
 * ============================================================================
 * Step 02 — 핵심 개념과 실행 모델 : Solution.java  (정답 + 해설)
 * ============================================================================
 *
 * Exercise.java 를 먼저 풀어 본 뒤에 여세요.
 *
 * 실행 방법
 *   [터미널 1] java com.example.order.Solution$WorkerMain
 *   [터미널 2] java com.example.order.Solution$StarterMain <문제번호>
 *   kill 실험: kill -9 $(pgrep -f Solution)
 * ============================================================================
 */

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.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;
import java.util.concurrent.atomic.AtomicInteger;

public class Solution {

    public static final String ORDER_TASK_QUEUE = "ORDER_TASK_QUEUE";

    /* ==================================================================
     * 정답 1 — 33 이벤트
     * ------------------------------------------------------------------
     * 산식:
     *   기본 골격                     5
     *   액티비티 3개 x 6            = 18
     *   타이머 2개 x 5              = 10
     *                               ----
     *                                 33
     *
     * 왜 액티비티 1개가 6 이벤트인가 (Step 01 복습):
     *   ActivityTaskScheduled / Started / Completed        3
     *   + 결과를 코드에 전달하기 위한 WorkflowTask 3종        3
     *
     * 왜 타이머 1개가 5 이벤트인가:
     *   TimerStarted                                        1
     *   TimerFired                                          1
     *   + 만료를 코드에 전달하기 위한 WorkflowTask 3종        3
     *   → 타이머는 "Started" 와 "Fired" 만 있고 Activity 처럼
     *     "Started"(=워커가 가져감) 단계가 없으므로 액티비티보다 1개 적다.
     *     타이머는 서버가 직접 관리하며 Worker 가 개입하지 않기 때문이다.
     *
     * 전체 히스토리:
     *    1 WorkflowExecutionStarted
     *    2- 4 WorkflowTask x3
     *    5- 7 ActivityTask x3   (charge)
     *    8-10 WorkflowTask x3
     *   11-12 Timer x2          (5초)
     *   13-15 WorkflowTask x3
     *   16-18 ActivityTask x3   (reserve)
     *   19-21 WorkflowTask x3
     *   22-23 Timer x2          (5초)
     *   24-26 WorkflowTask x3
     *   27-29 ActivityTask x3   (requestShipment)
     *   30-32 WorkflowTask x3
     *   33    WorkflowExecutionCompleted
     *
     * 검증: temporal workflow describe -w order-5001 → History Length 33
     * ================================================================== */
    public static final int EXPECTED_EVENT_COUNT = 33;

    public static final AtomicInteger INVOKE_CHARGE = new AtomicInteger();
    public static final AtomicInteger INVOKE_RESERVE = new AtomicInteger();
    public static final AtomicInteger INVOKE_SHIP = new AtomicInteger();

    public record OrderRequest(
            String orderId, String customerId, String sku, int qty, long amount, String address) {}

    // ==================================================================
    // 액티비티
    // ==================================================================
    @ActivityInterface
    public interface PaymentActivity {
        @ActivityMethod String charge(String orderId, long amount);
    }

    @ActivityInterface
    public interface InventoryActivity {
        @ActivityMethod String reserve(String orderId, String sku, int qty);
    }

    @ActivityInterface
    public interface ShippingActivity {
        @ActivityMethod String requestShipment(String orderId, String address);
    }

    @ActivityInterface
    public interface MetricsActivity {
        @ActivityMethod void countOrderStarted(String orderId);
    }

    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, INVOKE_CHARGE.incrementAndGet());
            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={}  (실제 실행 {}회째)",
                    orderId, sku, INVOKE_RESERVE.incrementAndGet());
            return "resv-" + orderId;
        }
    }

    public static class ShippingActivityImpl implements ShippingActivity {
        private static final Logger log = LoggerFactory.getLogger(ShippingActivityImpl.class);
        @Override public String requestShipment(String orderId, String address) {
            log.info("[{}] 배송 요청  (실제 실행 {}회째)",
                    orderId, INVOKE_SHIP.incrementAndGet());
            return "ship-" + orderId;
        }
    }

    // 정답 6(b) — 카운터는 액티비티로 뺀다. 그래야 정확히 한 번 실행된다.
    public static class MetricsActivityImpl implements MetricsActivity {
        private static final Logger log = LoggerFactory.getLogger(MetricsActivityImpl.class);
        public static final AtomicInteger COUNTER = new AtomicInteger();

        @Override public void countOrderStarted(String orderId) {
            log.info("[{}] 주문 시작 카운트 = {}", orderId, COUNTER.incrementAndGet());
            // 실제로는 meterRegistry.counter("order.started").increment()
        }
    }

    /* ==================================================================
     * 정답 2 — 리플레이 때 액티비티는 재호출되지 않는다
     * ------------------------------------------------------------------
     * 확인 결과:
     *
     *   (a) 재기동 후 PaymentActivityImpl 로그가 다시 찍히는가?
     *       → 아니다. 한 줄도 안 찍힌다.
     *          워크플로우 코드는 payment.charge(...) 줄을 분명히 다시 지나가지만,
     *          그 호출은 액티비티를 실행하지 않고 히스토리의
     *          ActivityTaskCompleted.result 에서 "pay-4002" 를 꺼내 반환한다.
     *
     *   (b) INVOKE_CHARGE 값이 늘었는가?
     *       → 재기동으로 JVM 이 새로 떴으니 0 부터 다시 시작하지만,
     *          재기동 후 charge 가 다시 실행되지 않으므로 0 인 채로 남는다.
     *          (reserve/requestShipment 만 1 이 된다)
     *
     *   (c) ActivityTaskStarted 의 attempt 값은?
     *       → 전부 1.
     *          temporal workflow show -w order-5002 --output json \
     *            | jq '[.events[] | select(.eventType=="ActivityTaskStarted")
     *                   | .activityTaskStartedEventAttributes.attempt]'
     *          → [1, 1, 1]
     *          attempt 가 2 이상이면 그것은 "리플레이"가 아니라 "재시도"다.
     *          둘은 완전히 다른 개념이다:
     *            리플레이 = 워크플로우 코드 재실행. 액티비티는 실행 안 됨.
     *            재시도   = 액티비티가 실패해서 실제로 다시 실행됨.
     *
     *   (d) 최종 History Length 는?
     *       → 33. kill 하지 않은 order-5001 과 완전히 같다.
     *          Worker 가 죽었다는 사실은 히스토리에 흔적조차 남지 않는다.
     *          걸린 시간만 10초 → 91초로 늘어난다.
     * ================================================================== */
    @WorkflowInterface
    public interface Q12Workflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class Q12WorkflowImpl implements Q12Workflow {
        private static final Logger log = Workflow.getLogger(Q12WorkflowImpl.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);
        private final ShippingActivity shipping =
                Workflow.newActivityStub(ShippingActivity.class, opts);

        @Override
        public String processOrder(OrderRequest req) {
            log.info("[{}] 결제 시작", req.orderId());
            payment.charge(req.orderId(), req.amount());
            Workflow.sleep(Duration.ofSeconds(5));
            log.info("[{}] 재고 예약 시작", req.orderId());
            inventory.reserve(req.orderId(), req.sku(), req.qty());
            Workflow.sleep(Duration.ofSeconds(5));
            log.info("[{}] 배송 요청 시작", req.orderId());
            shipping.requestShipment(req.orderId(), req.address());
            return "order-" + req.orderId() + " COMPLETED";
        }
    }

    /* ==================================================================
     * 정답 3 — Workflow.getLogger 로 바꾸면 8줄 → 4줄
     * ------------------------------------------------------------------
     * 측정 (실행 중 kill -9 한 번 섞은 경우):
     *
     *   grep -c 'Q3WorkflowImpl' replay.log
     *     LoggerFactory 사용      : 8
     *     Workflow.getLogger 사용 : 4
     *
     * 왜 8줄이 되는가:
     *   코드에 log.info 가 4개 있는데, 워크플로우가 3번 실행되면서
     *   이미 지나간 로그가 반복 출력되기 때문이다.
     *     1회차 Workflow Task : "결제 시작"                       (1줄)
     *     2회차 Workflow Task : "결제 시작"(중복) + "결제 완료"     (2줄)
     *     3회차(재기동)       : "결제 시작"(중복) + "결제 완료"(중복)
     *                          + "재고 예약 시작"                  (3줄)
     *     4회차               : ... + "재고 예약 완료"             (2줄)
     *                                                    합계 8줄
     *
     * 내부 동작:
     *   Workflow.getLogger 는 ReplayAwareLogger 라는 래퍼를 돌려준다.
     *   대략 이런 구조다:
     *
     *     public void info(String msg, Object... args) {
     *         if (!Workflow.isReplaying()) {
     *             delegate.info(msg, args);
     *         }
     *     }
     *
     *   즉 "이미 히스토리에 있는 구간을 다시 지나가는 중"이면 출력을 버린다.
     *   그래서 각 사건이 정확히 한 번씩만 기록된다.
     *
     * 주의 — 액티비티에서는 반대다:
     *   액티비티 구현체에서 Workflow.getLogger 를 쓰면 워크플로우 컨텍스트 밖이라
     *   예외가 난다. 액티비티는 평범한 LoggerFactory 를 쓴다.
     *
     * 팀 규칙으로 강제하기 (ArchUnit 예시):
     *
     *   @ArchTest
     *   static final ArchRule 워크플로우는_Workflow_getLogger_를_쓴다 =
     *       noClasses()
     *           .that().haveSimpleNameEndingWith("WorkflowImpl")
     *           .should().callMethod(LoggerFactory.class, "getLogger", Class.class)
     *           .because("워크플로우 로그는 리플레이마다 중복 출력됩니다. "
     *                  + "Workflow.getLogger() 를 사용하세요.");
     *
     * 더 넓은 원칙:
     *   로거만의 문제가 아니다. 워크플로우 코드 안의 모든 관찰 가능성 작업이
     *   리플레이마다 반복된다 — 메트릭 카운터, 슬랙 알림, System.out.
     *   판단 기준은 하나다: "이 줄이 100번 반복 실행돼도 괜찮은가?"
     *   안 괜찮다면 액티비티로 빼라.
     * ================================================================== */
    @WorkflowInterface
    public interface Q3Workflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class Q3WorkflowImpl implements Q3Workflow {

        // 정답: Workflow.getLogger
        private static final Logger log = Workflow.getLogger(Q3WorkflowImpl.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) {
            log.info("[{}] 결제 시작 amount={}", req.orderId(), req.amount());
            String paymentId = payment.charge(req.orderId(), req.amount());
            log.info("[{}] 결제 완료 {}", req.orderId(), paymentId);

            Workflow.sleep(Duration.ofSeconds(5));

            log.info("[{}] 재고 예약 시작", req.orderId());
            String reservationId = inventory.reserve(req.orderId(), req.sku(), req.qty());
            log.info("[{}] 재고 예약 완료 {}", req.orderId(), reservationId);

            return "order-" + req.orderId() + " COMPLETED";
        }
    }

    /* ==================================================================
     * 정답 4 — Command 매핑
     * ------------------------------------------------------------------
     *   ID  Type                          → 반환한 Command
     *   --  ----------------------------    -----------------------------
     *    4  WorkflowTaskCompleted           → ScheduleActivityTask(charge)
     *   10  WorkflowTaskCompleted           → StartTimer(5s)
     *   15  WorkflowTaskCompleted           → ScheduleActivityTask(reserve)
     *   21  WorkflowTaskCompleted           → CompleteWorkflowExecution
     *
     * 규칙이 왜 항상 성립하는가:
     *   Worker 는 Workflow Task 를 처리한 뒤 RespondWorkflowTaskCompleted 라는
     *   단 하나의 gRPC 호출로 (a) "처리 완료" 와 (b) "그 결과 만들어진 Command 목록" 을
     *   함께 보낸다. 서버는 이것을 받아
     *     ① WorkflowTaskCompleted 이벤트를 먼저 append
     *     ② 그 다음 각 Command 를 대응하는 Event 로 변환해 순서대로 append
     *   한다. 그래서 WorkflowTaskCompleted 바로 뒤에 오는 이벤트들이
     *   반드시 그 Task 가 반환한 Command 의 결과가 된다.
     *
     * Command → Event 대응표:
     *   ScheduleActivityTask              → ActivityTaskScheduled
     *   StartTimer                        → TimerStarted
     *   CompleteWorkflowExecution         → WorkflowExecutionCompleted
     *   FailWorkflowExecution             → WorkflowExecutionFailed
     *   StartChildWorkflowExecution       → StartChildWorkflowExecutionInitiated
     *   ContinueAsNewWorkflowExecution    → WorkflowExecutionContinuedAsNew
     *   RequestCancelActivityTask         → ActivityTaskCancelRequested
     *
     * 추가 질문 정답:
     *   (a) processOrder() 는 총 몇 번 실행됐나?
     *       → 4번. WorkflowTaskCompleted 가 4개(4, 10, 15, 21)이기 때문이다.
     *          Workflow Task 하나가 곧 processOrder() 한 번 전체 실행이다.
     *
     *   (b) 액티비티는 총 몇 번 실행됐나?
     *       → 2번. ActivityTaskStarted 가 2개(6, 17)다.
     *          코드는 4번 돌았지만 액티비티는 2번만 실행됐다 — 이 비대칭이 핵심이다.
     *
     *   (c) 이벤트 11 의 TimerStarted 는 코드의 어느 줄에서 나왔나?
     *       → Workflow.sleep(Duration.ofSeconds(5)) 줄.
     *          Workflow.sleep 은 실제로 스레드를 재우지 않는다. StartTimer Command 를
     *          만들고 코루틴을 yield 시킬 뿐이다. 5초는 서버가 세고, 만료되면
     *          TimerFired 를 기록하고 새 Workflow Task 를 스케줄한다.
     *          이것이 "워크플로우가 30일을 자도 리소스를 안 먹는" 이유다.
     * ================================================================== */

    /* ==================================================================
     * 정답 5 — Workflow.isReplaying() 으로 리플레이 관찰
     * ------------------------------------------------------------------
     * kill 을 섞어 실행한 출력 예:
     *
     *   >> 진입 #1  isReplaying=false     ← 첫 실행
     *   >> 진입 #2  isReplaying=true      ← charge 결과를 받아 이어감 (앞부분은 리플레이)
     *   --- kill -9 / 재기동 ---
     *   >> 진입 #1  isReplaying=true      ← 새 JVM. 카운터도 0부터. full replay
     *   >> 진입 #2  isReplaying=true
     *
     * 읽는 법:
     *   isReplaying 은 "지금 이 코드 라인이 히스토리에 이미 기록된 구간인가" 를 나타낸다.
     *   같은 processOrder() 실행 안에서도 앞부분은 true, 새로 진행하는 뒷부분은 false 가 된다.
     *   그래서 "진입 시점의 isReplaying" 은 "이번 Workflow Task 가 리플레이로 시작했는가" 를
     *   의미할 뿐, 실행 전체의 성격을 말하지 않는다.
     *
     * 왜 kill 을 섞어야 하는가:
     *   Sticky Execution 캐시 때문이다. Worker 가 워크플로우 인스턴스를 메모리에
     *   들고 있으면 다음 Workflow Task 는 리플레이 없이 "마지막 지점부터" 이어서 실행된다.
     *   즉 정상 경로에서는 리플레이가 거의 일어나지 않는다.
     *   캐시를 날려야(= Worker 재기동) full replay 를 관찰할 수 있다.
     *
     * 주의 — 카운터로 뭔가를 판단하지 말 것:
     *   진입 횟수는 캐시 히트 여부, Worker 재기동 타이밍, 서버 부하에 따라 매번 달라진다.
     *   "몇 번 실행됐는지"에 의존하는 로직은 어떤 형태든 잘못된 설계다.
     * ================================================================== */
    @WorkflowInterface
    public interface Q5Workflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class Q5WorkflowImpl implements Q5Workflow {
        private static final AtomicInteger ENTER = new AtomicInteger();

        private final PaymentActivity payment = Workflow.newActivityStub(
                PaymentActivity.class,
                ActivityOptions.newBuilder()
                        .setStartToCloseTimeout(Duration.ofSeconds(10))
                        .build());

        @Override
        public String processOrder(OrderRequest req) {
            // log 가 아니라 System.out 이어야 리플레이 중에도 보인다.
            // (Workflow.getLogger 는 리플레이 중 출력을 억제하므로 관찰 목적에 안 맞는다)
            System.out.printf("  >> 진입 #%d  isReplaying=%s%n",
                    ENTER.incrementAndGet(), Workflow.isReplaying());

            payment.charge(req.orderId(), req.amount());
            Workflow.sleep(Duration.ofSeconds(5));
            payment.charge(req.orderId() + "-2", req.amount());

            return "order-" + req.orderId() + " COMPLETED";
        }
    }

    /* ==================================================================
     * 정답 6 — Thread.sleep 과 static 카운터
     * ------------------------------------------------------------------
     * (a) Thread.sleep(3000) → Workflow.sleep(Duration.ofSeconds(3))
     * (b) counter++          → 액티비티로 이동 (MetricsActivity)
     *
     * (c) Thread.sleep 이 구체적으로 무엇을 망가뜨리는가 — 두 가지:
     *
     *   답 1) 워크플로우 코루틴 스레드를 진짜로 블로킹한다.
     *     Workflow Task 는 workflowTaskTimeout(기본 10초) 안에 응답해야 한다.
     *     3초를 자면 예산의 30%를 그냥 태운다. 액티비티가 여러 개라 리플레이가
     *     길어지면 10초를 넘겨 WorkflowTaskTimedOut 이 나고, 서버가 그 Task 를
     *     재스케줄해 처음부터 다시 시작한다. 그러면 또 3초를 자고... 무한 루프가 된다.
     *
     *     실제 히스토리:
     *       temporal workflow show -w order-4006
     *          ...
     *          14  WorkflowTaskTimedOut       ← 10초 초과
     *          15  WorkflowTaskScheduled      ← 재스케줄
     *          16  WorkflowTaskStarted
     *          17  WorkflowTaskTimedOut       ← 또 초과
     *          ...
     *
     *     또한 Worker 의 워크플로우 스레드는 유한하다(기본 600).
     *     동시 실행 중인 워크플로우가 600개를 넘고 각각 3초씩 자면
     *     "Timeout expired while waiting for a free workflow thread" 가 난다.
     *     즉 한 워크플로우의 Thread.sleep 이 워커 전체를 마비시킨다.
     *
     *   답 2) 리플레이할 때마다 다시 3초를 잔다.
     *     Workflow.sleep 은 히스토리에 TimerStarted/TimerFired 로 남으므로
     *     리플레이 때 "이미 지났음"으로 즉시 통과한다.
     *     Thread.sleep 은 히스토리에 아무 흔적도 남기지 않으므로
     *     리플레이할 때마다 실제로 3초를 다시 잔다.
     *     Workflow Task 가 10번 돌면 30초를 낭비한다. 히스토리가 길어질수록
     *     선형으로 느려지며, 결국 workflowTaskTimeout 을 넘긴다.
     *
     * Workflow.sleep 은 왜 안전한가:
     *   스레드를 재우지 않는다. StartTimer Command 를 만들고 코루틴을 yield 한 뒤
     *   Workflow Task 를 즉시 완료한다. 워크플로우 인스턴스는 캐시에서 축출되고
     *   Worker 는 아무 리소스도 붙들지 않는다. 3초 뒤 서버가 TimerFired 를 기록하고
     *   새 Workflow Task 를 스케줄한다.
     *   이 구조 덕분에 "30일 뒤에 리마인드" 같은 워크플로우가 리소스 0 으로 대기한다.
     *
     * counter 는 왜 액티비티로 빼야 하는가:
     *   static 필드는 리플레이마다 증가하므로 실제 실행 횟수와 무관한 값이 된다.
     *   Worker 를 재기동하면 JVM 이 새로 떠서 0 으로 리셋되기까지 한다.
     *   즉 이 값은 아무것도 의미하지 않는다.
     *   게다가 워크플로우 인스턴스 간에 공유되는 가변 static 상태는
     *   결정성 자체를 깨뜨린다 — 같은 히스토리로 리플레이해도 다른 값이 나온다.
     *   정답: metrics.countOrderStarted(orderId) 로 액티비티에 위임한다.
     *         액티비티는 리플레이되지 않으므로 정확히 한 번 실행된다.
     * ================================================================== */
    @WorkflowInterface
    public interface Q6Workflow {
        @WorkflowMethod String processOrder(OrderRequest req);
    }

    public static class Q6WorkflowImpl implements Q6Workflow {
        private static final Logger log = Workflow.getLogger(Q6WorkflowImpl.class);

        private final ActivityOptions opts = ActivityOptions.newBuilder()
                .setStartToCloseTimeout(Duration.ofSeconds(10))
                .build();
        private final PaymentActivity payment =
                Workflow.newActivityStub(PaymentActivity.class, opts);

        // 정답 (b): 카운터를 액티비티로 위임
        private final MetricsActivity metrics =
                Workflow.newActivityStub(MetricsActivity.class, opts);

        @Override
        public String processOrder(OrderRequest req) {
            // static counter++ 대신 액티비티 호출.
            // 리플레이되지 않으므로 정확히 한 번만 증가한다.
            metrics.countOrderStarted(req.orderId());

            payment.charge(req.orderId(), req.amount());

            // 정답 (a): Thread.sleep -> Workflow.sleep
            //   스레드를 블로킹하지 않고, 히스토리에 TimerStarted/TimerFired 로 남아
            //   리플레이 때 즉시 통과한다.
            Workflow.sleep(Duration.ofSeconds(3));

            payment.charge(req.orderId() + "-2", req.amount());

            log.info("[{}] 완료", req.orderId());
            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(
                    Q12WorkflowImpl.class, Q3WorkflowImpl.class,
                    Q5WorkflowImpl.class, Q6WorkflowImpl.class);
            worker.registerActivitiesImplementations(
                    new PaymentActivityImpl(),
                    new InventoryActivityImpl(),
                    new ShippingActivityImpl(),
                    new MetricsActivityImpl());

            factory.start();
            System.out.println("Solution Worker started. queue=" + ORDER_TASK_QUEUE);

            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                System.out.println();
                System.out.println("--------------------------------------------");
                System.out.println(" 액티비티 실제 실행 횟수 (리플레이해도 안 늘어남)");
                System.out.println("   charge          : " + INVOKE_CHARGE.get());
                System.out.println("   reserve         : " + INVOKE_RESERVE.get());
                System.out.println("   requestShipment : " + INVOKE_SHIP.get());
                System.out.println("   order.started   : " + MetricsActivityImpl.COUNTER.get());
                System.out.println("--------------------------------------------");
            }));
        }
    }

    // ==================================================================
    // 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" -> System.out.println("정답 4 는 파일 주석의 Command 매핑표를 보세요.");
                case "5" -> q5(client);
                case "6" -> q6(client);
                default -> System.out.println("문제 번호는 1~6 입니다.");
            }
            System.exit(0);
        }

        static void q1(WorkflowClient client) {
            Q12Workflow w = client.newWorkflowStub(Q12Workflow.class, opts("order-5001"));
            System.out.println("결과: " + w.processOrder(sample("5001")));
            System.out.println("정답: " + EXPECTED_EVENT_COUNT + " 이벤트");
            System.out.println("검증: temporal workflow describe -w order-5001");
            System.out.println("      → History Length 33");
        }

        static void q2(WorkflowClient client) {
            System.out.println("★ '결제 요청' 로그가 뜨면 5초 안에: kill -9 $(pgrep -f Solution)");
            Q12Workflow w = client.newWorkflowStub(Q12Workflow.class, opts("order-5002"));
            long t0 = System.currentTimeMillis();
            System.out.println("결과: " + w.processOrder(sample("5002")));
            System.out.println("소요: " + (System.currentTimeMillis() - t0) + "ms");
            System.out.println();
            System.out.println("검증 명령:");
            System.out.println("  temporal workflow describe -w order-5002    → History Length 33");
            System.out.println("  temporal workflow show -w order-5002 --output json \\");
            System.out.println("    | jq '[.events[] | select(.eventType==\"ActivityTaskStarted\")");
            System.out.println("           | .activityTaskStartedEventAttributes.attempt]'");
            System.out.println("                                              → [1,1,1]");
        }

        static void q3(WorkflowClient client) {
            Q3Workflow w = client.newWorkflowStub(Q3Workflow.class, opts("order-5003"));
            System.out.println("결과: " + w.processOrder(sample("5003")));
            System.out.println("grep -c 'Q3WorkflowImpl' replay.log  → 4 (LoggerFactory 였다면 8)");
        }

        static void q5(WorkflowClient client) {
            System.out.println("★ 실행 중 kill -9 를 섞으세요. isReplaying 값 변화를 관찰합니다.");
            Q5Workflow w = client.newWorkflowStub(Q5Workflow.class, opts("order-5005"));
            System.out.println("결과: " + w.processOrder(sample("5005")));
        }

        static void q6(WorkflowClient client) {
            Q6Workflow w = client.newWorkflowStub(Q6Workflow.class, opts("order-5006"));
            System.out.println("결과: " + w.processOrder(sample("5006")));
            System.out.println();
            System.out.println("검증: temporal workflow show -w order-5006");
            System.out.println("  → WorkflowTaskTimedOut 이 없다. Workflow.sleep 은 스레드를 안 잡는다.");
            System.out.println("  → TimerStarted / TimerFired 가 히스토리에 남아 리플레이 때 즉시 통과한다.");
            System.out.println("  → order.started 카운터는 정확히 1 이다 (액티비티로 뺐으므로).");
        }

        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 = 5001; i <= 5006; 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);
        }
    }
}