같은 요청으로 워크플로가 두 번 시작된다면: 원자적 시작·토큰 보존·해제 경계

주문 접수 API가 응답하지 않아 클라이언트가 같은 요청을 다시 보냈다. 서버는 먼저 “이 주문을 처리하는 실행이 있나?”를 조회한 다음, 없으면 워크플로를 시작한다. 평소에는 괜찮지만 두 요청이 동시에 조회하면 둘 다 없다는 답을 받고 각각 시작할 수 있다. 조회를 추가했다는 사실만으로 조회와 시작 사이의 틈이 없어지는 것은 아니다.

Workflow SDK의 원자적 시작은 이 경계를 바꾼다. 실행을 받아들이는 일과 Hook 토큰을 차지하는 일을 한 단계로 묶는다. 토큰을 가진 소유자가 있으면 중복 요청은 새 실행이 되지 않고 충돌로 돌아온다. 다만 이 보장은 토큰을 붙잡고 있는 동안의 실행 시작에 관한 것이다. 결제·발송 같은 외부 효과까지 무조건 한 번만 일어난다는 뜻은 아니다.

이 글은 공식 문서와 고정된 패키지 선언을 대조한 설계 해설이며, SDK 실행이나 장애 주입 실험 결과가 아니다. 아래 판단표와 도식은 문서의 상태 전이를 독자가 운영 분기로 옮길 수 있도록 새로 정리한 것이다.

1. 조회 결과가 아니라 시작의 소유권을 원자적으로 정한다

2026년 11월 4일 Vercel 발표는 기존 getHookByToken() 다음 start() 패턴의 중복 시작 경쟁을 설명한다. 두 요청이 같은 조회 결과를 보고 출발할 수 있으므로, “없음을 확인했다”와 “내가 유일한 소유자다”는 다른 주장이다.

원자적 시작에서는 start() 옵션의 experimental_hook.token에 안정적인 업무 식별자를 넣는다. 주문 ID나 가져오기 작업 ID처럼 같은 일을 재시도할 때도 유지되는 값이 출발점이다. 요청이 올 때마다 무작위 토큰을 새로 만들면, 시스템 입장에서는 서로 다른 일처럼 보인다. 여러 고객이 같은 주문 번호를 쓸 수 있는 서비스라면 업무 범위도 키 설계에서 구분해야 한다. 이 범위 구분은 문서의 업무 키 원칙에서 도출한 편집상 권고이지 SDK가 자동으로 보장하는 권한 검사 기능이 아니다.

같은 토큰의 두 요청이 조회 후 각각 시작할 수 있는 방식과 원자적 시작의 소유·거절을 비교한 그림

그림 1. 조회와 시작의 틈 대신 수락과 토큰 획득을 묶는다. 거절된 중복은 실행이 되지 않는다. 출처: vercel.com/changelog/atomic-start-hooks-in-the-workflow-sdk.

토큰이 이미 잡혀 있으면 HookConflictError가 발생하고 conflictingRunId로 소유자를 알 수 있다. 거절된 중복은 실행으로 생성되지 않으며 그 중복 실행의 코드도 돌지 않는다. 반면 워크플로 안에서 Hook 충돌을 뒤늦게 확인하는 기존 방식은 중복 실행 자체가 만들어지는 것까지 막지는 못한다. “실행을 만들기 전 거절”과 “실행 안에서 중복임을 알고 종료”를 로그에서 구분할 필요가 있다.

중복 요청의 목적도 나눠야 한다. 같은 주문 처리 결과를 묻는 재시도라면 소유 실행 ID를 돌려주는 것으로 충분할 수 있다. 추가 메모를 전달하는 요청이라면 소유자의 Hook으로 메시지를 보내는 분기가 필요하다. 어느 쪽인지 정하지 않고 모든 충돌을 일반 실패로 처리하면, 정상적인 중복 방지 동작까지 장애처럼 보인다.

2. 실행이 살아 있는가와 토큰이 남아 있는가는 다르다

Hook은 실행이 받아들여지는 시점부터 존재하므로, 소유 실행이 코드를 시작하기 전에도 resumeHook(token, payload)로 데이터를 보낼 수 있다. 워크플로 안에서 같은 토큰으로 createHook({ token })을 열면 이미 만들어진 시작 Hook에 연결된다. 그 전에 도착한 데이터는 보관됐다가 순서대로 전달된다. 다만 워크플로가 Hook을 열지 않으면 읽지 않은 데이터는 그대로 남는다. 토큰을 중복 시작 방지용으로만 쓰는 것과, 메시지를 실제 소비하는 설계는 별개다.

멱등성 문서의 보존 절은 기본 보존 기간이 없다고 명시한다. 보존 옵션을 주지 않으면 정상적으로 실행이 끝날 때 토큰이 풀리고, 같은 키의 나중 요청은 새 실행을 시작할 수 있다. 영구 중복 방지를 기본 제공한다고 읽으면 안 된다.

