[Next.js] Dynamic Routes와 generateStaticParams 활용법




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-world
  • params.slug로 접근

Catch-all Segments: […param]

  • /products/[...categories]/products/electronics/phones/smartphones
  • params.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 성능을 모두 만족하는 웹사이트를 만들 수 있습니다.

특히 성능 최적화를 위해서는 모든 경로를 정적 생성하기보다는 인기 있는 페이지나 중요한 페이지만 미리 생성하고, 나머지는 런타임에 생성하는 점진적 접근법을 권장합니다. 이를 통해 빌드 시간을 단축하면서도 필요한 성능 이점을 얻을 수 있습니다.




댓글 남기기