Dynamic Routes는 Next.js에서 동적 경로를 처리하는 핵심 기능입니다. App Router에서 도입된 generateStaticParams는 Pages Router의 getStaticPaths를 대체하며, 더욱 직관적이고 강력한 정적 생성 기능을 제공합니다. 이 가이드에서는 실무에서 활용할 수 있는 모든 패턴과 최적화 방법을 다룹니다.
Dynamic Routes 기본 개념
파일 기반 라우팅 시스템
Next.js는 파일 시스템을 기반으로 한 직관적인 라우팅을 제공합니다.
app/
├── blog/
│ ├── page.tsx # /blog
│ ├── [slug]/
│ │ └── page.tsx # /blog/[slug]
│ ├── [category]/
│ │ └── [slug]/
│ │ └── page.tsx # /blog/[category]/[slug]
│ └── [...tags]/
│ └── page.tsx # /blog/[...tags]
├── products/
│ ├── [id]/
│ │ ├── page.tsx # /products/[id]
│ │ ├── edit/
│ │ │ └── page.tsx # /products/[id]/edit
│ │ └── reviews/
│ │ └── [reviewId]/
│ │ └── page.tsx # /products/[id]/reviews/[reviewId]
│ └── [...categories]/
│ └── page.tsx # /products/[...categories]
└── users/
└── [[...profile]]/
└── page.tsx # /users 및 /users/[...profile]
Dynamic Segments 종류
Single Dynamic Segment: [param]
/blog/[slug]→/blog/hello-worldparams.slug로 접근
Catch-all Segments: […param]
/products/[...categories]→/products/electronics/phones/smartphonesparams.categories는 배열로 전달
Optional Catch-all Segments: [[…param]]
/users/[[...profile]]→/users또는/users/settings/password- 기본 경로도 매칭
generateStaticParams 기본 활용
기본 구현 패턴
generateStaticParams는 정적 생성할 매개변수 목록을 반환합니다.
// app/blog/[slug]/page.tsx
interface Post {
slug: string
title: string
content: string
publishedAt: string
}
// 정적으로 생성할 경로 매개변수 반환
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json())
return posts.map((post: Post) => ({
slug: post.slug
}))
}
// 실제 페이지 컴포넌트
async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(res => res.json())
return (
<article>
<h1>{post.title}</h1>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
export default BlogPost
중첩 동적 라우트 처리
여러 레벨의 동적 경로를 처리하는 방법입니다.
// app/products/[category]/[productId]/page.tsx
export async function generateStaticParams() {
// 모든 카테고리와 상품 조합 생성
const categories = await fetchCategories()
const allParams = []
for (const category of categories) {
const products = await fetchProductsByCategory(category.slug)
for (const product of products) {
allParams.push({
category: category.slug,
productId: product.id
})
}
}
return allParams
}
async function ProductPage({
params
}: {
params: { category: string; productId: string }
}) {
const [product, category] = await Promise.all([
fetchProduct(params.productId),
fetchCategory(params.category)
])
return (
<div>
<Breadcrumb category={category} product={product} />
<ProductDetails product={product} />
<RelatedProducts categoryId={category.id} />
</div>
)
}
export default ProductPage
부모-자식 매개변수 상속
부모 레이아웃에서 생성된 매개변수를 자식에서 활용합니다.
// app/products/[category]/layout.tsx
export async function generateStaticParams() {
const categories = await fetchCategories()
return categories.map(category => ({
category: category.slug
}))
}
async function CategoryLayout({
children,
params
}: {
children: React.ReactNode
params: { category: string }
}) {
const category = await fetchCategory(params.category)
return (
<div>
<CategoryHeader category={category} />
<div className="category-layout">
<CategorySidebar category={category} />
<main>{children}</main>
</div>
</div>
)
}
export default CategoryLayout
// app/products/[category]/[productId]/page.tsx
export async function generateStaticParams({
params: parentParams
}: {
params: { category: string }
}) {
// 부모의 category 매개변수 활용
const products = await fetchProductsByCategory(parentParams.category)
return products.map(product => ({
productId: product.id
}))
}
고급 Dynamic Routes 패턴
Catch-all Segments 활용
유연한 경로 구조를 위한 catch-all segments 사용법입니다.
// app/docs/[...segments]/page.tsx
export async function generateStaticParams() {
const allDocPaths = await fetchAllDocumentationPaths()
return allDocPaths.map(path => ({
segments: path.split('/') // 'getting-started/installation' → ['getting-started', 'installation']
}))
}
async function DocumentationPage({
params
}: {
params: { segments: string[] }
}) {
const fullPath = params.segments.join('/')
const doc = await fetchDocumentation(fullPath)
if (!doc) {
notFound()
}
return (
<div>
<DocumentationBreadcrumb segments={params.segments} />
<DocumentationNav currentPath={fullPath} />
<article>
<h1>{doc.title}</h1>
<MarkdownRenderer content={doc.content} />
</article>
<DocumentationFooter
previousDoc={doc.previous}
nextDoc={doc.next}
/>
</div>
)
}
export default DocumentationPage
Optional Catch-all 패턴
기본 경로와 동적 경로를 모두 처리하는 패턴입니다.
// app/shop/[[...filters]]/page.tsx
export async function generateStaticParams() {
const filterCombinations = await generateFilterCombinations()
return [
// 기본 경로 (매개변수 없음)
{},
// 다양한 필터 조합
...filterCombinations.map(filters => ({
filters: filters
}))
]
}
async function ShopPage({
params,
searchParams
}: {
params: { filters?: string[] }
searchParams: { page?: string; sort?: string }
}) {
// 필터 파싱
const appliedFilters = parseFilters(params.filters || [])
const page = parseInt(searchParams.page || '1', 10)
const sortBy = searchParams.sort || 'popularity'
// 상품 데이터 가져오기
const products = await fetchProducts({
filters: appliedFilters,
page,
sortBy,
limit: 24
})
return (
<div>
<ShopHeader />
<div className="shop-layout">
<FilterSidebar
appliedFilters={appliedFilters}
availableFilters={products.filters}
/>
<main>
<SortControls currentSort={sortBy} />
<ProductGrid products={products.items} />
<Pagination
currentPage={page}
totalPages={products.totalPages}
baseUrl="/shop"
filters={params.filters}
/>
</main>
</div>
</div>
)
}
function parseFilters(filterSegments: string[]): ProductFilters {
const filters: ProductFilters = {}
for (let i = 0; i < filterSegments.length; i += 2) {
const filterType = filterSegments[i]
const filterValue = filterSegments[i + 1]
if (filterType && filterValue) {
filters[filterType] = filterValue
}
}
return filters
}
export default ShopPage
성능 최적화 전략
점진적 정적 생성
모든 경로를 빌드 시점에 생성하지 않고 필요한 것만 생성합니다.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
// 인기 있는 포스트만 빌드 시점에 생성
const popularPosts = await fetchPopularPosts(50) // 상위 50개만
return popularPosts.map(post => ({
slug: post.slug
}))
}
// dynamicParams를 true로 설정하여 런타임에 추가 생성 허용
export const dynamicParams = true
async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug)
// 포스트가 없으면 404
if (!post) {
notFound()
}
return <BlogPostContent post={post} />
}
조건부 매개변수 생성
환경이나 조건에 따라 다른 매개변수를 생성합니다.
// app/products/[id]/page.tsx
export async function generateStaticParams() {
// 프로덕션에서는 활성 상품만, 개발에서는 모든 상품
const products = process.env.NODE_ENV === 'production'
? await fetchActiveProducts()
: await fetchAllProducts()
// 배치 처리로 성능 최적화
const batchSize = 100
const batches = []
for (let i = 0; i < products.length; i += batchSize) {
batches.push(products.slice(i, i + batchSize))
}
return products.map(product => ({
id: product.id
}))
}
async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetchProduct(params.id)
return <ProductDetails product={product} />
}
// 정적 생성되지 않은 경로는 404로 처리
export const dynamicParams = false
병렬 데이터 fetching
매개변수 생성 시 병렬 처리로 성능을 최적화합니다.
// app/users/[userId]/posts/[postId]/page.tsx
export async function generateStaticParams() {
// 병렬로 사용자와 포스트 데이터 가져오기
const [users, posts] = await Promise.all([
fetchActiveUsers(),
fetchPublicPosts()
])
// 사용자별 포스트 매핑 생성
const userPostMap = new Map()
posts.forEach(post => {
const userPosts = userPostMap.get(post.userId) || []
userPosts.push(post)
userPostMap.set(post.userId, userPosts)
})
// 매개변수 조합 생성
const params = []
for (const user of users) {
const userPosts = userPostMap.get(user.id) || []
for (const post of userPosts) {
params.push({
userId: user.id,
postId: post.id
})
}
}
return params
}
실제 사용 사례와 패턴
전자상거래 상품 페이지
복잡한 상품 구조를 처리하는 실제 예시입니다.
// app/products/[category]/[subcategory]/[productId]/page.tsx
interface ProductParams {
category: string
subcategory: string
productId: string
}
export async function generateStaticParams(): Promise<ProductParams[]> {
const categories = await fetchCategories()
const allParams: ProductParams[] = []
// 각 카테고리별로 처리
for (const category of categories) {
const subcategories = await fetchSubcategories(category.id)
for (const subcategory of subcategories) {
const products = await fetchProductsBySubcategory(subcategory.id)
// 상위 20개 상품만 정적 생성 (나머지는 런타임)
const topProducts = products.slice(0, 20)
for (const product of topProducts) {
allParams.push({
category: category.slug,
subcategory: subcategory.slug,
productId: product.id
})
}
}
}
return allParams
}
// 런타임에 추가 경로 생성 허용
export const dynamicParams = true
async function ProductPage({ params }: { params: ProductParams }) {
// 병렬로 필요한 데이터 가져오기
const [product, category, subcategory, reviews, relatedProducts] =
await Promise.all([
fetchProduct(params.productId),
fetchCategoryBySlug(params.category),
fetchSubcategoryBySlug(params.subcategory),
fetchProductReviews(params.productId, { limit: 5 }),
fetchRelatedProducts(params.productId, { limit: 8 })
])
if (!product || !category || !subcategory) {
notFound()
}
return (
<div>
<ProductBreadcrumb
category={category}
subcategory={subcategory}
product={product}
/>
<ProductGallery images={product.images} />
<ProductInfo product={product} />
<ProductReviews
reviews={reviews}
productId={params.productId}
/>
<RelatedProducts products={relatedProducts} />
</div>
)
}
export default ProductPage
다국어 지원 블로그
다국어와 동적 라우트를 조합한 복잡한 사례입니다.
// app/[locale]/blog/[category]/[slug]/page.tsx
interface BlogParams {
locale: string
category: string
slug: string
}
const supportedLocales = ['ko', 'en', 'ja', 'zh']
export async function generateStaticParams(): Promise<BlogParams[]> {
const allParams: BlogParams[] = []
// 각 로케일별로 처리
for (const locale of supportedLocales) {
const categories = await fetchBlogCategories(locale)
for (const category of categories) {
const posts = await fetchPostsByCategory(category.slug, locale)
for (const post of posts) {
allParams.push({
locale,
category: category.slug,
slug: post.slug
})
}
}
}
return allParams
}
async function BlogPost({ params }: { params: BlogParams }) {
const { locale, category, slug } = params
// 로케일 유효성 검사
if (!supportedLocales.includes(locale)) {
notFound()
}
const [post, categoryData, translations] = await Promise.all([
fetchPost(slug, locale),
fetchCategory(category, locale),
fetchPostTranslations(slug)
])
if (!post || !categoryData) {
notFound()
}
return (
<article>
<BlogHeader
post={post}
category={categoryData}
locale={locale}
/>
<LanguageSwitcher
translations={translations}
currentLocale={locale}
/>
<BlogContent content={post.content} />
<BlogFooter post={post} />
</article>
)
}
export default BlogPost
문서화 사이트 구조
복잡한 중첩 구조의 문서화 사이트 예시입니다.
// app/docs/[...sections]/page.tsx
export async function generateStaticParams() {
const docStructure = await fetchDocumentationStructure()
const allPaths: { sections: string[] }[] = []
// 재귀적으로 모든 문서 경로 생성
function generatePaths(sections: DocSection[], currentPath: string[] = []) {
for (const section of sections) {
const newPath = [...currentPath, section.slug]
// 현재 섹션이 페이지를 가지고 있으면 경로 추가
if (section.hasPage) {
allPaths.push({ sections: newPath })
}
// 하위 섹션이 있으면 재귀 호출
if (section.children && section.children.length > 0) {
generatePaths(section.children, newPath)
}
}
}
generatePaths(docStructure)
return allPaths
}
async function DocumentationPage({
params
}: {
params: { sections: string[] }
}) {
const sectionPath = params.sections.join('/')
const [doc, navigation, tableOfContents] = await Promise.all([
fetchDocumentation(sectionPath),
fetchDocumentationNavigation(),
fetchTableOfContents(sectionPath)
])
if (!doc) {
notFound()
}
return (
<div className="documentation-layout">
<DocumentationSidebar
navigation={navigation}
currentPath={sectionPath}
/>
<main className="doc-content">
<DocBreadcrumb sections={params.sections} />
<article>
<h1>{doc.title}</h1>
<DocLastUpdated date={doc.updatedAt} />
<MarkdownRenderer content={doc.content} />
</article>
<DocNavigation
previous={doc.previousDoc}
next={doc.nextDoc}
/>
</main>
<aside className="table-of-contents">
<TableOfContents items={tableOfContents} />
</aside>
</div>
)
}
export default DocumentationPage
메타데이터 생성과 SEO 최적화
동적 메타데이터 생성
동적 라우트에서 SEO를 위한 메타데이터를 생성합니다.
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
export async function generateMetadata({
params
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await fetchPost(params.slug)
if (!post) {
return {
title: '포스트를 찾을 수 없습니다',
}
}
return {
title: post.title,
description: post.excerpt,
keywords: post.tags.join(', '),
authors: [{ name: post.author.name }],
openGraph: {
title: post.title,
description: post.excerpt,
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
}
],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
tags: post.tags,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: {
canonical: `https://example.com/blog/${params.slug}`,
}
}
}
구조화된 데이터 생성
검색 엔진 최적화를 위한 JSON-LD 구조화 데이터입니다.
// app/products/[id]/page.tsx
async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetchProduct(params.id)
const reviews = await fetchProductReviews(params.id)
// 구조화된 데이터 생성
const structuredData = {
"@context": "https://schema.org",
"@type": "Product",
"name": product.name,
"description": product.description,
"image": product.images.map(img => img.url),
"brand": {
"@type": "Brand",
"name": product.brand
},
"offers": {
"@type": "Offer",
"price": product.price,
"priceCurrency": "KRW",
"availability": product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
"seller": {
"@type": "Organization",
"name": "Example Store"
}
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": reviews.averageRating,
"reviewCount": reviews.totalCount
}
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<ProductDetails product={product} />
<ProductReviews reviews={reviews} />
</>
)
}
에러 처리와 예외 상황
Not Found 처리
존재하지 않는 동적 경로에 대한 처리입니다.
// app/blog/[slug]/not-found.tsx
export default function BlogPostNotFound() {
return (
<div className="not-found-container">
<h1>포스트를 찾을 수 없습니다</h1>
<p>요청하신 블로그 포스트가 존재하지 않거나 삭제되었습니다.</p>
<div className="suggested-actions">
<Link href="/blog" className="btn-primary">
블로그 목록으로 돌아가기
</Link>
<SearchForm placeholder="다른 포스트 검색..." />
</div>
<RecentPosts limit={5} />
</div>
)
}
에러 바운더리
동적 라우트에서의 에러 처리입니다.
// app/products/[id]/error.tsx
'use client'
export default function ProductError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// 에러 로깅
console.error('Product page error:', error)
}, [error])
return (
<div className="error-container">
<h2>상품을 불러오는 중 문제가 발생했습니다</h2>
<p>잠시 후 다시 시도해 주세요.</p>
<button onClick={reset} className="btn-primary">
다시 시도
</button>
<Link href="/products" className="btn-secondary">
상품 목록으로 돌아가기
</Link>
</div>
)
}
디버깅과 개발 도구
매개변수 생성 디버깅
개발 시 매개변수 생성 과정을 디버깅하는 방법입니다.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
console.log('🔍 Generating static params for blog posts...')
const startTime = Date.now()
const posts = await fetchPosts()
const duration = Date.now() - startTime
console.log(`📊 Fetched ${posts.length} posts in ${duration}ms`)
const params = posts.map(post => {
console.log(`📄 Generating param for: ${post.slug}`)
return { slug: post.slug }
})
console.log(`✅ Generated ${params.length} static params`)
return params
}
개발 환경 최적화
개발 중 빠른 피드백을 위한 최적화입니다.
// 개발 환경에서는 적은 수의 매개변수만 생성
export async function generateStaticParams() {
if (process.env.NODE_ENV === 'development') {
// 개발 시에는 최근 10개만
const recentPosts = await fetchRecentPosts(10)
return recentPosts.map(post => ({ slug: post.slug }))
}
// 프로덕션에서는 모든 포스트
const allPosts = await fetchAllPosts()
return allPosts.map(post => ({ slug: post.slug }))
}
마무리
Dynamic Routes와 generateStaticParams는 Next.js App Router의 핵심 기능으로, 확장 가능하고 성능이 우수한 웹 애플리케이션을 구축하는 데 필수적입니다.
기본적인 단일 매개변수 라우트부터 복잡한 중첩 구조, catch-all segments까지 다양한 패턴을 이해하고 적절히 활용하면, 사용자 경험과 SEO 성능을 모두 만족하는 웹사이트를 만들 수 있습니다.
특히 성능 최적화를 위해서는 모든 경로를 정적 생성하기보다는 인기 있는 페이지나 중요한 페이지만 미리 생성하고, 나머지는 런타임에 생성하는 점진적 접근법을 권장합니다. 이를 통해 빌드 시간을 단축하면서도 필요한 성능 이점을 얻을 수 있습니다.