Skip to content

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. 결과:

  1. 빌드 시 fetchRecentDisclosures()X-Worker-Secret 헤더 없이 backend 호출
  2. backend (strict App Check 모드) 가 403
  3. getJSONSoft 가 throw 를 삼키고 null 반환
  4. "불러올 수 없습니다" 빈 상태가 정적 HTML 로 baked
  5. CF Workers 에 KV/R2 incremental cache binding 이 없어 ISR revalidate 가 사실상 동작 안 함 → 그 빈 상태가 영구 노출

해결: runtime 시크릿이 필요한 페이지는 build-time prerender 를 끈다.

ts
// 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 페이지가 "데이터 없음" 상태로 굳었을 때:

  1. 빌드 산출물 라우트 분류 확인

    bash
    cd frontend && npm run build:cf 2>&1 | grep -E "^[┌├└] [○ƒ]"

    ○ (Static) 으로 찍히면 prerender 됨. runtime secret 필요한 데이터 페이지는 ƒ (Dynamic) 이어야 함.

  2. 백엔드 직접 검증 (런타임 secret 으로)

    bash
    curl -sS "https://api.dartbrief.com/api/v1/disclosures/recent?limit=3" \
      -H "X-Worker-Secret: $WEB_SECRET"
    # 200 + 정상 데이터 → 백엔드 정상, 프론트 SSR fetch 가 secret 을 못 받았다는 뜻
  3. prod HTML 에서 빈 상태 텍스트 검색

    bash
    curl -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) 조합이 가장 안전.