들어가며,
Next.js 16 (App Router) 을 공부하다 보면 dynamic routes, params, generateStaticParams, runtime, cache 같은 개념이 한 번에 쏟아진다. 특히 공식 문서의 With Cache Components 섹션은 번역해서 읽으면 이해되지만, 실제로는 잘 와닿지가 않았다.
이 글에서 문서를 옮기기 보다 실제로 헷갈렸던 지점들을 기준으로 왜 동적 라우트가 런타임이 되는지, generateStaticParams는 왜 필요한지 정리해보려고 한다.
동적 라우트, 그냥 URL 변수라고 생각하면 안 된다.
Next.js 16 에서의 동적 라우트는 단순히 "URL에 들어오는 변수" 정도로 생각하면 헷갈린다.
URL 구조 자체가 렌더링 방식에 영향을 주기 때문이다.
URL의 구조는 어떤 파일을 렌더링할지 결정하고, 그 결과로 렌더링 방식(정적/동적) 까지 바꿔버린다.
1️⃣ [slug] : 필수 파라미터
[slug]는 반드시 값이 존재해야 하는 동적 세그먼트다.
즉, 해당 URL 구조가 아니면 페이지가 렌더링되지 않는다.
URL 구조에 따라 렌더링 되는 파일이 결정되고, params 값이 만들어진다.

- http://localhost:3000/blog → blog 폴더 안의 page.tsx 내용이 렌더링 됨 (※ 단, app/blog/page.tsx가 존재하는 경우에만 해당, 없으면 404)
- http://localhost:3000/blog/detail → ✅ [slug] = "detail" → app/blog/[slug]/page.tsx 렌더링
📌 page.tsx 에서 params 사용 예시
export default async function Page({
params,
}: {
params: Promise<{ slug?: string }>; // 이 때, 여기는 string
}) {
const { slug } = await params;
return <div>/blog/{slug}</div>;
}
여기서 URL이 /blog/detail 이면 slug 값은 "detail" , /blog/react 이면 slug 값은 "react"가 된다.
2️⃣ [...slug] : 여러 경로를 한 번에 받는 경우
catch-all 세그먼트라고 불린다. 말 그대로 여러 단계의 URL 경로를 한 번에 받아오는 동적 라우트다.
[slug]가 값 1개를 받는다면, [...slug]는 여러 개의 경로를 배열로 받는다고 이해하면 된다.
URL을 유연하게 설계하고 싶을 때 사용하면 좋다. (블로그 카테고리 + 상세 혼합 구조 등..)

- http://localhost:3000/blog/detail → slug = ["detail"]
- http://localhost:3000/blog/2025/12/nextjs → slug = ["2025", "12", "nextjs"]
blog 아래의 어떤 깊이의 경로든 전부 매칭된다.
📌 page.tsx 에서 params 사용 예시
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>; // 배열로 받아야 한다.
}) {
const { slug } = await params;
return <div>slug: {slug?.join(", ") ?? "no slug"}</div>;
}
3️⃣ [[...slug]] : 값이 없어도 되는 동적 라우트
optional catch-all 세그먼트다. 이름 그대로, 여러 경로를 받을 수 있고, 아무 값도 없어도 된다!
앞에서 살펴본 라우트들과의 차이점이 무엇일까 살펴보자면, blog 폴더 내 page.tsx 가 없어도 /blog 로 들어갔을 때 페이지가 나온다.

