Skip to content
Published on

Temporal Worker Versioning GA — 리플레이 모델이 만든 배포 문제, 두 세대를 버리고 나온 세 번째 답

공유하기
Authors

들어가며 — 내구 실행의 청구서는 배포에서 날아온다

내구 실행(durable execution) 엔진들의 전반적인 비교는 이미 5월의 딥다이브에서 다뤘습니다. 이 글은 그 반대편, 엔진 하나를 좁고 깊게 팝니다 — Temporal이 2026년 3월 30일 Worker Versioning의 GA를 발표했고, OSS 서버에서는 v1.31.0(2026년 4월 29일)이 Worker Deployment API의 GA를 명시했습니다.

"버저닝 기능 GA"라고 하면 하품이 나올 수 있는데, 이건 그런 뉴스가 아닙니다. 첫 프리뷰(2023년 6월)부터 GA까지 2년 9개월이 걸렸고, 그 사이 두 세대의 API가 통째로 버려졌습니다. GA 발표문의 첫 문단에서 벤더 스스로 이렇게 인정합니다 — 오래 도는 워크플로에 새 코드를 배포하는 일은 내구 실행에서 늘 가장 까다로운 문제 중 하나였다고. 왜 이 문제가 그렇게 어려운지는 엔진의 핵심 설계, 즉 결정론적 리플레이에서 곧바로 따라 나옵니다. 그 메커니즘부터 봅시다.

리플레이가 배포를 깨는 메커니즘

Temporal 워크플로의 내구성은 이렇게 작동합니다. 워크플로 코드가 액티비티 실행·타이머·자식 워크플로 같은 커맨드를 낼 때마다 서버가 이벤트 이력(event history)에 기록하고, 워커가 죽거나 교체되면 새 워커가 그 이력을 처음부터 리플레이해서 코드의 실행 상태를 복원합니다. 이 마술이 성립하려면 전제가 하나 필요합니다 — 같은 이력에 대해 코드가 항상 같은 커맨드를 내야 한다는 것. 공식 문서는 이를 명시적으로 요구합니다: 워크플로 코드는 결정론적이어야 하고, API 호출이나 DB 쿼리 같은 비결정 작업은 액티비티로 빼야 하며, 액티비티는 Temporal이 알아서 재시도한다고요.

그런데 이 전제는 코드가 바뀌는 순간 무너집니다.

v1 코드: A 실행 → 타이머 → B 실행     (이 순서로 이력이 이미 기록됨)
v2 코드: A 실행 → C 실행 → 타이머 → B (새 액티비티 C를 중간에 추가)

v1으로 시작한 실행을 v2 워커가 리플레이하면:
  이력의 다음 이벤트는 "타이머 시작"인데
  코드는 "액티비티 C 스케줄" 커맨드를 내놓음
  → 비결정론(non-determinism) 에러, 워크플로 태스크 실패

짧은 워크플로만 있다면 별 문제가 아닙니다. 배포 전에 다 끝나니까요. 하지만 Temporal의 존재 이유가 바로 며칠, 몇 주, 몇 년씩 도는 워크플로입니다. 즉 "배포 시점에 실행 중인 워크플로는 없다"는 가정이 구조적으로 성립하지 않고, 리플레이라는 내구성의 원천이 그대로 배포의 지뢰가 됩니다. 이건 Temporal의 버그가 아니라 리플레이 기반 내구 실행 모델 전체가 공유하는 제약입니다.

지금까지의 답 — 패칭, 그리고 그 비용

이 문제의 전통적인 답은 패칭(patching)입니다. 문서의 표현을 빌리면 피처 플래그와 비슷하게, 코드 안에 "이 실행이 해당 변경 이전에 시작했는가"를 묻는 논리 분기를 심는 겁니다.

from datetime import timedelta
from temporalio import workflow


