이 블로그는 Next.js 16 output: "export"로 빌드되는 정적 사이트입니다. 어느 날 존재하지 않는 포스트 slug로 들어오는 트래픽이 호스팅의 기본 404 페이지로 떨어지는 걸 봤습니다. 빌드는 멀쩡히 통과했고, 정상 slug는 잘 응답합니다. 그런데 미정의 slug의 응답이 디자인 일관성이 깨진 일반 404였습니다. 원인을 잡고 보니 동적 라우트의 dynamicParams 기본값이 정적 export와 의미적으로 어긋난 채 방치되어 있었던 게 컸습니다.

dynamicParams 기본값이 약속하는 것#

Next.js 16 공식 문서가 명시한 동작은 짧고 분명합니다.

true (default): Dynamic route segments not included in generateStaticParams are generated at request time.

false: Dynamic route segments not included in generateStaticParams will return a 404.

dynamicParams가 true(기본값)면, generateStaticParams가 알려주지 않은 동적 세그먼트가 들어왔을 때 Next.js가 요청 시점에 새로 렌더링해서 응답하겠다고 약속합니다. 이건 SSR 또는 ISR을 가정한 동작입니다. getStaticPathsfallback: true | blocking을 대체한 옵션이라는 점도 같은 문서에 적혀 있습니다.

여기까지만 보면 default값이 합리적입니다. 빌드 시점에 모든 slug를 다 알 수 없는 일반적인 동적 사이트라면, 새 slug가 들어왔을 때 런타임에 렌더하는 쪽이 자연스럽습니다.

정적 export 환경에서 의미가 어긋난다#

문제는 output: "export" 모드에 있습니다. 정적 export는 빌드 시점에 모든 페이지가 HTML로 굳어져서 out/ 디렉터리로 떨어집니다. 런타임이 없고, 요청 시점 렌더링이라는 개념 자체가 사라집니다. 이 사이트의 호스팅 셋업은 Cloudflare Pages 정적 배포 글에 정리해 둔 그대로, CDN이 정적 파일만 서빙합니다.

이 환경에서 dynamicParams = true가 약속한 "요청 시점 렌더링"은 일어날 수 없습니다. 빌드되지 않은 slug는 결국 호스팅의 fallback 404 처리로 떨어집니다. Cloudflare Pages는 매칭되는 정적 파일이 없을 때 자체 404 페이지를 응답합니다. Next.js가 빌드한 out/404.html이 있어도, 동적 라우트 슬롯에는 그게 자동으로 매핑되지 않는 경우가 있습니다.

코드 쪽 의도와 호스팅 쪽 동작이 따로 노는 상태였습니다. default값을 그대로 두면 Next.js의 디자인이 입혀진 404 페이지 대신 호스팅의 기본 404가 나옵니다. 사용자가 보는 결과물이 사이트 톤과 어긋납니다.

false로 명시했을 때 얻는 것#

해결은 동적 라우트 파일에 한 줄을 추가하는 일이었습니다.

// app/posts/[slug]/page.tsx
export const dynamicParams = false;

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

dynamicParams = false는 build-time params만 인정하고, 나머지는 모두 404로 보내겠다는 명시적 선언입니다. 정적 export에서는 이게 의도와 정확히 일치합니다. Next.js는 빌드 시 generateStaticParams가 반환한 slug 전체로 HTML을 만들고, 다른 어떤 slug도 빌드 산출물에 등장하지 않습니다.

명시해서 얻는 건 두 가지입니다.

첫째, 코드를 읽는 사람이 이 라우트의 약속을 한 줄로 파악합니다. "이 동적 라우트는 빌드 시 모든 후보가 결정된다, 그 밖은 404다"가 코드에 적혀 있습니다.

둘째, 정적 export와 호스팅 셋업 사이의 모호함이 사라집니다. 빌드 산출물에 없는 slug는 Next.js 정의상 404로 떨어지도록 의도된 것이고, 호스팅의 generic 404로 빠지는 게 디자인 회귀가 아니라 의도된 동작임을 코드가 보장합니다.

notFound()와 결합#

dynamicParams = false만으로는 충분하지 않은 경우가 있습니다. generateStaticParams가 빌드 시 slug 목록을 만들었더라도, 그 사이에 콘텐츠가 삭제되거나 published 플래그가 바뀌어 fetch 시점에 null이 돌아올 수 있습니다. 이런 race를 위해 페이지 본문에서 notFound()를 함께 호출합니다.

// app/posts/[slug]/page.tsx
export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) {
    notFound();
  }
  // ...
}

notFound()를 호출하면 Next.js는 가장 가까운 not-found.tsx로 렌더링을 넘깁니다. 이 사이트는 app/not-found.tsx를 두어 디자인 톤이 일관된 404 화면을 보여줍니다. 빌드 시점에는 out/404.html이 함께 떨어지고, Cloudflare Pages는 정적 자산이 매칭되지 않을 때 이 페이지로 응답합니다.

카테고리 라우트도 같은 패턴입니다. generateStaticParams로 알려진 카테고리 외에는 빌드되지 않고, 그래도 만에 하나 다른 값이 들어오면 isPostCategory 가드가 notFound()를 호출합니다.

// app/posts/category/[category]/page.tsx
export const dynamicParams = false;

export function generateStaticParams() {
  return POST_CATEGORIES.map((category) => ({ category }));
}

export default async function CategoryPage({ params }: CategoryPageProps) {
  const { category } = await params;
  if (!isPostCategory(category)) {
    notFound();
  }
  // ...
}

dynamicParams = false는 라우트 단위 약속, notFound()는 핸들러 단위 가드입니다. 두 개가 같이 있어야 빌드 시점과 페이지 본문 양쪽에서 404 동작이 명시됩니다.

빌드 산출물에서 확인하기#

코드를 바꿨다면 결과물도 보고 싶습니다. 정적 export는 out/ 디렉터리를 들여다보면 답이 빠르게 나옵니다.

out/404.html이 빌드 시 만들어졌는지, 그리고 out/posts/ 아래에 generateStaticParams가 반환한 slug 디렉터리만 있는지 확인하면 됩니다.

ls out/posts/ | wc -l
ls out/404.html

out/posts/ 항목 수가 getAllPosts()가 반환하는 포스트 수와 일치해야 정상입니다. 404 HTML이 같이 떨어져 있어야 Cloudflare Pages가 미매칭 경로를 이 페이지로 회수합니다. 한 번의 빌드 검증으로 라우팅 약속과 실제 산출물이 맞는지 확인합니다.

정적 export에서의 디폴트 선택#

dynamicParams = false는 정적 export 환경에서 코드의 의도를 명시하는 한 줄짜리 도구입니다. 기본값을 두면 빌드는 통과하지만, 호스팅 fallback과 Next.js 404 UI 사이가 미묘하게 어긋납니다. 명시해 두면 라우트의 약속이 코드에 적히고, 빌드 산출물의 모양이 그 약속과 정확히 맞물립니다. 정적 export로 사이트를 운영한다면 모든 동적 라우트에 일관되게 dynamicParams = false를 두는 쪽이 안전합니다.