experimental_minRetention을 설정하면 실행이 끝난 뒤에도 늦게 도착한 중복을 거절할 수 있다. 기간의 기준은 완료 시점이 아니라 start() 시점이다. 문서의 30 days는 예시이지 기본값이나 모든 서비스에 대한 권장값이 아니다. 정상 종료까지 Hook을 해제하지 않는다는 조건 아래, 실행 종료와 보존 만료가 모두 충족돼야 토큰이 풀린다. 보존 기간이 먼저 지나도 실행이 아직 진행 중이면 토큰을 계속 가진다.

공유된 시작점에서 실행 종료와 보존 만료를 별도로 표시한 설명용 타임라인

그림 2. 명시적 해제가 없을 때 종료와 보존 만료를 모두 충족해야 해제된다. 막대 길이는 측정값이 아니다. 출처: workflow-sdk.dev/docs/foundations/idempotency.

실행이 끝났지만 토큰이 보존 중이면 새 시작은 거절되고, 종료된 실행에는 추가 메시지도 보낼 수 없다. 이때 resumeHook()은 HookNotFoundError를 낼 수 있다. “시작이 충돌했으니 메시지 전달은 반드시 된다”는 분기는 성립하지 않는다. 이미 끝난 소유자라면 getRun(owner)로 결과를 조회하는 방향과, 진행 중인 소유자에게 데이터를 보내는 방향을 구별해야 한다.

또한 experimental_retention은 이름이 비슷해도 다른 옵션이다. 이는 실행 완료 뒤 데이터 보존·삭제를 다루며, 시작 Hook의 토큰을 얼마나 붙잡을지 정하는 experimental_minRetention과 섞어서는 안 된다.

3. 보존 기간을 넣어도 dispose가 먼저 풀 수 있다

가장 놓치기 쉬운 반례는 작업이 아직 끝나지 않았는데 토큰을 해제하는 경우다. hook.dispose()를 호출하거나 내부 블록의 using 범위를 빠져나오면 시작 Hook의 토큰이 즉시 풀린다. 그 뒤 같은 토큰으로 새 실행이 시작될 수 있고, 기존 실행은 여전히 배송 같은 후속 작업을 수행 중일 수 있다.

보존 기간은 이 명시적 해제를 되돌리지 않는다. experimental_minRetention은 Hook을 해제하지 않은 채 실행이 끝났을 때 적용되는 보존 조건이다. “30일 보존이 있으니 dispose 뒤에도 중복이 막힌다”는 해석은 틀리다.

실행 A의 Hook 해제 뒤 같은 토큰으로 실행 B가 시작되며 A는 계속될 수 있는 반례

그림 3. 명시적 해제는 즉시 토큰을 돌려준다. 기존 실행의 후속 작업과 새 실행이 겹칠 수 있고 보존도 적용되지 않는다. 출처: workflow-sdk.dev/docs/foundations/idempotency.

전체 실행 동안 소유권을 유지하려면 문서 예시처럼 const로 시작 Hook을 열고, 실행 종료가 해제 경계를 맡도록 한다. 함수 최상위의 using은 반환할 때 해제되므로 실행 중에는 토큰을 유지하지만, 종료 후 보존이 필요할 때는 여전히 맞지 않는다. 반환 시 명시적으로 해제돼 보존 기간 대신 즉시 풀리기 때문이다.

이 차이는 코드 스타일 취향이 아니라 업무 경계다. 메모 수집 블록이 끝나는 순간부터 같은 주문의 새 실행을 허용하려는 설계라면 조기 해제가 의도일 수 있다. 반대로 배송까지 하나의 소유자가 맡아야 한다면 그 블록 끝은 너무 이른 해제 지점이다. 원자적 시작을 도입한 뒤에도 Hook의 수명과 업무 수명이 맞는지 따로 읽어야 한다.

4. 운영 분기는 오류 이름보다 상태 조합으로 정리한다

아래 표는 공식 API의 고정 정책 목록이 아니라, 문서에서 확인한 결과를 요청 처리 의사결정으로 재구성한 것이다. 특히 충돌 후 메시지 전달 실패를 무한 재시도로 덮지 않는 것이 중요하다.

관찰한 상황 상태의 의미 처리 결정
같은 토큰으로 시작 충돌 토큰 소유자 있음. 중복 실행 없음 소유 실행 ID를 보존. 결과 조회와 메시지 전달을 구분
소유자 Hook 열림 실행 전에도 수신. 이후 순서대로 소비 전달 성공을 업무 완료로 표시하지 않기
실행 종료, 보존 중 시작 거절. 메시지 수신 불가 완료 결과나 보존 상태 응답. 무한 재시도 금지
실행 중 Hook 해제 토큰 즉시 반환. 새 실행 가능 기존 업무의 종료로 추정하지 않기
시작 결과 불명확 큐 등록·실행 수락을 확정 못 함 같은 업무 토큰 유지. 후보 ID로 상태 대조
시작 응답 유실을 실패로 단정하는 판단과 같은 토큰·후보 ID·실패 단계를 유지하는 판단 비교