@workflow.defn
class MyWorkflow:
    @workflow.run
    async def run(self) -> None:
        if workflow.patched("use-new-activity"):
            # 이 패치 이후에 시작한 실행
            self._result = await workflow.execute_activity(
                post_patch_activity,
                schedule_to_close_timeout=timedelta(minutes=5),
            )
        else:
            # 패치 이전에 시작해 아직 도는 실행 — 옛 경로 유지
            self._result = await workflow.execute_activity(
                pre_patch_activity,
                schedule_to_close_timeout=timedelta(minutes=5),
            )

작동은 합니다. 문제는 누적입니다. GA 발표문이 정확히 짚습니다 — 워크플로가 며칠·몇 주·몇 년씩 돌 수 있기 때문에, 패치가 실제 코드 복잡도와 인지 부담으로 쌓인다고. 분기를 지우려면 옛 경로로 도는 실행이 전부 끝났는지 확인하고 사용 중단(deprecate) 절차를 밟아야 하는데 이것도 수동입니다. 요컨대 패칭은 배포 문제를 워크플로 코드 안의 조건문으로 흡수하는 방식이고, 변경이 잦은 코드베이스에서는 그 조건문이 곧 기술 부채가 됩니다.

Worker Versioning은 접근을 뒤집습니다 — 버전 분기를 코드 안에 심는 대신, 배포 단위로 워커를 통째로 버저닝하고 실행 중인 워크플로를 시작한 버전의 워커에 남겨 두는 겁니다. 아이디어는 단순한데, 이 단순한 아이디어의 API를 확정하는 데 3년이 걸렸습니다.

3년, 3세대 — 버저닝 API 폐기의 연대기

릴리스 노트를 시간순으로 따라가면 이 기능의 난도가 그대로 보입니다.

2023-06-23  v1.21.0  V1 프리뷰: Build ID를 "버전 세트"로 묶는 방식
                     UpdateWorkerBuildIdCompatibility / GetWorkerTaskReachability
2024-05-31  v1.24.0  V1의 버전 세트 개념 폐기 선언
                     → V2 "버저닝 룰"(assignment rules)로 대체, 실험 단계
2024-12     (프리릴리스) Deployment 기반 3번째 설계 등장
2025-06-27  v1.28.0  Worker Deployment API 공개 미리보기 승격
                     V1·V2 API 일괄 deprecated
                     2024-12 프리릴리스 API는 지원 종료
2026-03-30  블로그   GA 공식 발표 (전 SDK 대상)
2026-04-29  v1.31.0  OSS 서버에서 GA 명시, V1·V2는 sunset
                     → 차기 버전 v1.32.0에서 제거 예고

하나씩 짚으면 이렇습니다. v1.21.0(2023년 6월)이 첫 프리뷰였습니다 — 워커에 Build ID를 달고, 어떤 빌드끼리 호환되는지를 UpdateWorkerBuildIdCompatibility로 "버전 세트"에 등록하는 방식. v1.24.0(2024년 5월)은 릴리스 노트에 버전 세트 개념 자체가 폐기되고 더 유연한 "버저닝 룰"로 대체된다고 적었습니다. 그리고 v1.28.0(2025년 6월)의 노트에는 "2024년 12월 프리릴리스"로 나왔던 Deployment 기반 API들(DescribeDeployment 등)마저 지원 종료로 표기되고, 지금의 Worker Deployment API가 공개 미리보기로 올라옵니다. 이때 V1·V2 API가 한꺼번에 deprecated 됐고, v1.27.x에서 버저닝을 쓰던 사용자에게는 업그레이드 전에 기존 Deployment를 전부 삭제하라는 호환성 파괴 공지까지 붙었습니다.

그리고 v1.31.0(2026년 4월)이 SetWorkerDeploymentCurrentVersion, SetWorkerDeploymentRampingVersion, DescribeWorkerDeployment 등 9개 API를 GA로 선언하면서, V1·V2 API는 다음 서버 버전인 v1.32.0에서 제거된다고 예고했습니다(이 글을 쓰는 시점의 최신 서버는 v1.31.2로, 제거는 아직 집행 전입니다). 흥미로운 건 GA와 동시에 CreateWorkerDeployment 같은 실험 단계 API가 새로 추가됐다는 점입니다 — 표면이 아직도 움직이고 있다는 뜻입니다.

