
배포 버튼을 누르고 5분 만에 에러 세 개가 동시에 터졌다.
콘텐츠 타입 분리 작업을 마치고 staging에 배포했는데, 로컬에서 멀쩡하던 페이지들이 하나둘 깨지기 시작했다. Next.js 16 canary를 쓰면서 ISR 설정을 적용한 직후였고, 세 가지 문제가 동시에 터졌다. 하나하나 파헤쳐 보니 모두 공식 문서에 명시되지 않은 엣지 케이스였다.
포트폴리오 목록 페이지에서 카테고리 필터를 searchParams로 받고 있었다. 동시에 ISR을 적용하려고 export const revalidate = 60을 설정했더니 빌드 타임에 DYNAMIC_SERVER_USAGE 에러가 터졌다.
원인은 단순했다. searchParams를 사용하는 순간 Next.js는 그 페이지를 자동으로 fully dynamic으로 처리한다. 요청마다 쿼리스트링이 달라질 수 있으니 정적 생성이 불가능한 것이다. 여기에 revalidate("나를 정적으로 캐시해줘")를 동시에 설정하면 "동적이면서 정적"이라는 모순이 생겨서 에러가 발생한다.
// Before — DYNAMIC_SERVER_USAGE 에러
export const revalidate = 60
export default async function Page({ searchParams }) { ... }
// After — searchParams가 있으면 자동 dynamic
// revalidate와 NEXT_PHASE 가드 모두 제거
export default async function Page({ searchParams }) { ... }해결은 간단했다. revalidate와 NEXT_PHASE 가드를 모두 제거했다. searchParams를 쓰는 페이지는 어차피 매 요청마다 서버에서 렌더링되니까 ISR 설정 자체가 무의미하다.
기존 /blog 경로를 /tech-blog로 리다이렉트하는 페이지를 만들었다. permanentRedirect()를 사용했고, HTTP 308 응답이 가야 하는데 실제로는 200 상태 코드에 <meta http-equiv="refresh">로 리다이렉트가 되고 있었다.
범인은 같은 디렉토리에 있던 loading.tsx였다. Next.js는 loading.tsx가 있으면 해당 페이지를 Suspense boundary로 감싸서 스트리밍을 시작한다. 스트리밍이 시작되면 이미 HTTP 200 응답 헤더가 전송된 상태이므로, 이후에 permanentRedirect()가 호출되어도 308 헤더를 보낼 수 없다. Next.js는 이 상황에서 meta refresh 태그로 폴백한다.
해결책은 리다이렉트 전용 라우트에서 loading.tsx를 삭제하는 것이었다. 어차피 리다이렉트만 하는 페이지에 로딩 UI가 필요할 리 없다.
Kubernetes의 헬스체크용 /api/health 라우트에서 NextResponse.json()을 사용하고 있었는데, Next.js 16에서 간헐적으로 타입 관련 경고가 나왔다. Web API 표준 Response를 직접 사용하는 방식으로 전환했다.
// Before
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok' })
}
// After — native Response
export async function GET() {
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
})
}큰 변경은 아니지만, 외부 의존성 없이 Web 표준 API만 사용하면 프레임워크 버전 업그레이드에서 깨질 일이 줄어든다.
Next.js App Router의 렌더링 전략은 페이지가 사용하는 API에 의해 암묵적으로 결정된다. searchParams를 읽으면 dynamic, draftMode()를 호출해도 dynamic이 된다. 이런 암묵적 규칙들은 문서에 명확히 정리되어 있지 않아서, canary 버전을 쓸 때는 직접 부딪혀보는 수밖에 없었다.
loading.tsx 문제는 특히 디버깅이 어려웠다. 브라우저에서는 리다이렉트가 되니까 "잘 되는 것 같은데?"라고 생각하기 쉬운데, 실제로는 SEO 크롤러가 308이 아닌 200을 받고 있었다. curl -I로 응답 헤더를 직접 확인하는 습관이 필요하다.
canary 버전의 프레임워크를 프로덕션에 쓰는 건 이런 리스크가 따른다. 하지만 PayloadCMS가 Next.js 16.2.0-canary.10+를 요구하는 상황이라 선택의 여지가 없었다. 대신 발견한 문제를 즉시 기록해두면 같은 함정을 반복하지 않을 수 있다.
불가능하다. searchParams를 사용하는 페이지는 Next.js가 자동으로 fully dynamic으로 처리한다. revalidate를 설정하면 DYNAMIC_SERVER_USAGE 에러가 발생한다. 필터링 로직을 클라이언트 사이드 useSearchParams()로 옮기면 ISR과 병행할 수 있다.
loading.tsx가 있으면 Next.js가 Suspense boundary로 감싸서 스트리밍 응답을 먼저 시작한다. 이미 200 상태로 응답이 시작된 후에는 308 리다이렉트 헤더를 보낼 수 없다. 결과적으로 HTML 내 meta refresh로 폴백되어 SEO와 UX 모두 손해를 본다.
동작은 하지만 native Response를 사용하는 것이 더 안정적이다. 특히 health check처럼 단순한 API 라우트에서는 new Response(JSON.stringify(...))가 의존성도 적고 호환성 문제도 없다.