1. 개요
Next.js 16 App Router 로 사이트 세 개를 운영하며 밟은 것 중, 프레임워크 동작 자체가 원인이었던 셋을 모았다. 셋 다 “코드는 맞는데 결과가 이상하다” 였고, 최소 재현 앱을 만들어 프레임워크 동작임을 확인하고 나서야 방향이 잡혔다.
2. 핵심 내용
2-1. notFound() 를 던지면 서버 HTML 이 비어 있다
없는 slug 로 들어온 요청에 페이지가 notFound() 를 던진다. 응답은 404 다. 맞다. 그런데 curl 로 본문을 보면 스크립트 태그를 뺀 텍스트가 38자 다. 홈은 19,765자. not-found.tsx 에 만든 안내 문구와 홈 링크가 서버 HTML 에 없다.
최소 앱으로 16.3.4, 16.3.6, canary 에서 재현했다. 페이지나 레이아웃이 notFound() 를 던지면 not-found 트리는 RSC 페이로드에만 실리고 클라이언트에서 그려진다. 서버 HTML 은 빈 셸이다. 라우트 매칭 자체가 실패한 경우(아무 파일도 안 맞는 URL)만 서버 렌더된다.
catch-all 라우트([...slug])를 쓰는 사이트는 모든 잘못된 URL 이 매칭에 성공하고 페이지에서 notFound() 를 던지므로, 모든 404 가 빈 셸 이다. JS 를 실행하지 않는 클라이언트(일부 크롤러, 링크 미리보기 봇, curl)는 빈 페이지를 본다.
쓰면 안 되는 우회: “generateMetadata 에서 먼저 던지자” 는 생각을 했다. 실제로 하면 상태 코드가 200 이 되고 본문은 not-found 내용이 나온다. soft-404 다. 검색 엔진이 “없는 페이지” 를 200 으로 색인한다. 빈 404 보다 나쁘다.
결론은 현상 유지다. 페이지 본체에서만 던지고, 저장소 규칙에 “메타데이터에서 던지지 말 것” 을 적고, 업스트림 이슈를 추적한다. 그리고 검증 도구를 의심하는 법을 배웠다. curl 기반 검증이 “404 코드가 안 붙었다” 는 오진을 냈는데, 코드는 맞고 본문만 빈 것이었다. 상태 코드는 맞는데 본문이 빈 결과는 도구가 대상의 렌더 모델을 못 따라가는 신호 다.
2-2. generateStaticParams + headers() = 런타임 500
한 파일에 이 둘이 있었다.
// [...slug]/page.tsx
export async function generateStaticParams() { /* 빌드 시 slug 목록 */ }
export async function generateMetadata() {
const host = (await headers()).get("host") // 요청의 Host 로 테넌트 판정
...
}generateStaticParams 는 “이 페이지를 빌드 시점에 정적으로 그린다” 는 선언이다. headers() 는 “요청이 있어야 안다” 는 동적 API 다. 모순이다. Next 는 이걸 DYNAMIC_SERVER_USAGE 로 거부한다. 빌드는 통과 하고, 런타임에 그 페이지를 요청하면 500.
Host 로 테넌트를 정하는 라우트는 본질적으로 요청마다 다르다. 프리렌더 시도 자체가 설계 오류였다. generateStaticParams 를 지우고 export const dynamic = "force-dynamic".
그리고 이 조합이 다시 들어오지 못하게 라우트 불변식 테스트 를 뒀다. src/app 을 전부 스캔해 한 파일에 generateStaticParams 와 headers()/cookies() 가 같이 있으면 실패한다. 문제 조합을 되돌려 빨간불을 확인한 뒤 머지했다.
같은 부류의 함정 하나. CMS 화면을 별도 파드로 분리하기 전, 정적 프리렌더가 /cms/... 경로로 나가면서 Providers 게이트 조건이 서버와 클라이언트에서 갈려 하이드레이션 오류가 났다. 이것도 force-dynamic 이 답이었다. 렌더 시점에 환경(호스트, 모드)을 읽는 페이지는 정적일 수 없다.
2-3. next dev 가 CLAUDE.md 를 고친다
로컬에서 렌더만 확인하고 브랜치를 올렸는데 CLAUDE.md(코딩 에이전트가 읽는 저장소 규칙 파일)에 무관한 변경이 섞여 있었다. 네 브랜치 전부.
Next 16 의 next dev 는 프로젝트 루트의 에이전트 규칙 파일에 <!-- BEGIN:nextjs-agent-rules --> 블록을 써 넣는다. 그리고 그 블록 안의 텍스트가 “지워도 다시 생기니 함께 커밋하라” 고 지시한다.
두 가지 문제다. 첫째, 개발 서버 실행이 소스 파일을 바꾼다. 둘째, 도구가 만든 텍스트가 사람과 에이전트에게 지시를 한다. 에이전트가 읽는 파일에 도구가 자기 규칙을 주입하는 구조는 프롬프트 인젝션의 실전 사례다. 따르지 않았다.
- 되돌리는 커밋을 브랜치마다 얹고,
git diff --name-only origin/master..<branch>로 소스 파일만 남았는지 확인. - 저장소 규칙에 “
next dev가 만든 블록은 커밋하지 않는다” 를 적었다. - 로컬 렌더 확인 뒤
git status를 습관으로.
2-4. 덤: initialData 에 빈 배열
SSR 에서 목록 fetch 가 실패해 [] 를 initialData 로 넘기면, React Query 는 그걸 유효한 데이터 로 보고 staleTime 동안 클라이언트 재조회를 하지 않는다. 사용자는 빈 목록을 5분 본다. initialBranches.length ? initialBranches : undefined 로 실패는 undefined 를 넘긴다. 자세한 건 프론트 상태 판정 함정 셋에서.
3. 마무리
요약
- 페이지에서
notFound()를 던지면 서버 HTML 은 빈 셸이다.generateMetadata에서 던지는 우회는 200 soft-404 라 더 나쁘다.generateStaticParams와headers()는 같은 파일에 못 있다. 빌드는 통과, 런타임 500. 불변식 테스트로 잠근다.- Host 로 테넌트를 정하는 라우트는
force-dynamic.next dev가 에이전트 규칙 파일을 고친다. 도구가 쓴 지시는 따르지 않는다.