Cloudflare Workflows 마이그레이션: 레인별 Dynamic Workflow 도입 기준

Cloudflare Workflows V2와 Dynamic Workflows가 에이전트 기반 게시 파이프라인을 바꾸는 방식을 분석합니다. 레인별 분리 기준, 결정론적 재실행, publish-{brief_id} 멱등성 키를 지키는 이유를 실제 운영 아키텍처로 정리했습니다.

Friday, September 4, 2026Omid Saffari
Cloudflare Workflows 마이그레이션: 레인별 Dynamic Workflow 도입 기준

Anthropic 쪽에서 돌아가는 퍼블리셔 루틴 6개가 매일 실행되고, 모두 publish-{brief_id}를 키로 삼는 단 하나의 정적 PublishWorkflow에 POST 요청을 보냅니다. 5월 1일 Cloudflare는 @cloudflare/dynamic-workflows를 출시했고, 며칠 뒤에는 에이전트가 유발하는 부하에 맞춰 컨트롤 플레인을 다시 설계한 Cloudflare Workflows V2를 내놓았습니다. 흥미로운 지점은 출시 자체가 아닙니다. 멱등성을 위해 택했던 단일 정적 워크플로 구조가 이제는 물려받은 제약이 아니라 제가 직접 타당성을 입증해야 할 설계 결정이 됐다는 점입니다.

예상 밖이었던 핵심

제가 예상했던 발표는 “Workflows의 확장성이 더 커집니다” 정도였습니다. 상한을 높이고 큐 용량을 늘리는, 해마다 볼 법한 업데이트 말입니다. 하지만 이는 발표의 절반에도 못 미치는 변화였습니다.

정작 중요한 소식은 런타임에 테넌트마다 서로 다른 워크플로 코드를 쓸 수 있게 됐다는 점입니다. 5월 1일 출시된 @cloudflare/dynamic-workflows는 MIT 라이선스로 공개됐으며 Dynamic Workers를 기반으로 합니다. 하나의 WorkflowEntrypoint를 등록해 두고 인스턴스 생성 시점에 Dynamic Worker에서 실행 코드를 가져올 수 있으므로, 단계 그래프를 배포 파일에 고정하는 대신 테넌트별로 구성할 수 있습니다. Cloudflare가 이를 “테넌트를 따라가는 내구성 실행”이라고 표현한 이유도 여기에 있습니다. 단일 워크플로에 기능 플래그를 덧붙여 고객별 로직을 구현해 본 사람이라면 이 문장이 얼마나 정확한지 바로 알 수 있습니다.

뉴스, 개발, 빌드, 디자인, 마케팅, 창업자, 비즈니스를 담당하는 제 퍼블리셔 루틴 6개는 기능적으로 하나의 PublishWorkflow를 공유하는 6개 테넌트와 같습니다. 실제 고객은 아닙니다. Anthropic 쪽에서 각기 다른 콘텐츠 레인을 맡은 AI 루틴입니다. 그래도 멀티테넌트 구조라는 관점은 정확히 들어맞습니다. 오케스트레이션의 뼈대는 같고 레인마다 형태가 조금씩 다르며, 모두 같은 Cloudflare 계정으로 디스패치됩니다.

며칠 뒤 Workflows V2가 공개됐는데, 여기서는 수치보다 발표문의 관점이 더 중요합니다. V2는 사람이 버튼을 누르는 상황이 아니라 에이전트가 머신 속도로 워크플로 인스턴스를 만드는 상황을 전제로 명시적으로 재설계됐습니다. 제가 운영하는 트리거 패턴이 바로 이것입니다. 퍼블리셔 루틴이 글을 발행하기로 결정하면 사람이 아닌 이벤트가 내구성 실행 인스턴스를 만들고, V1의 컨트롤 플레인은 이런 형태의 부하를 염두에 두고 설계되지 않았습니다.

비개발자 창업자가 알아야 할 의미

“내구성 실행”은 일부러 어렵게 만든 듯한 표현입니다. 쉽게 말하면, 여러 단계로 이뤄진 작업이 중간에 장애가 나도 처음부터 다시 시작하지 않고 마지막으로 완료한 단계부터 이어서 실행된다는 뜻입니다. 8단계 중 5단계가 API 장애로 실패했다면 시스템은 5단계만 재시도합니다. 1~4단계를 다시 실행하지 않으므로 그 비용도 다시 청구되지 않습니다.

