Vercel이 Workflow SDK를 구축한 방법을 소개한다. 일반 코드처럼 작성할 수 있고, 이미 운영 중인 인프라에서 실행되며, 각 단계의 무료화를 목표로 하는 견고한 워크플로다.
불안정하고 무상태(stateless)인 인프라 위에서 오래 실행되는 상태 기반(stateful) 로직을 조율한다는 발상 자체는 새롭지 않다. 메시지 큐, 잡 러너, 마이크로서비스 코레오그래피, 본격적인 워크플로 엔진까지, 이런 도구들은 오래전부터 존재해 왔다. 다만 한 가지가 없었다. 실제로 작성하기 편한 버전이다.
나는 약 6개월간, 주로 주말을 이용해 Temporal 포크 작업에 매달렸다. Vercel 특유의 개발자 경험(DX)을 갖춘 서버리스 대안으로 만들고 싶었기 때문이다. 그러다 문득 깨달았다. 그 경험을 제대로 구현하려면 실행 환경까지 직접 소유해야 한다는 것을. 결국 포크를 내려놓고 Vercel에 합류해, Nathan Rajlich와 함께 새 프레임워크를 처음부터 만들기 시작했다.
워크플로는 DAG(방향성 비순환 그래프)다. Temporal이 등장하기 전(그리고 그 이전의 Cadence까지 거슬러 올라가면), 거의 모든 워크플로 프레임워크는 이 DAG를 직접 손으로 그리도록 요구했다. Apache Airflow가 대표적인 예다. 파이프라인을 태스크와 의존성의 명시적인 그래프로 기술해야 하고, 정작 핵심 로직은 노드 안에 파묻혀 버린다.
이 방식은 항상 거꾸로 된 것 같다는 느낌이었다. "이걸 하고, 그다음 저걸 하고, 이것들은 병렬로 처리하고, 여기서 분기한다"를 표현하는 도구는 이미 있다. 바로 프로그래밍 언어다. 추상 구문 트리(AST)는 DAG이고, 소프트웨어 자체가 DAG다.
이론적으로는 알고 있었지만, Temporal을 보고 나서야 비로소 실감했다. 일반적인 순차 코드처럼 작성하면 엔진이 내부적으로 견고함을 보장해 주는 방식. 그게 처음부터 꿈꿔 오던 모습이었다.
인프라가 갖춰진 상태라면 Temporal은 훌륭하다. 하지만 나는 처음부터 시작해야 했고, 셋업 과정은 이러했다:
Temporal Cloud를 구성하거나 직접 서버를 호스팅하기: Frontend, History, Matching, Worker 서비스에 Cassandra/Postgres/MySQL 백엔드와 샤딩까지.
자체 워커 플릿 운영: Temporal은 코드를 직접 실행하지 않는다. 워커가 서버를 폴링해 워크플로와 액티비티를 실행한다. 실제로는 직접 관리해야 하는 Kubernetes 클러스터가 필요하다는 뜻이다.
전체 연결 구성: 태스크 큐, 액티비티 등록, 클라이언트 설정.
워커 직접 관리: 스케일링, 업타임, 재시작, 그리고 워커 프로세스를 위한 빌드·배포 파이프라인.
암호화 설정: 워커는 인터넷을 통해 컨트롤 플레인과 통신하므로, 상호 TLS를 구성하고 페이로드가 환경 밖으로 나가기 전에 암호화할 데이터 컨버터를 설정해야 한다.
이 모든 것을 갖추고 나면 워크플로의 꿈은 대부분 현실이 된다. 하지만 나는 시작이 쉬우면서도 프로젝트가 프로덕션으로 성장해도 함께 확장되는 무언가를 원했다.
워커 빌드·배포 프로세스를 직접 소유하는 것 외에도, 더 까다로운 문제가 있었다. 실행 중인 워크플로가 있는 상태에서 코드를 변경하면 난감한 상황이 벌어진다. 기존의 올바른 버전 코드를 실행하는 워커가 더 이상 존재하지 않기 때문이다. 재실행(replay) 시 새 코드가 과거 이벤트 히스토리를 만나면 비결정성 오류로 깨진다.
공식적인 해법은 패칭 API(patched() / GetVersion)다. 변경 ID를 기준으로 코드를 분기하고, 오래된 실행이 보존 기간을 벗어날 때까지 단계적 폐기-제거 라이프사이클을 따라가야 한다. 작동은 하지만, 워크플로 코드를 매우 신중하게 진화시켜야 한다는 뜻이다. 변경이 쌓이다 보면 워크플로 코드는 버전 플래그 덤불로 변해버린다.
친구들에게 Temporal 데모를 보여줄 때마다, 시그널·쿼리·업데이트가 항상 가장 혼란스러운 부분이었다. 실행 중인 워크플로와 데이터를 주고받기 위한 세 가지 별개의 프리미티브로, 각각 고유한 규칙이 있다. 쿼리는 블로킹이 불가능하고, 시그널은 fire-and-forget 방식으로 버퍼에 쌓이며, 상황에 따라 어떤 것을 써야 할지 알아야 한다. 개념 자체는 이해되지만 직관적이지 않았다. 화이트보드 없이 human-in-the-loop 프리미티브를 설명할 수 없다면, 그건 너무 복잡한 것이다. 이것이 바로 Workflow SDK가 세 가지를 모두 단일 프리미티브인 훅(hook)으로 대체한 이유다.
Workflow SDK에서는 일반 코드 한 파일만 작성하면 된다. "use workflow"는 오케스트레이터를, "use step"는 부수 효과(side effect)가 있는 작업 단위를 표시한다. 그 사이는 그냥 await, try/catch, Promise.all, 반복문, 조건문이다.
이 파일 하나에서 여러 가지가 따라온다:
DAG 파일 없음: 컴파일러가 디렉티브를 읽고 코드를 워크플로 번들과 클라이언트/스텝 번들로 분리한다. 그래프는 곧 제어 흐름이다.
재시도: 처리되지 않은 오류는 기본적으로 재시도된다. 중단하려면 new FatalError(...)를 던지고, 커스텀 백오프는 new RetryableError(..., { retryAfter })을 던지면 된다. fn.maxRetries = n으로 세부 조정도 가능하다.
애드혹(ad-hoc) 웹훅: const webhook = createWebhook()는 실행 중인 워크플로 안에서 실제로 호출 가능한 URL을 단 한 줄로 생성한다. 별도의 라우트나 핸들러 없이, 누군가 해당 URL을 호출할 때까지 실행이 대기한다.
훅: 웹훅은 더 범용적인 프리미티브 위에 얹힌 편의 기능이다. createHook()으로 훅을 생성하고 await하면, 누군가 전송한 데이터가 그대로 반환된다. 이 하나의 개념이 시그널, 쿼리, 업데이트를 모두 대체한다.
다음은 애드혹 웹훅의 전체 흐름이다. 몇 초가 됐든 몇 주가 됐든 일시 정지할 수 있는 승인 흐름을 추가 배포 없이 구현한 예시다.
기존 엔진들은 프레임워크에 맞춰 인프라를 가져오도록 요구한다. Workflow SDK는 반대로, 이미 운영 중인 인프라에 프레임워크를 가져온다.
실제로 구현하는 방법을 고민하던 중, 찾을 수 있는 워크플로 엔진을 모두 살펴봤다. 그 중 눈에 띈 것은 DBOS였다. 거의 모든 처리가 클라이언트 사이드에서 이루어지는 오픈소스 견고한 실행(durable execution) 라이브러리로, 서버가 필요로 하는 것은 Postgres뿐이다.
Workflow SDK는 이 구조를 따른다. 별도로 셀프 호스팅하거나 비용을 내야 하는 전용 오케스트레이터도 없고, 새로 운영해야 할 상태 기반 시스템도 없다. 필요한 인프라는 이미 운영 중인 것들뿐이다. 앱, 데이터베이스, 큐. 모든 핵심 처리는 라이브러리 안에서 이루어진다.
Workflow SDK는 이 아이디어를 특정 데이터베이스 하나를 넘어 확장한다. 전체가 하나의 스펙이다. 어떤 인프라든 뒷받침할 수 있는, 워크플로·스텝·훅을 작성하는 방식이다. 자신의 스택에 맞게 기반 기술을 자유롭게 조합할 수 있다:
스트림: Redis / Kafka 등
내구성: Postgres / Cassandra / 파일 시스템 / Turso / Durable Objects 등
큐: Vercel Queues / SQS / Cloudflare Queues 등
런타임은 단일 인터페이스인 World하고만 통신한다. World는 스토리지, 큐잉, 인증, 스트리밍을 담당한다. 어느 레이어를 교체해도 워크플로 코드는 건드릴 필요가 없다.
실제로 우리는 DBOS에서 영감을 받아 내구성·큐잉·스트리밍 모두 Postgres를 사용하는 공식 Postgres World를 직접 관리한다. 그리고 어떤 World든 워커 플릿도, 컨트롤 플레인도 없다. 프레임워크 연동은 일반 HTTP 엔드포인트 두 개를 노출하며, 나머지 앱과 동일한 방식으로 배포된다.
Vercel에서 워크플로를 지원하는 Vercel Workflow Server는 무상태이며 연산이나 오케스트레이션을 전혀 수행하지 않는다. Postgres World와 본질적으로 같고, Vercel의 인증과 멀티 테넌시만 추가된 구조다. CRUD API, 그 이상도 이하도 아니다. 심지어 일반 Vercel 배포로 실행된다. 모든 핵심 처리, 즉 실제 워크플로 로직 전체는 Apache 라이선스로 공개된 클라이언트 사이드 라이브러리에 있으며, 자유롭게 확장할 수 있다.
따라서 "매니지드" 서비스라고 해도 락인되는 블랙박스가 아니다. 누구나 읽고 포크하고 확장할 수 있는 스펙의 구현체 중 하나일 뿐이다. 데이터 포맷도 독자적이지 않아, 스택의 어느 부분이든 교체할 수 있다.
솔직하게 말해야 할 부분이 있다. Workflow SDK의 설계 결정 중 일부는 Vercel의 코드 실행 방식에 의해 형성됐다. 가장 명확한 예가 버저닝이다. 기존 엔진들을 그토록 고통스럽게 만드는 코드 진화 문제 말이다.
우리의 해법은 각 실행을 시작된 시점의 특정 배포에 고정하는 것이다. 실행은 시작될 때의 코드 사본을 기준으로 계속 진행되므로, 워크플로 코드를 변경해도 실행 중인 워크플로가 깨지지 않는다. 깔끔한 해법이지만, Vercel이 이미 불변 배포(immutable deployment)를 오랫동안 유지하고 있었기 때문에 구현이 수월했다. 공식 Postgres World는 아직 이 방식으로 버전을 추적하고 라우팅하지 않는다. 그러나 고무적이게도, 커뮤니티 World 중에는 이미 스펙대로 구현한 사례가 있다. Platformatic의 Kubernetes 기반 버전 안전 견고한 워크플로가 좋은 예다.
이것이 옳은 트레이드오프라고 생각한다. 고정(pinning) 방식은 버저닝의 복잡성을 워크플로를 작성하는 개발자에게서 인프라로 옮긴다. 그 부담은 World 제작자, 인프라 제공자, 그리고 프레임워크 제작자인 우리에게 돌아간다. 프레임워크 사용자 모두가 같은 어려운 문제에 부딪혀 각자 똑같이 고통스러운 방식으로 해결해야 한다면, 그 프레임워크는 실패한 것이다. 좋은 프레임워크는 그 복잡성을 상위 레이어로 끌어올린다.
다음으로 해결해야 할 큰 과제는 성능이다. 스텝을 호출하는 것은 가볍지 않다. 각 스텝은 결과를 올바르게 내구적으로 커밋하기 위해 네트워크 통신과 큐를 거쳐야 한다. 정확성 측면에서는 옳은 방식이지만, 분산 컴퓨팅이 함수 호출처럼 느껴져야 한다는 본래의 약속과는 상충된다.
일반 코드를 작성할 때는 함수 호출 오버헤드를 신경 쓰지 않는다. 너무 빨라서 사실상 비용이 없다고 느껴지기 때문이다. 하지만 모든 스텝에 무거운 오버헤드가 따른다면 추상화가 무너진다. 스텝을 아껴 쓰게 되고, 세분도(granularity)를 따지게 되며, "스텝으로 만들기 아깝다"는 이유로 작은 함수를 체크포인트로 전환하지 않게 된다.
그래서 목표는 단순하다: 스텝은 무료여야 한다.
이상적으로는, 기존 코드베이스의 어떤 함수에도 "use step"만 추가하면 직렬화 제약도 성능 패널티도 없이 그냥 동작해야 한다.
그 꿈이 우리를 이끈다.
어떤 개발자든 일반 함수를 단 두 단어 "use step"만으로 독립적인 마이크로서비스 수준의 성능, 네트워킹, 관찰 가능성(observability), 가용성을 갖춘 무언가로 바꿀 수 있다면 어떨까? 코드를 작성하는 방식 자체가 달라질 것이다.
이 글을 쓰는 시점에 베타 중인 Workflow v5는 사용자 API 변경 없이 최대 5배의 성능 향상을 이미 제공하며, v6에서는 서드파티 World도 동일한 속도를 낼 수 있도록 더 밀어붙일 예정이다. 진행 상황은 GitHub 디스커션을 통해 공유하고 있으며, 지금 바로 npm install workflow@beta로 테스트를 시작할 수 있다.
일반 코드처럼 작성할 수 있고, 이미 운영 중인 인프라에서 실행되며, 스텝을 사용할 때 성능 걱정이 없는 견고한 프레임워크를 만들고자 했다. 지금 이미 현실이 됐고, 나머지는 공개적으로 함께 만들어가고 있다.
실제 제약 속에서 견고한 시스템을 설계하는 일이 즐겁게 느껴진다면, Vercel Workflow 팀에서 함께할 분을 찾고 있습니다.
채용 공고를 확인해 보세요.