여기서 두 가지를 읽을 수 있습니다. 첫째, 리플레이 모델 위에서 "안전한 배포"의 API를 설계하는 일은 한 벤더가 두 번 갈아엎을 만큼 어렵습니다. 둘째, 실무적 경고 — self-hosted에서 V1(build ID 호환성)이나 V2(버저닝 룰) API 위에 배포 자동화를 쌓아 뒀다면, 마이그레이션은 선택이 아니라 v1.32.0이라는 마감이 있는 작업입니다.

GA된 모델 — Deployment, Pinned, Auto-Upgrade

살아남은 세 번째 설계의 구조는 이렇습니다(개념 문서 기준).

Worker Deployment는 서비스 단위의 논리적 묶음이고(이름을 가짐), Worker Deployment Version은 그 서비스의 한 이터레이션으로 배포 이름 + Build ID로 식별됩니다. 워커는 폴링을 시작할 때 자기 버전을 서버에 보고합니다. 워커 쪽 설정은 이렇게 생겼습니다(프로덕션 가이드의 Python 예시).

from temporalio.common import WorkerDeploymentVersion, VersioningBehavior
from temporalio.worker import Worker, WorkerDeploymentConfig

worker = Worker(
    client,
    task_queue="mytaskqueue",
    workflows=workflows,
    activities=activities,
    deployment_config=WorkerDeploymentConfig(
        version=WorkerDeploymentVersion(
            deployment_name="llm_srv", build_id=my_env.build_id
        ),
        use_worker_versioning=True,
        default_versioning_behavior=VersioningBehavior.UNSPECIFIED,
    ),
)

핵심은 워크플로 타입마다 선언하는 Versioning Behavior 두 가지입니다.

@workflow.defn(versioning_behavior=VersioningBehavior.PINNED)
class OrderWorkflow:
    ...
  • Pinned — 시작한 버전에서 끝까지 실행됩니다. 문서의 보장 문구 그대로, 단일 Worker Deployment Version에서 완료됨이 보장됩니다. 실행 중 코드가 바뀔 일이 없으니 그 워크플로에는 패칭이 필요 없습니다. 굳이 옮겨야 하면 temporal workflow update-options로 수동 이동합니다.
  • Auto-Upgrade — current 버전이 바뀌면 자동으로 따라갑니다. 대신 문서가 명시하듯 여러 버전을 오가므로 패칭으로 리플레이 안전성을 수동으로 유지해야 합니다. 버저닝이 패칭을 대체하는 게 아니라, pinned에 한해 면제해 주는 겁니다.

라우팅은 세 개념으로 돌아갑니다. 새 워크플로가 가는 Current Version, 트래픽의 일부(0~100%, 워크플로 ID 기준으로 그룹이 갈림)를 먼저 받아보는 Ramping Version, 그리고 각 실행이 다음에 올라탈 Target Version. 카나리처럼 새 버전에 5%만 흘려보다가 문제없으면 current로 승격하고, 문제가 생기면 current를 이전 버전으로 되돌립니다 — 옛 버전 워커가 아직 폴링 중이므로 롤백이 즉각적입니다.

버전의 생애 주기는 Inactive → Active → Draining(current/ramping에서 내려왔지만 pinned 워크플로가 남아 있음) → Drained(남은 pinned가 모두 종료됨)로 흐릅니다. 상속 규칙도 문서화됐습니다 — pinned 부모의 자식은 (같은 배포의 태스크 큐라면) 부모 버전을 물려받고, auto-upgrade는 아무것도 상속하지 않으며, 크론은 절대 상속하지 않습니다. Continue-as-New는 pinned 버전을 체인 너머로 이어받습니다.

버전 요구 사항은 만만치 않습니다. 문서 기준 서버 v1.29.1+, CLI v1.4.1+, SDK는 Go v1.35.0 / Python 1.11 / Java 1.29 / TypeScript 1.12 / .NET 1.7.0 / Ruby 0.5.0 이상입니다.

아주 긴 워크플로의 남은 구멍 — Upgrade on Continue-as-New