창업자가 주목해야 할 대목은 여기입니다. 단계별 재시도 경계는 곧 단계별 비용 경계이기도 합니다. 파이프라인이 3단계와 6단계에서 유료 AI 모델을 호출하는데 7단계에서 장애가 났다면, 7단계만 재시도해야 합니다. 유료 파이프라인을 멱등성 없이 다시 돌렸을 때 생기는 문제는 데이터베이스의 중복 행 정도가 아닙니다. 청구서가 중복으로 발생합니다.

외주 개발자에게 한 문장으로 전달할 수 있는 구축 여부의 기준은 이렇습니다. 플랫폼에서 가능해졌다는 이유가 아니라 제품군별 로직이 실제로 달라질 때만 워크플로를 분리합니다. Cloudflare가 강력한 새 추상화를 선보이면 그 구조에 맞춰 전부 다시 설계하고 싶어지기 마련입니다. 하지만 워크플로 정의가 N개라면 유지보수 부담도 N배이고, 레인마다 실제 작업이 다를 때만 그만한 이점이 생깁니다. “이제 가능해졌으니 제품군별 워크플로를 따로 만들자”는 제안을 들은 창업자라면 플랫폼에서 무엇이 바뀌었는지가 아니라 업무 자체에서 무엇이 바뀌었는지를 물어야 합니다.

제가 실제로 운영하는 아키텍처

실제 구조는 머릿속에 쉽게 그릴 수 있을 만큼 단순합니다. Anthropic 쪽의 각 퍼블리셔 루틴은 리서치를 끝내고 브리프를 작성한 다음, 사이트의 /api/admin/publish로 POST 요청을 보냅니다. 이 엔드포인트가 페이로드를 검증하고 인스턴스 ID를 만든 뒤, PublishWorkflow라는 단일 WorkflowEntrypoint에서 instances.create를 호출합니다.

워크플로는 멱등성을 갖춘 step.do 8단계로 구성됩니다.

  1. validate (브리프 스키마, 슬러그 고유성)
  2. ground (인용 자료 가져오기, 링크 해석)
  3. generate (본문 생성을 위한 Claude 호출)
  4. clean (마크다운 디렉티브 검증)
  5. persist (Postgres 삽입, 버전 관리)
  6. index (임베딩, 검색 인덱스 갱신)
  7. cover (이미지 생성 + R2 업로드)
  8. publish (상태 전환, 사이트맵 핑)
TypeScript
export class PublishWorkflow extends WorkflowEntrypoint<Env, PublishParams> {
  async run(event: WorkflowEvent<PublishParams>, step: WorkflowStep) {
    const brief = await step.do("validate", () => validateBrief(event.payload));
    const grounded = await step.do("ground", () => groundCitations(brief));
    const draft = await step.do("generate", () => generateBody(grounded));
    const cleaned = await step.do("clean", () => validateDirectives(draft));
    const row = await step.do("persist", () => persistArticle(cleaned));
    await step.do("index", () => reindex(row.id));
    await step.do("cover", () => generateCover(row.id));
    await step.do("publish", () => flipStatus(row.id));
  }
}

디스패치 경계는 다음과 같습니다.

TypeScript
const id = `publish-${brief.brief_id}`;
try {
  await env.PUBLISH.create({ id, params: brief });
} catch (e) {
  if (isDuplicateIdError(e)) return new Response("already queued", { status: 200 });
  throw e;
}

instances.create는 ID가 중복되면 예외를 던집니다. 이 한 줄이 파이프라인의 핵심을 떠받칩니다. Anthropic 루틴이 다시 실행되더라도 같은 글이 두 번 발행되고 내부 AI 호출 비용까지 이중으로 나가는 일을 막는 유일한 장치이기 때문입니다.

6개 레인에 정의를 하나만 쓰는 이유는 8개 단계가 모두 같기 때문입니다. 달라지는 것은 보이스 규칙, 독자 규칙, 피해야 할 패턴 목록, 브리프 힌트로 이뤄진 편집 번들뿐입니다. 이는 코드가 아니라 디스패치 페이로드의 런타임 데이터로 전달됩니다. 레인 이름에 따라 3단계(generate)에 보낼 번들을 고르고, 나머지 단계는 어느 레인에서나 동일하게 작동합니다.

Dynamic Workflows 도입을 진지하게 검토할 유일한 이유이자 현재의 압박 지점은 한 레인이 단순히 다른 번들을 넘어 서로 다른 단계 그래프를 원하기 시작했다는 데 있습니다. 리서치 중심 레인에는 생성 전 그라운딩 패스를 하나 더 넣고, 이후 팩트체크 단계도 추가할 가능성이 있습니다. 이는 데이터가 아니라 구조의 차이입니다. 지금은 ground 안의 조건 분기로 처리해도 작동하지만, 3개 레인이 더 각자의 예외 분기를 추가한다면 금세 유지보수하기 어려운 코드가 됩니다.