그림 4. 결과를 확정할 수 없으면 같은 업무 토큰과 후보 정보를 유지한다. 즉시 성공하는 복구를 보장하는 그림은 아니다. 출처: workflow-sdk.dev/docs/api-reference/workflow-api/start.

WorkflowStartError는 “아무 실행도 없다”는 증거가 아니다. start API의 상세 설명은 수락됐지만 실행 레코드로 아직 구체화되지 않은 후보나, 입력 스트림을 기다리는 실행 같은 예외도 다룬다. 따라서 같은 토큰을 유지하는 재시도가 필요하다는 원칙을 “항상 즉시 성공하는 복구”라고 바꾸면 안 된다. 후보 실행 ID와 실패 단계를 남겨 결과를 구분하는 것이 편집상 운영 권고다.

토큰이 해제된 뒤의 새 시작까지 영원히 금지되는 것도 아니다. 같은 키 재시도가 의미 있는 중복 방지가 되려면 그 키의 소유·보존 수명이 업무의 중복 허용 정책과 맞아야 한다. 이 때문에 보존 값 하나보다 종료·해제·불명확한 응답을 함께 설계하는 일이 먼저다.

5. 적용 전 확인할 버전과 보장의 바깥쪽

발표는 workflow 5.4.0으로 업그레이드하라고 안내한다. 다만 수집된 저장소 변경 기록에는 5.3.0에도 원자적 시작 항목이 있으며 그 설명은 hook이라는 이전 표기를 사용한다. 이번에 대조한 5.4.0 패키지의 타입 선언은 StartOptions.experimental_hook과 StartHookOptions.experimental_minRetention을 명시한다. 따라서 이 글은 5.4.0을 기능 최초 출시 버전이라고 부르지 않으며, 수집일이나 이 글의 예정 공개일을 기능 발표일로 바꾸지도 않는다. 실제 적용에서는 설치 버전에 맞는 선언과 예제를 맞춰야 한다.

World도 조건이다. 공식 문서는 Local·Postgres·Vercel World를 지원 대상으로 설명하고, Postgres에는 0028_add_start_hook_admissions 마이그레이션과 구버전 작업자를 먼저 멈추는 절차를 명시한다. 대상 World가 원자적 시작을, 보존 옵션을 쓸 때는 Hook 보존을 지원해야 한다. 동적 워크플로에는 이 옵션을 지원하지 않는다. 이는 문서상 적용 조건이며, 이 글에서 마이그레이션을 실행하거나 배포 환경을 검증했다는 뜻이 아니다.

마지막으로 실행 시작의 멱등성과 외부 API 호출의 멱등성을 분리해야 한다. 실행 하나가 외부 결제 응답을 잃고 내부 단계를 재시도할 수 있기 때문이다. 문서는 재시도 사이에 유지되는 stepId를 외부 API의 멱등 키로 쓰는 예를 든다. 이것도 대상 API가 해당 계약을 지원하고, 키의 범위와 보존 기간이 맞을 때의 이야기다. 원자적 시작만으로 결제나 게시가 정확히 한 번 실행된다고 결론 내릴 수 없다.

도입 판단의 핵심은 짧다. 같은 업무에는 같은 키를 쓰고, 시작 허용과 소유권 획득을 함께 처리하며, 토큰을 놓는 지점을 업무 종료와 맞춘다. 그리고 이미 끝난 실행의 보존 상태를 진행 중 실행의 메시지 수신 상태와 구별한다. 이 네 가지가 맞아야 “중복 요청은 정상적으로 같은 일에 합류했다”는 응답을 근거 있게 만들 수 있다.

이어 읽기

PostgreSQL 에이전트 큐의 소유권 설계는 작업 행의 잠금과 커밋 이후의 소유 상태를 구분한다. 이번 글의 SDK 시작 수락과는 다른 계층이지만, 토큰 획득·완료 상태·외부 효과를 한 가지 보장으로 섞지 않아야 한다는 비교에 도움이 된다.

참고 자료와 확인 범위

본문은 2026년 11월 6일 확보된 공식 원문과 고정 패키지 증거를 기준으로 작성했다. 실제 신뢰성·처리량·비용 개선 수치는 측정하지 않았다. 그림은 제품 화면이나 실험 로그가 아니라 문서 기반 자체 도식이다.

AI를 활용해 작성·편집한 글입니다.

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다