pinned와 auto-upgrade로도 깔끔하지 않은 사례가 하나 남습니다. GA 발표문이 직접 드는 예로, 몇 달씩 도는 엔티티 워크플로나 상호작용 사이에 몇 주씩 잠드는 AI 에이전트처럼 사실상 끝나지 않는 워크플로입니다. pinned로 두면 그 버전에 영원히 묶이고, auto-upgrade로 두면 패칭 지옥으로 돌아갑니다.

GA와 함께 공개 미리보기로 나온 Upgrade on Continue-as-New가 이 구멍을 겨냥합니다. 많은 장수 워크플로가 이미 Continue-as-New를 체크포인트로 쓰고 있으니, 그 경계를 버전 업그레이드 지점으로 쓰자는 겁니다 — 각 run은 pinned로 돌다가(run 중 패치 불필요), 새 버전이 current/ramping이 되면 GetTargetWorkerDeploymentVersionChanged(워크플로 태스크가 끝날 때마다 갱신됨)로 감지하고, 다음 Continue-as-New에서 새 버전으로 갈아탑니다. 각 run은 한 버전 안에서 끝나지만 논리적 워크플로는 여러 버전을 가로지릅니다. v1.31.0에는 ramping 버전으로 이어붙는 동작도 추가됐습니다. 단, 이 부분은 아직 공개 미리보기라는 걸 감안해야 합니다.

정직한 트레이드오프

GA라는 도장에 속아 공짜 점심으로 읽으면 안 됩니다. 청구서는 이렇게 생겼습니다.

첫째, pinned는 레인보우 배포이고, 레인보우 배포는 인프라 비용입니다. pinned 워크플로가 남아 있는 한 그 버전의 워커를 계속 돌려야 합니다. 문서 스스로 레인보우 배포에서는 동시에 두 개 이상의 활성 버전이 돈다고 말합니다. 몇 달짜리 pinned 워크플로는 몇 달짜리 워커 fleet을 뜻하고, 배포할 때마다 버전이 하나씩 늘어나므로 운영 대상도 그만큼 늘어납니다. Kubernetes라면 GA로 나온 Temporal Worker Controller가 버전별 fleet 관리를 대신해 주지만, 컴퓨트 비용 자체가 사라지는 건 아닙니다.

둘째, auto-upgrade는 패칭에서 해방되지 않습니다. 위에서 인용한 문서 문구 그대로입니다. 버저닝을 켜도 auto-upgrade 워크플로의 코드 변경은 여전히 리플레이 안전성 검토와 패치 마커를 요구합니다. "GA 됐으니 patched 호출을 다 지워도 된다"는 결론은 틀렸습니다.

셋째, 버전은 무한히 쌓을 수 없습니다. v1.28.0 릴리스 노트의 운영 노브 기준으로 deployment당 버전 수 한도의 기본값은 100이고, 노트는 몇백 이상으로 올리는 건 안전하지 않다고 경고합니다(네임스페이스당 deployment 수 기본 100은 크게 올려도 안전하다고 하는 것과 대조적입니다). drain이 안 끝나는 pinned 실행이 쌓이면 버전 위생 — 오래된 버전을 비우고 지우는 일 — 이 새로운 운영 업무가 됩니다. drainage 상태 갱신도 기본 3분 주기라 실시간이 아닙니다.

넷째, 도입은 배포 파이프라인 통합 작업입니다. 버저닝의 반쪽은 서버가 아니라 당신의 CD입니다 — 빌드마다 Build ID를 찍고, 배포 후 SetWorkerDeploymentRampingVersionSetWorkerDeploymentCurrentVersion을 호출하는 단계가 파이프라인에 들어가야 합니다. 위의 최소 버전 매트릭스(서버·CLI·SDK 전부)도 채워야 하고요.