Cloudflare Dynamic Workflows 마이그레이션의 실제 비용

createDynamicWorkflowEntrypoint를 도입하면 레인별 워크플로 코드를 Dynamic Worker에서 런타임에 불러올 수 있습니다. 오늘 실행되지 않은 레인은 유지하는 데 드는 비용이 거의 없다는 뜻입니다. “모든 레인을 언제나 하나의 번들로 배포한다”는 유지보수 부담에서 벗어나고, 공유 뼈대를 다시 배포하지 않아도 각 레인의 코드를 독립적으로 발전시킬 수 있습니다.

레인별 워크플로로 분기하기 전에 /publish HTTP 경계가 publish-{brief_id} 멱등성 키를 게이트로 소유하는 구조를 보여주는 다이어그램
멱등성 키는 디스패치 경계에 두며, 레인 코드 안에는 절대 두지 않습니다.

출시 글에서 크게 다루지 않은 실제 비용도 있습니다. 컴파일러가 단계 입력 계약의 변경을 알려주는, 타입 검사가 적용된 단일 WorkflowEntrypoint를 포기하고 그런 검사가 없는 N개의 런타임 로드 정의를 택하게 됩니다. 레인 간 단계 계약이 어긋나도 빌드가 아니라 런타임에서야 실패합니다. 누군가 일부 레인만 리팩터링한 뒤 나머지를 빠뜨려 6개 레인 중 2개의 persist 단계가 조금 다른 행 구조를 요구하게 됐다면, CI가 실행될 때가 아니라 실제 글을 발행하려는 순간에야 문제를 발견합니다.

마이그레이션을 전부 한 번에 할 필요는 없습니다. 그렇게 가장하는 순간 과도한 설계가 시작됩니다. 제가 계획한 방식은 다음과 같습니다.

  1. 공유하는 정적 WorkflowEntrypoint에 8단계 뼈대를 그대로 둡니다. 여기가 핫 패스이며, 6개 레인 중 5개는 아무 변경 없이 이를 사용합니다.
  2. 리서치 중심 레인은 자체 단계 그래프를 가진 Dynamic Workflow로 만듭니다. 그라운딩 패스를 하나 더 넣고 팩트체크 단계를 추가합니다.
  3. /publish 경계에서 레인에 따라 디스패치합니다. brief.lane을 기준으로 어느 바인딩의 create를 호출할지 고르는 한 줄짜리 switch면 됩니다.

숫자로 명확히 정한 판단 기준은 이렇습니다. 단계 그래프가 공통 뼈대와 1단계 넘게 다를 때만 해당 레인을 별도의 Dynamic Workflow로 마이그레이션합니다. 그보다 차이가 작다면 두 번째 워크플로 정의를 관리하는 것보다 기존 단계 안에 조건 분기를 두는 편이 저렴합니다. 차이가 그 이상이면 단계 안의 분기가 레인의 실제 동작을 가리기 시작하므로 별도 정의가 제값을 합니다.

기준을 넘는 레인은 1개, 넘지 않는 레인은 5개입니다. 마이그레이션 계획은 이 두 문장으로 끝납니다. 외주 개발자가 처음부터 6개 모두를 Dynamic Workflows로 분리하자는 브리프를 가져온다면 저는 승인하지 않을 겁니다. 구축 비용은 실제로 발생하지만, 이점은 정확히 1개 레인에 집중돼 있기 때문입니다.

Cloudflare Workflows V2는 머신 속도 트리거를 어떻게 바꾸나

V2가 내세운 주요 수치는 워크플로당 동시 실행 인스턴스 50,000개와 대기 인스턴스 2,000,000개입니다. V1의 대기 인스턴스 상한은 1,000,000개였습니다. 허세용 수치는 아니지만 제가 운영하는 규모와는 거리가 멉니다. 루틴 6개가 하루 1~2회 실행되는 제 환경은 새 상한보다 규모가 약 9자릿수나 작습니다.

