IT 시행착오··약 6분

운영은 Workers만, 개발은 localhost — Cloudflare URL이 두 갈래로 갈라질 때

프로덕션은 커스텀 도메인·Workers에만 두고 로컬 3000/8787로 개발하는 구성을 정리하면서, OAuth 리다이렉트·CORS·배포 검증에서 막힌 지점을 재현합니다.

노트북 화면의 프로그램 코드 — 로컬 개발과 프로덕션 Workers 분리 맥락 (cc by-sa 3.0, wikimedia-commons)

사이드 프로젝트를 Cloudflare Workers로 운영하면서 문서를 다시 정리해 보니, 팀원에게 가장 자주 헷갈리게 만드는 문장이 하나 있었다. “Cloudflare에 개발 URL이 없다.” 프로덕션은 커스텀 도메인과 *.workers.dev 직접 URL로만 살아 있고, 기능 검증은 localhost:3000(웹)과 localhost:8787(API)에서 한다. 표면상 단순한데, OAuth 콜백·CORS·배포 후 검증까지 한 줄로 이어지지 않으면 “로컬에선 되는데 운영만 안 된다”가 반복된다.

사실: 두 계층이 완전히 갈라진다

운영 프론트는 커스텀 도메인과 Workers 직접 URL로 접근한다. API 역시 별도 Worker 호스트명에 붙어 있고, 웹 빌드 설정은 프로덕션에서 그 API에 직연결한다. 반면 개발은 npm run dev 한 번으로 로컬 웹·로컬 API(8787)·로컬 QA API(8788)가 같이 뜬다. Cloudflare 대시보드에 “스테이징 Workers”를 따로 두지 않았기 때문에, 환경 변수만 바꿔서 같은 Worker 이름에 배포하면 곧바로 프로덕션이 바뀐다.

이 구조의 장점은 비용과 복잡도가 낮다는 것이다. 단점은 “스테이징에서 한 번 더 보자”는 안전망이 없다는 점이다. 그래서 로컬에서 통과한 변경은 커밋 직후 wrangler deploy까지 이어지면 사용자 트래픽에 바로 닿는다.

이유: OAuth·CORS가 오리진 단위로 묶인다

로컬과 운영이 갈라지면 인증이 먼저 흔들린다. 예를 들어 Google OAuth는 redirect_uri가 등록된 호스트와 바이트 단위로 맞아야 한다. 서브도메인 전용 로그인 플로우를 쓰면, 콜백은 API Worker URL로 받고 토큰을 서브도메인으로 핸드오프하는 식으로 우회할 수 있다. Console에 모든 프론트 URI를 넣지 않아도 되게 설계했지만, 문서에 그 흐름이 없으면 “로컬 3000은 되는데 faketeams 서브도메인만 401” 같은 디버깅이 길어진다.

CORS 허용 목록에 예전 도메인(new. 접두)이 남아 있는 경우도 봤다. 레거시 호스트가 실제로는 301으로 메인 도메인에 합쳐졌는데, 서버 코드의 allowlist만 안 지워져 있으면 “어느 순간부터 preflight만 실패”하는 유령 버그가 된다. 오리진 분리 = 인증·CORS·쿠키(또는 localStorage) 범위를 같이 점검해야 한다는 뜻이다.

평가: 어디에 맞고, 어디에 안 맞는가

이 패턴은 개인·소규모 서비스, 트래픽이 예측 가능하고 롤백이 wrangler deploy 이전 커밋으로 가능할 때 잘 맞는다. 반대로 규제·결제·대규모 QA가 필요한 제품은 Cloudflare Preview URL, 별도 스테이징 Worker, 또는 최소한 *.workers.dev의 비프로덕션 인스턴스를 두는 편이 낫다.

블로그(seominhyuk.com)도 비슷한 철학이다. Astro는 로컬 astro dev, 프로덕션은 Workers Static Assets. Git push만으로 HTML이 바뀌지 않으면(Workers Builds 미연동 시) npm run deploy를 빼먹은 것이다. **“환경이 둘”이 아니라 “배포 경로가 둘”**이라고 팀에 말해 두는 게 덜 헷갈린다.

부연: 배포 후 30초 체크리스트

로컬에서 막히지 않았다고 끝내지 않는다. 프로덕션 도메인에서 아래만 순서대로 본다.

# 1) 메인 HTML이 최근 빌드인지 (Last-Modified 또는 본문에만 있는 문자열)
curl -sI https://example.com/ | grep -i last-modified

# 2) API 헬스 (경로는 프로젝트마다 다름)
curl -s https://api-worker.example.workers.dev/health

# 3) OAuth 시작 URL이 302로 올바른 호스트를 가리키는지
curl -sI 'https://example.com/auth/google' | grep -i location

비밀값·토큰은 로그에 남기지 않는다. 실패 시 “로컬 env와 production secret 불일치”부터 의심하고, Cloudflare 대시보드의 Variables와 로컬 .dev.vars키 이름 기준으로만 대조한다.

정리하면, 운영-only Cloudflare + 로컬 개발은 단순해 보이지만 오리진이 갈라진 순간 인증과 CORS가 제품의 일부가 된다. 문서에 URL 표 하나와 OAuth 시퀀스 다이어그램을 남겨 두면, 다음에 합류하는 나(또는 에이전트)가 같은 구덩이에 덜 빠진다.

출처

  • 내부 실측: 로컬 localhost:3000 / 8787 vs 프로덕션 Workers·커스텀 도메인 분리 구성, OAuth 핸드오프·CORS allowlist 점검 (비밀값·실명 제외)
  • Notion 시드: «리뷰올 오프아이스 구성 설명» — 운영 URL·로컬 개발 포트·레거시 도메인 메모 (민감 정보 마스킹 후 재구성)