다섯째, 안 써도 되는 경우가 분명히 있습니다. 모든 워크플로가 짧아서 배포 간격 안에 끝난다면 — 태스크 큐를 자연스럽게 비우고 배포할 수 있다면 — unversioned 큐로 충분합니다. 워크플로 코드 변경이 드물다면 patched 몇 개가 버전별 워커 fleet 운영보다 쌉니다. 이 기능의 손익분기점은 "배포 사이에 안 끝나는 워크플로 × 코드 변경 빈도"가 충분히 클 때입니다.

같은 릴리스의 나머지 — 엔진이 가는 방향

맥락을 위해 v1.31.0에 함께 실린 것들도 짚어 둡니다. 서버가 태스크 큐를 보고 AWS Lambda 워커를 직접 invoke/scale하는 Serverless Workers가 프리릴리스로 들어왔고(Worker Controller Instance라는 새 서버 컴포넌트, 기본 비활성), 워크플로가 아닌 서버 내부 상태머신들을 일반화하는 CHASM 프레임워크가 기본 활성화됐으며(그 위의 애플리케이션은 아직 꺼져 있음), 워크플로 없이 액티비티만 단독 실행하는 Standalone Activities가 공개 미리보기로, 태스크 큐 우선순위/공정성은 GA로 올라왔습니다. Nexus는 이제 피처 플래그 없이 상시 활성입니다. 워크플로 리플레이 머신에서 범용 내구 실행 플랫폼으로 — 릴리스 노트가 가리키는 방향은 일관됩니다.

그래서 당신은 써야 하나

판단 기준을 세 질문으로 압축하면 이렇습니다.

  • 배포 사이에 안 끝나는 워크플로가 실제로 있는가? 없다면 여기서 끝 — unversioned로 사세요.
  • 배포 때 비결정론 에러를 실제로 겪었거나, patched 분기가 코드 리뷰에서 계속 시비가 붙는가? 그렇다면 pinned 기본값이 그 고통을 배포 토폴로지 문제로 치환해 줍니다.
  • 버전별 워커 fleet을 굴릴 인프라 여력이 있는가? rainbow 배포의 컴퓨트 비용과 버전 위생 업무를 감당할 수 없다면, 차라리 워크플로를 짧게 쪼개고 Continue-as-New 주기를 당기는 쪽이 나을 수 있습니다.

한 가지 착각만 경계하면 됩니다 — 버저닝은 배포 문제를 풀지, 재시도 문제를 풀지 않습니다. 액티비티는 여전히 Temporal이 재시도하고, 그 부작용(결제, 메일, 티켓)의 멱등성은 여전히 당신 몫입니다. 이 이야기는 에이전트의 프로덕션 실패 분류와 멱등성 편과 Exactly-once는 환상인가 편에서 자세히 다뤘습니다. 특히 몇 주씩 잠드는 AI 에이전트를 내구 워크플로로 감싸려는 팀이라면, 이번 GA의 Upgrade on Continue-as-New가 정확히 그 시나리오를 겨냥하고 있다는 점과 함께, 그것이 아직 미리보기라는 점도 같이 기억해 두세요.

마치며

정리하면 이렇습니다. 결정론적 리플레이는 내구 실행의 원천이지만, 그 대가는 배포에서 청구됩니다 — 이력과 코드가 어긋나는 순간 비결정론 에러가 나니까요. 지금까지는 그 대가를 워크플로 코드 안의 패치 분기가 흡수했고, Worker Versioning GA는 그것을 배포 토폴로지 — 버전별 워커 fleet과 라우팅 규칙 — 로 옮겼습니다. 제약이 사라진 게 아니라 자리를 옮긴 겁니다. 코드가 깨끗해지는 대신 인프라가 두꺼워집니다.

그리고 이 답이 나오기까지 한 벤더가 3년간 두 세대의 API를 버렸다는 사실 자체가, 리플레이 모델 위에서 안전한 배포가 얼마나 본질적으로 어려운 문제인지에 대한 가장 정직한 증언일 겁니다. 도입 여부와 무관하게, 내구 실행 엔진을 고르는 사람이라면 "이 엔진은 실행 중인 워크플로가 있는 채로 코드를 어떻게 바꾸게 해 주는가"를 첫 질문 목록에 올려 두세요.

참고 자료