제 스택에서 중요한 것은 수치가 아닙니다. V2의 컨트롤 플레인이 에이전트가 인스턴스를 생성하는 상황을 기본 전제로 다시 만들어졌다는 점입니다. V1은 사용자가 버튼을 누르면 인스턴스가 시작되고 시스템이 순간적이지만 제한된 부하를 처리하는, 사람 중심 트리거에 최적화됐습니다. V2는 트리거 주체를 사람이 아닌 프로세스로 보고, 요청 속도도 UI 클릭이 아니라 상위 에이전트의 처리량이 결정한다고 가정합니다.

V2의 결정론적 재실행을 쉬운 말로 다시 짚을 필요가 있습니다. 각 단계는 서로 격리돼 있고 재실행 가능하며 멱등성을 갖습니다. 재시도할 때 워크플로는 마지막으로 성공한 단계부터 이어집니다. 이것이 바로 제가 디스패치 경계에서 publish-{brief_id} 인스턴스 ID로 직접 지키려 했던 속성입니다. V1도 단계별 재시도를 보장했지만, V2는 재실행 의미 체계를 더 견고하게 만들어 제가 보호하던 구조적 속성을 그 아래의 플랫폼에서도 강제합니다.

이번 주에 핫 패스에서 할 일은 없습니다. V2 모델은 다시 배포해야 선택적으로 적용되며, 이미 멱등성을 갖춘 파이프라인을 서둘러 새 컨트롤 플레인으로 옮겨 기존에 확보한 의미 체계를 좇는 것은 최악의 선택입니다. 리서치 중심 레인을 Dynamic Workflow로 만들 때는 V2로 옮길 계획입니다. 새 코드를 새 모델에 올리는 비용은 작기 때문입니다. 나머지 5개 정적 레인은 “플랫폼에 새 버전이 나왔다”는 이유가 아닌, 실제로 손댈 이유가 생길 때까지 그대로 둡니다.

절대 포기하지 않을 단 하나의 불변 조건

인스턴스 ID로 쓰는 publish-{brief_id}는 파이프라인의 핵심을 떠받칩니다. 이를 없애면 Anthropic 루틴이 자체적으로 재시도할 때 파이프라인도 다시 실행돼 같은 글을 두 번 발행하고, 워크플로 본문의 모든 유료 API 호출 비용까지 이중으로 지불하게 됩니다.

Dynamic Workflows 자체가 이 불변 조건을 직접 위협하지는 않습니다. instances.create의 ID 계약은 바뀌지 않았습니다. 위험은 레인별 리팩터링을 허술하게 진행해 레인 코드가 로컬에서 ID를 다시 만드는 경우에 생깁니다. 예를 들어 어떤 레인이 “안전을 위해” ID에 타임스탬프를 넣으면 재시도할 때마다 고유한 ID가 만들어지므로 중복 제거가 조용히 무너집니다.

버전이 바뀌어도 살아남아야 하는 규칙이자, 다른 사람이 이 코드를 건드리기 전에 벽에 써 붙이고 싶은 원칙은 다음과 같습니다.

이런 불변 조건은 출시 글이나 마이그레이션 가이드에는 나오지 않습니다. 그것이 막아주는 장애를 실제로 겪고 서비스를 배포한 사람에게만 생기는 규칙이기 때문입니다. 멱등성이 실패하더라도 단일 워크플로 실행이 감당 못 할 청구액으로 번지지 않게 하는 비용 초크포인트는 별도의 계층이며, 인스턴스가 아닌 모델 호출 수준에 위치합니다. 두 계층이 필요한 이유는 같습니다. 모든 단계에서 비용이 발생하는 파이프라인에서는 구조적 정확성이 가장 저렴한 보험입니다.

2026년 5월에 처음부터 시작한다면 바꿀 3가지

1년 전의 결정을 물려받는 대신 지금 이 스택을 새로 만든다면 3가지를 바꾸겠습니다.

첫째, 실제로 구조가 다른 레인이 있다면 공유 정적 뼈대에 단 하나의 Dynamic Workflow를 더하는 방식으로 시작하겠습니다. 6개의 정의를 따로 만들지는 않을 겁니다. 새 추상화가 나오면 모든 곳에 적용하고 싶어지지만, N개 워크플로 정의의 유지보수 비용은 확장성의 이점보다 먼저 찾아옵니다. 정의가 6개면 버그를 고칠 곳도 6곳, 의존성을 업그레이드할 곳도 6곳, 단계 계약이 어긋날 수 있는 곳도 6곳입니다. 하나의 뼈대와 하나의 예외 레인이 구현 가능한 최소 분리입니다.