- http://localhost:3000/blog → slug = undefined
- http://localhost:3000/blog/detail → slug = ["detail"]
- http://localhost:3000/blog/2025/12/nextjs → slug = ["2025", "12", "nextjs"]
즉, /blog 도 정상 렌더링되고 /blog/* 도 전부 정상 렌더링이 된다.
📌 page.tsx 에서 params 사용 예시
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
if (!slug) { // 존재 여부 체크하기!
return <div>블로그 메인 페이지</div>
}
return <div>상세 경로: {slug.join(' / ')}</div>
}
반드시 존재 여부 체크가 필요하며, 리스트 + 상세 페이지를 하나의 파일로 처리하고 싶을때 유용할 듯 싶다.
- /blog : 목록
- /blog/react : 카테고리
- blog/react/hooks : 상세
하지만, 분기처리를 잘못하면 의도치 않은 페이지까지 렌더링 될 수 있으므로 명확한 조건 분기가 중요하다.
Params 는 왜 Promise 일까?
여기서부터 런타임 개념이 시작된다. params는 서버가 요청을 받아야만 알 수 있는 값이다.
⚡️ 런타임 데이터(동적 데이터)란?
사용자가 실제로 페이지에 접속했을 때에만 알 수 있는 데이터
예시) URL params, cookies, headers, searchParams, 현재 시간
// 사용자가 어떤 값을 입력할지 모름
const { slug } = await params // 런타임 데이터
📌 중요
generateStaticParams가 없으면 params는 기본적으로 런타임 데이터이다.
🤔 Next.js는 왜 런타임 데이터를 조심하라고 할까?
- Suspense가 등장하는 이유 : 서버가 기다려야 하는 부분이 생긴다.
즉, 지금 당장 렌더링할 수 없는 부분을 나중으로 미루기 위한 경계다.
<Suspense fallback={<div>Loading...</div>}>
{params.then(({ slug }) => (
<Content slug={slug} />
))}
</Suspense>
- 런타임 데이터는 서버가 기다려야 하는 값 (요청이 와야 URL params를 알 수 있다.)
- 서버가 그때부터 params 해석 > 데이터 fetch > 컴포넌트 렌더링 > HTML 생성 > 응답
- React는 이를 비동기 경계(Suspense)로 분리하려고 함
- 그래서 Suspense 없이는 에러가 발생할 수 있음 (페이지 전체 대기, 스트리밍 불가, 정적 최적화 깨짐 등 에러/전체 페이지 느려짐)
- Suspense를 사용하면, 정적인 부분은 먼저 전송하고 런타임 데이터가 필요한 부분만 나중에 렌더링해 체감 속도가 빨라짐
🤔 generateStaticParams는 왜 필요할까?
⚡️ generateStaticParams는 "이 URL들은 안전하다"라고 알려주는 힌트! 즉, 미리 알 수 있는 URL을 선언하는 함수이다.
export async function generateStaticParams() {
return [
{ slug: '1' },
{ slug: '2' },
{ slug: '3' },
]
}
📌 이 함수가 존재한다면
- 빌드 타임에 HTML 생성, params는 빌드 타임 데이터
- 해당 페이지는 정적으로 생성됨
- Suspense없이 바로 사용 가능
🤔 같은 동적 라우트, 완전히 다른 결과
| 구분 | generateStaticParams (X) | generateStaticParams (O) |
| params | 런타임 데이터 | 빌드 타임 데이터 |
| Suspense | 필요 | 불필요 |
| HTML 생성 | 요청 시 | 빌드 시 |
| SEO | 불리 | 유리 |
| 초기 속도 | 느릴 수 있음 | 매우 빠름 |
🤔 실무에서는 자주 쓰일까?
모든 페이지에서 쓰는 건 아니다. "공개 페이지" 전용에 쓰면 좋다.
즉, 메인페이지/서비스 소개/블로그 글/문서 및 가이드 등에 사용하면 좋다.
로그인 후 대시보드나 마이페이지, 관리자 페이지에서는 사용하지 않는 편이 좋다.
동작 방식 한눈에 정리
HTML이 언제 만들어지는지가 다르다!
1️⃣ 정적 라우트 (고정 URL)
URL에 변수가 없으면 Next.js가 자동으로 정적 페이지로 판단한다.
// app/page.tsx
export default async function HomePage() {
const data = await getStaticData()
return <div>{data}</div>
}
- 서버 계산, DB 호출 없음
- 빌드 시: / → .next/server/app/page.html 생성 (빌드 시점에 이미 만들어짐)
- 사용자 방문 시: 미리 생성된 HTML 즉시 전송
2️⃣ 동적 라우트 (generateStaticParams X)
이 경우 params는 완전한 런타임 데이터다.
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const { slug } = await params
return <div>{slug}</div>
}
- 빌드 시: 아무것도 생성되지 않음 (어떤 slug가 올지 알 수 없음!)
- 사용자 방문 시: /blog/react 요청 → 런타임에 HTML 생성
3️⃣ 동적 라우트 (generateStaticParams O)
generateStaticParams는 "이 URL들은 미리 만들어도 안전하다"는 힌트다.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
return [{ slug: 'react' }, { slug: 'vue' }]
}
export default async function BlogPost({ params }) {
const { slug } = await params
return <div>{slug}</div>
}
- 빌드 시: /blog/react → HTML 생성
/blog/vue → HTML 생성 - 사용자 방문 시: 미리 생성된 HTML 즉시 전송
💥 주의: 런타임 API를 잘못 쓰면?
cookies, headers 같은 런타임 API를 페이지 최상단에서 사용하면 페이지 전체가 동적으로 변한다.
// app/page.tsx
import { cookies } from 'next/headers'
// ❌ Suspense 없이 런타임 API 사용
export default async function HomePage() {
const token = (await cookies()).get('token')
return <div>Token: {token}</div>
}
- 빌드 시: 페이지가 동적 라우트로 강제 전환, 정적 HTML 생성 안됨
✅ 올바른 패턴: Suspense로 분리하기
정적 영역은 지키고, 동적 영역만 분리한다.
// app/page.tsx
import { Suspense } from 'react'
export default function HomePage() {
return (
<div>
{/* 정적으로 미리 생성 */}
<StaticBanner />
{/* 동적 부분만 스트리밍 */}
<Suspense fallback={<Loading />}>
<UserData /> {/* 여기서 cookies 사용 */}
</Suspense>
</div>
)
}
📖 generateStaticParams 사용 기준 정리
✅ 사용이 필요한 경우 (URL 변수 있음, 어떤 값들을 미리 생설할지 알려줘야 함)
app/blog/[slug]/page.tsx
app/products/[id]/page.tsx
app/docs/[...slug]/page.tsx
app/[locale]/page.tsx
❎ 사용이 필요 없는 경우 (URL고정, 자동으로 정적 생성)
app/page.tsx
app/about/page.tsx
app/pricing/page.tsx
마무리
App Router에서 URL은 단순한 이동 경로가 아니다.
URL 구조 자체가 어떤 파일을 렌더링할지, 그리고 언제 HTML을 만들지까지 결정한다.
동적 라우트는 편리하지만, 아무 설정 없이 사용하면 자연스럽게 런타임 렌더링이 된다.
이때 generateStaticParams는 "이 경로들은 미리 만들어도 안전하다"라고 Next.js에게 의도를 명확히 선언하는 장치이다.
정리하면 판단 기준은 단순하다.
- 고정 URL → 자동으로 정적 생성
- 변수 URL → 기본은 런타임
- 변수 URL + generateStaticParams → 정적 생성으로 전환
이 차이를 이해하면 왜 어떤 페이지는 즉시 뜨고, Suspense 없이는 깨지는지까지 구조적으로 설명할 수 있게 된다.
다음 글에서는 generateStaticParams를 실제 프로젝트에서 어떻게 쪼개서 사용하는지, 그리고 ISR, dynamicParams를 언제 선택해야하는지를 정리해보려고 한다.