동적 라우트 생성
대괄호 폴더로 단일·다중 동적 세그먼트를 만들고 params와 generateStaticParams로 상세 페이지를 구성합니다.
4장에서는 URL의 일부가 바뀌는 동적 라우트와 중첩 레이아웃, Catch-all 세그먼트, 라우트 그룹을 다룹니다. 앞 장에서는 정적인 URL 경로를 가진 페이지를 만들어 이었지만, 실제 서비스에서는 블로그 상세(/blog/nextjs-basics), 상품 상세(/products/luxury-watch-xyz), 사용자 프로필(/users/alice)처럼 URL의 일부가 데이터에 따라 바뀌는 경우가 훨씬 많습니다. Next.js App Router는 이를 위해 동적 라우트(Dynamic Routes)를 제공하며, 이 글에서는 동적 라우트를 만들고 URL에서 값을 꺼내 페이지 컴포넌트에서 쓰는 방법을 자세히 알아봅니다.
앞 장의 학습 프로젝트에서 src/app/layout.tsx와 개발 명령을 먼저 확인하세요. 아래 실습은 같은 프로젝트에 라우트 파일을 만드는 작업입니다. 라우트 파일의 이름, 브라우저가 요청한 URL, await params의 결과를 차례로 대조하면 값이 어디서 생겼는지 설명할 수 있으며, 파일 이름의 대괄호는 URL에 그대로 입력하는 문자가 아닙니다. 이 장은 Next.js 16 App Router의 기본 설정을 기준으로 하므로, cacheComponents를 따로 켰을 때 더 필요한 Suspense와 캐시의 요건은 여기서 다루지 않습니다.
동적 라우트의 개념과 필요성
정적 라우트가 /about이나 /dashboard/settings처럼 고정된 URL을 가진다면, 동적 라우트는 URL의 특정 부분이 변수처럼 동작합니다. 블로그 게시물의 /blog/my-first-post와 /blog/nextjs-deep-dive, 상품 상세의 /products/12345와 /products/67890, 사용자 프로필의 /users/john-doe와 /users/jane-smith에서 /blog/, /products/, /users/는 고정되어 있지만, 그 뒤의 my-first-post, 12345, john-doe 같은 값은 항목마다 달라집니다. Next.js는 이 동적인 부분을 알아채 그 값을 페이지 컴포넌트에 넘겨, 항목마다 다른 내용을 렌더링하게 돕습니다.
동적 라우트 만들기: 대괄호 [] 사용
Next.js App Router에서 동적 라우트 세그먼트를 정의하려면 폴더 이름을 대괄호([])로 감쌉니다. 대괄호 안의 이름이 매개변수(Parameter)의 키(key)가 됩니다. 블로그 게시물의 상세 페이지를 만든다고 해 봅시다. src/app 안에 blog 폴더를 만들고(없다면), 그 안에 [slug]라는 이름의 폴더를 만든 뒤, 그 안에 page.tsx를 만듭니다. 여기서 slug가 동적인 값이 들어갈 매개변수의 이름입니다.
src/app/blog/[slug]/page.tsx는 params라는 prop으로 URL에서 꺼낸 동적인 값을 받습니다.
// src/app/blog/[slug]/page.tsx
interface BlogDetailPageProps {
params: Promise<{
slug: string; // [slug] 폴더 이름과 일치해야 합니다.
}>;
}
// 서버 컴포넌트로 작동하며, params를 prop으로 받습니다.
export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
const { slug } = await params; // URL에서 slug 값을 추출
// 실제 애플리케이션에서는 이 slug 값을 사용하여
// 데이터베이스나 API에서 해당 게시물의 내용을 불러올 것입니다.
// 예: const post = await getPostBySlug(slug);
return (
<div>
<h1>블로그 게시물: {slug}</h1>
<p>
현재 보고 계신 게시물의 식별자(slug)는 <strong>{slug}</strong> 입니다.
</p>
<p>여기에 실제 게시물 내용이 표시됩니다.</p>
</div>
);
}개발 서버가 실행 중인 상태에서 http://localhost:3000/blog/my-first-post, /blog/nextjs-deep-dive, /blog/welcome-to-my-blog에 차례로 들어가 보세요. URL마다 페이지의 제목과 내용에 slug 값이 다르게 보입니다. Next.js 16에서 params는 Promise이므로 await params로 값을 꺼냅니다.
더 깊이: params가 Promise로 바뀐 이유
Next.js 14까지 params는 평범한 객체였습니다. Next.js 15 발표는 이것을 Promise로 바꾼 이유를 이렇게 설명합니다. 전통적인 서버 렌더링은 요청이 들어온 뒤에야 렌더링을 시작하지만, 모든 컴포넌트가 요청에 딸린 정보를 쓰는 것은 아니므로 요청이 오기 전에 준비할 수 있는 부분은 미리 준비하는 편이 좋습니다. 그러려면 렌더링 중 어느 지점에서 요청을 기다려야 하는지를 프레임워크가 알아야 하므로, 요청에 딸린 값을 읽는 params, searchParams, cookies(), headers(), draftMode()를 비동기로 바꾼 것입니다. await params를 쓰는 자리가 곧 요청 정보를 기다리는 자리가 됩니다.
Next.js 15는 옮겨 가는 기간을 주려고 동기 접근을 경고와 함께 임시로 허용했지만, Next.js 16에서는 동기 접근이 완전히 없어졌습니다. 그래서 예전 글처럼 params.slug를 바로 읽는 코드는 Next.js 16에서 동작하지 않습니다. async로 만들 수 없는 클라이언트 컴포넌트 페이지에서는 React의 use(params)로 값을 꺼내고, 페이지가 아닌 하위 클라이언트 컴포넌트에서는 useParams 훅을 쓸 수 있습니다.
여러 개의 동적 세그먼트
한 라우트에 동적 세그먼트를 여러 개 둘 수도 있습니다. 예를 들어 특정 카테고리 안의 블로그 게시물을 나타내는 /blog/[category]/[slug] 같은 경로를 만들 수 있습니다. 이것은 앞의 세그먼트 하나짜리 예제와 다른 별개의 설계입니다. 같은 blog 바로 아래에 [slug]와 [category]를 함께 두면 동적 키의 이름이 충돌하므로, 두 구조를 동시에 더하지 마세요.
src/app/blog/[category]/[slug]/page.tsx에는 다음 내용을 작성합니다.
interface CategoryBlogDetailPageProps {
params: Promise<{
category: string; // [category] 폴더 이름과 일치
slug: string; // [slug] 폴더 이름과 일치
}>;
}
export default async function CategoryBlogDetailPage({ params }: CategoryBlogDetailPageProps) {
const { category, slug } = await params;
return (
<div>
<h1>카테고리: {category}</h1>
<h2>게시물: {slug}</h2>
<p>
이 게시물은 <strong>{category}</strong> 카테고리에 속하며, 식별자는 <strong>{slug}</strong> 입니다.
</p>
</div>
);
}이제 http://localhost:3000/blog/web-dev/understanding-react와 /blog/frontend/nextjs-features에 들어가 보면, URL의 category와 slug 부분이 정확히 꺼내져 페이지에 보입니다.
NEXT · CH4-1
폴더 이름이 params 키를 정합니다
/blog/web-dev/understanding-react를 blog/[category]/[slug]로 받습니다. 고정된 blog는 키가 아니고, 두 동적 값은 await params 뒤에 읽습니다.
한 세그먼트 blog/[slug]와 이 두 세그먼트 구조는 별개 설계입니다. 같은 blog 아래에 이름이 다른 동적 폴더를 동시에 두지 않습니다.
위 그림은 동적 폴더의 이름과 params 키의 대응을 보여 줍니다. 그림의 요약처럼 /blog/web-dev/understanding-react를 blog/[category]/[slug]로 받으면, 고정된 blog는 키가 아니고 두 동적 값은 await params 뒤에 읽습니다. 고정 /blog/ 뒤의 두 조각 가운데 web-dev는 [category] 폴더에 대응해 category: "web-dev"가 되고, understanding-react는 [slug] 폴더에 대응해 slug: "understanding-react"가 됩니다. 그림의 마지막 설명처럼 세그먼트 하나짜리 blog/[slug]와 이 두 세그먼트 구조는 서로 다른 설계이므로, 같은 blog 아래에 이름이 다른 동적 폴더를 동시에 두지 않습니다.
다시 말해 이 URL이 두 세그먼트 설계로 매칭되면, 고정된 blog를 뺀 첫 값은 [category]에, 다음 값은 [slug]에 대응하므로 await params 뒤의 값은 { category: 'web-dev', slug: 'understanding-react' }입니다. 폴더에 쓴 이름이 키이고 URL로 들어온 문자열이 값이므로, 동적 폴더의 이름만 category에서 topic으로 바꾸면 URL의 모양은 같아도 코드에서 읽어야 할 키가 달라집니다(Next.js 문서의 동적 세그먼트).
generateStaticParams 함수
동적 라우트를 가진 페이지를 정적으로 생성(Static Site Generation, SSG)하려면, Next.js가 빌드할 때 어떤 params 값으로 페이지를 만들지 알아야 합니다. 이때 generateStaticParams 함수를 쓰며, 이 함수는 라우트의 page.tsx나 layout.tsx에서 내보낼 수 있습니다. 다음 코드는 처음의 blog/[slug]/page.tsx 예제에 더하는 것이며, 바로 앞의 여러 세그먼트 구조와 함께 쓰는 코드가 아닙니다.
// ... (BlogDetailPage 컴포넌트 코드) ...
// SSG를 위해 빌드 시 어떤 slug 값을 생성할지 명시
export async function generateStaticParams() {
const posts = [
{ slug: 'my-first-post' },
{ slug: 'nextjs-deep-dive' },
{ slug: 'understanding-server-components' },
];
// 반환 값은 { slug: '...' } 형태의 객체 배열이어야 합니다.
return posts;
}이 예제는 빌드할 때 알려진 세 slug를 후보로 주어 그 경로들을 미리 렌더링합니다. 다만 데이터나 런타임 API를 쓰는지에 따라 렌더링 방식이 달라질 수 있으므로, 이 함수가 있다는 사실만으로 모든 경로가 완전한 정적 HTML이 되는 것은 아닙니다.
목록에 없는 slug를 허용할지는 dynamicParams로 정합니다. 기본값 true는 요청이 들어왔을 때 처리하는 것을 허용하고, false는 등록하지 않은 경로를 허용하지 않습니다. 경로가 매칭되어도 실제 게시물이 없다면, 데이터를 조회한 뒤 notFound() 같은 분기가 필요합니다.
개발 서버에서는 동적 경로를 방문할 때도 이 함수를 부를 수 있습니다. 그래서 npm run dev에서 제목이 바뀌는 것을 확인한 것과 프로덕션 빌드의 사전 렌더링 결과를 확인한 것은 서로 다른 범위이며, 등록하지 않은 slug를 시험할 때는 dynamicParams와 데이터가 없을 때의 분기를 함께 확인합니다. next build는 관련 페이지와 레이아웃을 만들기 전에 이 함수를 부르고, ISR로 재검증할 때는 generateStaticParams를 다시 실행하지 않습니다(Next.js 문서의 generateStaticParams). 이 글은 API의 규칙과 코드의 구성을 설명할 뿐, 특정 로딩 시간을 측정하지는 않았습니다.
정리
- 폴더 이름을
[slug]처럼 대괄호로 감싸면 동적 라우트 세그먼트가 되고, 대괄호 안의 이름이params의 키가 됩니다. - Next.js 16에서
params는 Promise이므로 서버 컴포넌트에서await params로 값을 꺼냅니다. [category]/[slug]처럼 동적 세그먼트를 여러 개 둘 수 있지만, 같은 위치에 이름이 다른 동적 폴더를 함께 두지 않습니다.generateStaticParams는 빌드할 때 미리 렌더링할params목록을 돌려주고, 목록에 없는 경로의 허용 여부는dynamicParams로 정합니다.- 경로가 매칭되어도 데이터가 없을 수 있으므로
notFound()같은 분기를 따로 둡니다.
연습 문제
1. 고정 경로, 동적 경로, 쿼리
별도의 설계 src/app/products/[productId]/reviews/[reviewId]/page.tsx가 /products/p-42/reviews/r-7?sort=recent를 받는다고 가정합니다. 페이지가 기다려야 하는 prop, 그 뒤의 두 키와 값, sort가 이 두 경로 값에 들어가는지 예측해 보세요. 매칭에 성공했다는 것만으로 상품과 리뷰가 실제로 있다고 판단해도 될까요?
해설 보기
페이지는 params Promise를 기다린 뒤 productId: 'p-42', reviewId: 'r-7'를 읽습니다. products와 reviews는 고정 폴더이므로 키가 아닙니다. sort=recent는 쿼리 문자열이라 경로 params의 두 값에 섞이지 않으며, 쿼리를 쓰려면 페이지가 받는 별도의 searchParams를 씁니다.
라우트가 매칭된 뒤에도 데이터가 있는지와 접근 조건을 확인해야 합니다. 이 문제는 키와 값의 대응을 연습하는 것일 뿐, 실제 DB 조회를 더한 코드가 아닙니다.
2. 같은 위치의 두 동적 폴더
src/app/blog/[slug]/page.tsx가 이미 있는데, 카테고리 기능을 위해 src/app/blog/[category]/[slug]/page.tsx를 함께 더했습니다. 어떤 문제가 생길까요?
해설 보기
같은 blog 바로 아래에 [slug]와 [category]라는 이름이 다른 동적 폴더가 함께 있게 되어, Next.js가 같은 위치의 동적 세그먼트 이름이 서로 다르다는 오류를 냅니다. 두 구조는 별개의 설계이므로 하나만 고르거나, 카테고리 경로를 /blog/category/[category]/[slug]처럼 고정 세그먼트로 나누어 충돌하지 않게 합니다.
3. 목록에 없는 slug
generateStaticParams가 세 slug만 돌려주고, 페이지에 export const dynamicParams = false를 더했습니다. 프로덕션에서 /blog/unknown-post에 들어가면 어떻게 될까요? dynamicParams가 기본값 true라면 어떻게 달라질까요?
해설 보기
dynamicParams가 false이면 목록에 없는 경로를 허용하지 않으므로 /blog/unknown-post는 404가 됩니다. 기본값 true라면 요청이 들어왔을 때 그 경로를 처리하므로 페이지가 렌더링되며, 이 예제는 slug를 그대로 보여 주기만 하므로 “블로그 게시물: unknown-post”가 보입니다.
실제 게시물을 조회하는 코드라면, true일 때 데이터가 없는 slug에 대해 notFound()를 불러 찾을 수 없음 화면을 보여 주는 분기가 필요합니다.
다음 글 중첩 라우트와 레이아웃 구성에서는 동적 라우트와 레이아웃을 함께 중첩해 화면을 구성하는 방법을 살펴봅니다.