오늘 나는 API 문서를 믿었다가 세 시간을 날렸다.

Contents Hub — AI가 매일 두 번 다이제스트를 발송하는 개인 뉴스레터 서비스 — 에 블로그 자동 배포 기능을 붙이고 있었다. 아이디어는 단순했다. 저녁 다이제스트를 발송하고 나면, 그날 수집된 인사이트를 바탕으로 AI가 블로그 글을 써서 Cloudflare Pages에 올린다. 매일 밤 자동으로.

문제는 '어떻게 올리느냐'였다.

Cloudflare Pages Direct Upload API — 희망편

Cloudflare는 Direct Upload API를 제공한다. git 연동 없이, CI/CD 없이, 그냥 HTTP 요청 하나로 정적 파일을 배포할 수 있다는 거다. 공식 문서에는 이렇게 나와 있다:

POST /accounts/{account_id}/pages/projects/{project}/deployments
Content-Type: multipart/form-data

manifest 필드에 파일 경로와 SHA256 해시를 JSON으로 담고, 각 파일을 해시명으로 multipart 필드에 붙이면 된다. 응답으로 deployment URL이 날아온다. 끝.

나는 이걸 Python httpx로 구현했다. 코드는 깔끔했다. 응답도 200 OK였다. success: true도 찍혔다. deployment URL도 반환됐다. 완벽했다.

Cloudflare Pages Direct Upload API — 현실편

배포된 URL을 열었다.

HTTP 404

처음엔 전파 시간 문제라고 생각했다. 1분 기다렸다. 404. 5분. 404. 10분. 404. wrangler로 배포 목록을 확인하니 'Production / success'라고 나온다. 근데 왜 404야.

CF API로 배포 상세를 조회했더니 이상한 걸 발견했다:

stages: {
  queued: "active",
  initialize: "idle",
  clone_repo: "idle",
  build: "idle",
  deploy: "success"
}

deploy: success인데 queued: active다. 이게 무슨 말이야. 배포는 성공했는데 큐에는 여전히 걸려있다? 파일이 올라갔지만 Cloudflare가 아직 처리 중인 건가? 아니면 파일이 아예 안 올라간 건가?

진단이 어려웠다. CF Pages 배포 상태가 모순적인 값을 반환하고 있으니까.

삽질의 목록

이후 한 시간 동안 여러 접근법을 시도했다:

POST /deployments 후 /finalize 호출 → 405 Method Not Allowed

/finalize가 없다니. 그럼 upload 엔드포인트는?

POST /deployments/{id}/upload → 405 Method Not Allowed

이것도 없다. 그럼 도대체 파일을 어떻게 올리라는 거지.

wrangler 소스코드를 떠올려봤다. 예전에 본 것 같은데... JWT 토큰 받아서 별도 upload.assets.cloudflare.com에 올리는 방식이었나? 응답에서 JWT를 찾아봤다. 없다. 이 버전에선 다른 방식인가.

근본 원인

결국 깨달았다. 내가 쓴 코드는 기술적으로 틀리지 않았다. API도 올바른 엔드포인트다. 200 OK도 진짜다. 그런데 파일이 서빙되지 않는 이유는 단 하나 — 배포가 "queued" 상태로 영원히 멈춰있기 때문이다.

왜 그럴까. 추측이지만: CF Pages Direct Upload는 내부적으로 일반 빌드 파이프라인과 같은 상태 기계를 사용한다. 직접 업로드 방식이라면 clone_repo, build 단계를 건너뛰고 바로 deploy 단계로 가야 하는데, 무언가 그 트리거가 제대로 발동하지 않은 것 같다. 공식 문서에는 이 부분이 명확히 설명되어 있지 않다.

wrangler CLI는 어떻게 하는 걸까. 내부 구현이 다를 것이다. 아마 다른 엔드포인트, 다른 인증 흐름을 쓰거나, 내부 API를 쓰거나.

해결책 — 왜 복잡하게 하나

결론은 단순했다. 그냥 wrangler를 쓰자.

wrangler는 이미 검증됐다. 다른 프로젝트들 — munguen.pages.dev, econipass-domain.pages.dev — 모두 wrangler로 잘 배포되고 있다. 왜 굳이 undocumented API를 직접 구현하려 했을까.

문제는 Contents Hub 백엔드가 Docker 컨테이너 안에서 돌아간다는 거였다. 컨테이너엔 wrangler가 없다. VM 호스트에 wrangler를 설치했지만 컨테이너 안에선 접근할 수 없다.

해결책:

Dockerfile에 Node.js 20 + wrangler를 추가한다.

RUN apt-get update && apt-get install -y curl gnupg ... \
    && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
    && apt-get install -y nodejs \
    && npm install -g wrangler@3 --silent

그리고 Python에서 subprocess로 wrangler를 호출한다:

cmd = ["wrangler", "pages", "deploy", tmpdir,
       "--project-name", project,
       "--branch", "main",
       "--commit-dirty=true"]
env = {**os.environ, "CLOUDFLARE_API_TOKEN": token}
proc = await asyncio.create_subprocess_exec(*cmd, env=env, ...)

파이썬이 HTML 파일들을 임시 디렉토리에 쓰고, wrangler가 그걸 읽어서 Cloudflare에 올린다. 인증은 환경변수 CLOUDFLARE_API_TOKEN으로.

빌드 후 테스트:

INFO: CH blog deployed: https://contents-hub-blog.pages.dev (3 posts)
result: True

브라우저로 열었다. 페이지가 뜬다.

교훈

세 가지를 배웠다.

첫째, API 문서의 'works' 예제가 '실제로 작동한다'를 보장하지 않는다. 특히 undocumented 또는 semi-documented API는. CF Pages Direct Upload API는 공식 문서가 있고, 요청이 200 OK를 반환하고, 응답에 success: true가 찍힌다. 그런데 실제로 서빙이 안 된다. 이런 API를 신뢰하는 건 위험하다.

둘째, 검증된 도구가 있다면 그걸 쓰는 게 낫다. wrangler는 이미 잘 작동하고 있었다. '컨테이너에 없다'는 이유로 삽질을 선택한 게 실수였다. 컨테이너에 추가하면 그만인데.

셋째, Docker 이미지 크기와 기능 사이의 트레이드오프. Node.js + wrangler를 추가하면 이미지가 커진다. 하지만 신뢰성을 얻는다. 여기서는 신뢰성이 이미지 크기보다 중요하다.

최종 결과

이제 매일 17:30 KST에 저녁 크롤이 끝나면, AI가 그날의 콘텐츠로 블로그 글을 생성하고, wrangler가 Cloudflare Pages에 자동으로 배포한다. https://contents-hub-blog.pages.dev 에서 확인할 수 있다.

API 문서를 믿지 마라. 실제로 써봐라. 그리고 검증된 도구가 있다면 그냥 써라.