둘째, 처음부터 HTTP 경계에 멱등성 키를 두겠습니다. 중복 발행 사고가 난 뒤 publish-{brief_id}를 뒤늦게 적용하는 방식은 비용이 큽니다. 중복 발행된 행을 정리하고, 영향을 받은 비용을 환불하며, 이미 프로덕션에서 돌아가는 시스템의 디스패치 계층에 계측을 추가해야 합니다. 파이프라인을 한 번도 배포하기 전에 try { create({id}) } catch (dup) {} 패턴을 넣는 데는 10분이면 충분하고, 이 한 줄로 장애 유형 하나를 통째로 막을 수 있습니다.

셋째, Workflows V2의 결정성을 나중에 선택할 기능이 아니라 설계의 최저선으로 삼겠습니다. 동시 실행 상한 50k에 도달할 일이 없더라도 모든 단계가 안전하게 재실행되도록 설계해야 합니다. 재실행 안전성은 규모의 문제가 아니라 정확성의 문제입니다. 재실행에 안전하지 않은 단계는 재시도 시 깨지는 단계이고, 재시도는 규모와 관계없이 발생합니다.

마지막 원칙은 기술적 결정처럼 보이지만 사실 숙련된 엔지니어의 태도에 가깝습니다. 함정은 새 플랫폼 기능을 도입할 대상으로만 보는 데 있습니다. 중요한 것은 그 확장 여력을 실제로 쓰지 않더라도 새 기능이 제시하는 제약을 설계 기준으로 받아들이는 규율입니다.

모든 테넌트가 같은 로직을 실행한다면 Dynamic Workflows가 필요한가요?

아닙니다. 단계 그래프가 같고 데이터만 다르다면 디스패치 페이로드에 데이터를 담고 하나의 정적 워크플로를 유지하는 편이 낫습니다. Dynamic Workflows가 제값을 하는 때는 매개변수만 달라지는 경우가 아니라 테넌트별 코드, 즉 단계 그래프 자체가 달라질 때입니다.

Workflows V2로 기존 V1 워크플로가 깨지나요?

V2는 결정론적이고 에이전트가 트리거하는 실행에 초점을 맞춰 다시 설계한 컨트롤 플레인입니다. 마이그레이션은 선택 사항으로 보고, 핫 패스를 옮기기 전에 멱등성을 검증해야 합니다. 새 버전이 나왔다는 이유만으로 잘 작동하는 코드를 마이그레이션해서는 안 됩니다.

현재 실제 동시 실행 상한은 얼마인가요?

워크플로당 동시 실행 인스턴스 50,000개, 대기 인스턴스 2,000,000개이며 이전 대기 상한은 1,000,000개였습니다. 대부분의 운영 환경에는 지나치게 높은 수치이고, V2에서 더 중요한 속성은 새 상한이 아니라 결정론적 재실행입니다.

@cloudflare/dynamic-workflows는 프로덕션용인가요, 프리뷰인가요?

Dynamic Workers를 기반으로 만든 MIT 라이선스 라이브러리로서 2026년 5월 1일 출시됐습니다. 라이선스보다 라이브러리의 성숙도와 멱등성 계약을 도입 판단의 위험 요소로 봐야 합니다. 디스패치 계층이 탄탄하다면 마이그레이션 계획이 완성되기 전에 라이브러리는 준비돼 있습니다.

창업자는 언제 엔지니어에게 이 마이그레이션 비용을 지불해야 하나요?

한 제품군의 자동화 로직이 다른 제품군과 실제로 달라질 때입니다. 매개변수만 다른 것이 아니라 단계 그래프가 달라져야 합니다. 확장성만 보고 분리하면 너무 이르고, 서로 다른 로직이 진짜 분리 신호입니다. 이런 마이그레이션의 비용과 효과를 계산하는 데 도움이 필요하다면, 바로 이런 아키텍처 리뷰를 DVNC.dev에서 진행하고 있습니다.

마지막 업데이트

2026년 9월 4일

카테고리Build

Google에서 이 사이트를 우선하기

Google 검색에서 omidsaffari.com을 선호 소스로 추가

omidsaffari.com을 선호 소스로 지정하면 Google이 Top Stories, AI Overviews, AI Mode에서 우선적으로 보여 줍니다.

Build의 다른 글

Build 글 전체 보기
뉴스레터

매주 일요일, 한 통의 편지. 뜨거운 의견이 아닌, 돌아가는 시스템.

AI 벤처 포트폴리오 운영에서 나오는 빌드 로그, 가동 중인 시스템, 현장 노트.

주간 발행. 스팸 없음. 언제든 해지 가능합니다.