Next.js on Cloudflare Workers 함정
frontend/ (Next.js 16 + @opennextjs/cloudflare + CF Workers + Static Assets) 운영 중 만난 이슈.
1. Build-time prerender 가 runtime secret 을 못 본다
증상: prod / 와 /disclosures 가 항상 "공시 목록을 불러올 수 없습니다" 라는 빈 상태로만 표시. 직접 curl 해도 동일. 백엔드 로그엔 그 페이지 fetch 흔적 자체가 없음. 한편 /disclosures/[rceptNo] 같은 dynamic param 페이지는 정상.
원인: Next 16 App Router 의 default 렌더 모드는 ISR (정적 prerender + revalidate). export const revalidate = N 만 있고 dynamic segment 가 아니면 빌드 시점에 prerender 됨. CF Workers 의 secret (WEB_SECRET, JWT_SECRET 등 dashboard 등록 secret) 은 runtime 에만 주입되므로 빌드 컨테이너에선 process.env.WEB_SECRET === undefined. 결과:
- 빌드 시
fetchRecentDisclosures()→X-Worker-Secret헤더 없이 backend 호출 - backend (strict App Check 모드) 가 403
getJSONSoft가 throw 를 삼키고null반환- "불러올 수 없습니다" 빈 상태가 정적 HTML 로 baked
- CF Workers 에 KV/R2 incremental cache binding 이 없어 ISR revalidate 가 사실상 동작 안 함 → 그 빈 상태가 영구 노출
해결: runtime 시크릿이 필요한 페이지는 build-time prerender 를 끈다.
// app/page.tsx, app/disclosures/page.tsx
export const dynamic = "force-dynamic"; // 빌드 prerender X
export const fetchCache = "default-cache"; // fetch.next.revalidate 존중
// export const revalidate = 60; ← 제거⚠️ force-dynamic 만 쓰면 fetch 의 default cache 가 no-store 로 떨어져 next.revalidate=60 가 무력화 → 매 요청마다 backend hit. 반드시 fetchCache = "default-cache" 도 같이 선언해서 fetch 의 명시적 cache/revalidate 옵션이 살아있게.
fetch() 의 next: { revalidate: 60 } 는 그대로 유지 — 페이지 렌더는 런타임이지만 백엔드 응답은 데이터 캐시에 60초 보관 → backend 부하 ≤ 1 req/min 유지.
왜 detail 페이지는 영향 없었나: /disclosures/[rceptNo] 같이 dynamic segment 가 있고 generateStaticParams 도 없으면 Next 가 빌드 시 어떤 path 를 prerender 해야 할 지 모름 → on-demand SSR. runtime 에 처음 요청 들어올 때 비로소 렌더 → 그 시점엔 secret 살아있음.
2. 진단 체크리스트
prod 페이지가 "데이터 없음" 상태로 굳었을 때:
빌드 산출물 라우트 분류 확인
bashcd frontend && npm run build:cf 2>&1 | grep -E "^[┌├└] [○ƒ]"○ (Static)으로 찍히면 prerender 됨. runtime secret 필요한 데이터 페이지는ƒ (Dynamic)이어야 함.백엔드 직접 검증 (런타임 secret 으로)
bashcurl -sS "https://api.dartbrief.com/api/v1/disclosures/recent?limit=3" \ -H "X-Worker-Secret: $WEB_SECRET" # 200 + 정상 데이터 → 백엔드 정상, 프론트 SSR fetch 가 secret 을 못 받았다는 뜻prod HTML 에서 빈 상태 텍스트 검색
bashcurl -sS https://www.dartbrief.com/ | grep -E "불러올 수 없|empty|아직"매칭되면 빌드 시 prerender 된 것. 매칭 없고 실제 항목 보이면 정상.
3. 일반화 — runtime 만 살아있는 값이 SSR 에 필요한 모든 페이지
같은 패턴이 발생하는 케이스 (모두 force-dynamic 필요):
- CF Workers secret (
WEB_SECRET,JWT_SECRET, etc.) — 빌드 시 unset - CF Workers env binding (
AI,KV,R2,D1) — 빌드 시 binding 자체가 없음 - 요청 헤더 / 쿠키 / IP 의존 — 본질적으로 build-time 에 알 수 없음 (Next 가 자동 dynamic 처리해주긴 함)
- 현재 시각 의존 데이터 (e.g. "오늘의 공시") — prerender 시점이 prod 시점과 다름
process.env.X 가 빌드와 런타임에서 다른 모든 케이스가 후보. 의심되면 dynamic = "force-dynamic" + 데이터 캐시 (fetch.next.revalidate) 조합이 가장 안전.