테마 전환

Next.js 동적 라우팅과 매개변수 처리 완벽 가이드: 기초부터 타입 안전성까지

Easton editorial illustration: server-client bridge

지난주 Next.js 프로젝트를 리팩터링하다가 정말 답답한 문제를 만났습니다. 문서대로 동적 라우트를 작성했는데도 들어가 보면 404가 표시됐습니다. 콘솔에는 아무 오류도 찍히지 않아 어찌할 바를 몰랐죠. 나중에야 Next.js 14의 App Router에서 라우트 매개변수를 가져오는 방식이 바뀌었는데도 Pages Router의 예전 방식을 쓰고 있었다는 사실을 알았습니다.

Next.js 라우팅에서 막힌 것이 처음은 아닙니다. Pages Router의 getStaticPaths에서 App Router의 generateStaticParams로 바뀔 때처럼, 업그레이드할 때마다 다시 배워야 했습니다. 언제 동적 라우트를 써야 하고, 언제 catch-all 라우트를 써야 할까요? 선택적 매개변수란 또 무엇일까요? 이런 개념이 한데 섞이면 정말 혼란스럽기 쉽습니다.

저처럼 Next.js 동적 라우팅이 헷갈리거나 Pages Router에서 App Router로 마이그레이션하는 중이라면 이 글이 도움이 될 것입니다. 가장 기초적인 동적 라우트부터 타입 안전성을 확보하는 실전 기법까지, 다양한 실제 코드 예제를 통해 차근차근 정리하겠습니다.

이 글을 다 읽고 나면 무엇을 얻게 될까요? 완성된 동적 라우팅 지식 체계를 갖추고, 각 상황에 어떤 라우트 유형을 사용해야 하는지, 매개변수를 올바르게 가져오는 방법은 무엇인지, TypeScript로 라우트 매개변수에도 타입 힌트를 적용하는 방법은 무엇인지 알게 됩니다. 추상적인 설명이 아니라 실제 코드와 해결책에 집중하겠습니다. 시작해 봅시다.

1장: 동적 라우트 기초(가장 간단한 것부터)

동적 라우트란 무엇인가요?

가장 흔한 사례부터 살펴봅시다. 블로그 사이트가 있고 각 글의 URL이 /blog/글ID라고 가정해 보겠습니다. 정적 라우트를 사용한다면 글마다 별도의 페이지 파일을 만들어야 하므로 현실적이지 않습니다. 이럴 때 필요한 것이 동적 라우트입니다. 페이지 파일 하나로 모든 글의 상세 페이지를 처리할 수 있습니다.

Next.js App Router에서는 대괄호로 이름을 지정한 폴더를 통해 동적 라우트를 구현합니다. 설명만 들으면 조금 복잡해 보이니 바로 예시를 보겠습니다.

app/
├── blog/
│   └── [slug]/
│       └── page.tsx    ← 이것이 동적 라우트입니다

이 구조는 다음과 같은 모든 /blog/* 경로와 일치합니다.

  • /blog/hello-worldslug = "hello-world"
  • /blog/nextjs-guideslug = "nextjs-guide"
  • /blog/123slug = "123"

가장 간단한 동적 라우트 구현

app/blog/[slug]/page.tsx를 만들고 다음 코드를 작성합니다.

// app/blog/[slug]/page.tsx
export default function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  return (
    <div>
      <h1>글 상세 정보</h1>
      <p>현재 글 slug: {params.slug}</p>
    </div>
  )
}

정말 간단합니다. 사용자가 /blog/hello-world에 접근하면 params.slug"hello-world"가 됩니다.

초보자가 자주 하는 실수:

  1. ❌ 파일 이름을 [slug].tsx로 지정함(App Router에서는 폴더를 사용해야 함)
  2. props.slug에 직접 접근함(params 객체를 통해 가져와야 함)
  3. ❌ 폴더 이름에 대괄호를 빠뜨림(대괄호가 없으면 정적 라우트가 됨)

Pages Router와 App Router 비교

이전에 Pages Router를 사용했다면 “예전에는 pages/blog/[slug].tsx에 작성하지 않았나?”라는 생각이 들 수 있습니다. 맞습니다. App Router에서는 많은 부분이 바뀌었습니다.

기능Pages RouterApp Router
파일 위치pages/blog/[slug].tsxapp/blog/[slug]/page.tsx
매개변수 가져오기router.query.slug 또는 getStaticPropsparams.slug
타입 정의직접 정의해야 함props 타입으로 추론
정적 생성getStaticPathsgenerateStaticParams

처음 마이그레이션할 때 가장 낯설었던 부분은 매개변수를 가져오는 방식이었습니다. Pages Router에서는 useRouter hook을 사용할 수 있지만, App Router의 Server Components에서는 hook을 사용할 수 없고 params prop으로만 받아야 합니다. Server Components는 기본적으로 서버에서 렌더링되므로 클라이언트의 router 객체가 없기 때문입니다.

실전 사례: 전자상거래 상품 상세 페이지

전자상거래 사이트를 만들고 있고 상품 상세 페이지의 URL이 /products/상품ID라고 가정해 보겠습니다. 전체 구현은 다음과 같습니다.

// app/products/[id]/page.tsx
interface Product {
  id: string
  name: string
  price: number
  description: string
}

// 데이터베이스에서 상품을 가져오는 상황을 시뮬레이션
async function getProduct(id: string): Promise<Product | null> {
  // 실제 프로젝트에서는 이 부분에서 데이터베이스를 조회하거나 API를 호출합니다
  const products: Product[] = [
    { id: '1', name: 'TypeScript 입문서', price: 99, description: '초보자에게 적합합니다' },
    { id: '2', name: 'React 실전 가이드', price: 129, description: '기초부터 프로젝트 출시까지' }
  ]
  return products.find(p => p.id === id) || null
}

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  if (!product) {
    return <div>상품이 존재하지 않습니다</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p className="price">¥{product.price}</p>
      <p>{product.description}</p>
    </div>
  )
}

다음 세부 사항에 주목하세요:

  1. Server Components는 비동기 처리를 지원하므로 컴포넌트에 async를 사용했습니다.
  2. 먼저 데이터를 가져온 다음 결과에 따라 무엇을 렌더링할지 결정합니다.
  3. 상품이 존재하지 않는 경우(404 상황)를 처리했습니다.

실제 404 페이지를 반환하려면 Next.js의 notFound 함수를 사용할 수 있습니다.

import { notFound } from 'next/navigation'

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  if (!product) {
    notFound() // 404 페이지 반환
  }

  return (
    <div>
      <h1>{product.name}</h1>
      {/* ... */}
    </div>
  )
}

이제 사용자가 존재하지 않는 상품에 접근하면 직접 만든 not-found.tsx 페이지가 표시되어 사용자 경험이 더 좋아집니다.

여기까지 기본 동적 라우트를 익혔습니다. 하지만 이는 빙산의 일각일 뿐입니다. 이제 여러 단계의 경로를 일치시켜야 할 때 어떻게 해야 하는지 더 복잡한 사례를 살펴보겠습니다.

2장: Catch-All 라우트와 선택적 매개변수(복잡한 경로 처리)

Catch-All 라우트는 언제 필요한가요?

다음과 같은 URL 구조를 가진 문서 사이트를 만든다고 가정해 보겠습니다.

  • /docs/getting-started
  • /docs/api/authentication
  • /docs/api/database/queries
  • /docs/guides/deployment/vercel

경로의 깊이가 고정되어 있지 않아 두 단계일 수도, 세 단계 이상일 수도 있습니다. 일반 동적 라우트로는 처리할 수 없으므로 이럴 때 Catch-All 라우트가 필요합니다.

Catch-All 라우트: [...slug]

폴더 이름에 [...slug](점 세 개)를 사용하면 깊이에 관계없이 경로를 일치시킬 수 있습니다.

app/
├── docs/
│   └── [...slug]/
│       └── page.tsx    ← /docs/* 아래의 모든 경로와 일치

다음 경로와 일치합니다.

  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authenticationslug = ["api", "authentication"]
  • /docs/guides/deployment/vercelslug = ["guides", "deployment", "vercel"]

주의: slug 매개변수는 문자열이 아니라 배열입니다.

코드 구현: 문서 시스템

// app/docs/[...slug]/page.tsx
interface Doc {
  title: string
  content: string
}

// 경로 배열에 따라 문서 가져오기
async function getDoc(slugArray: string[]): Promise<Doc | null> {
  // 배열을 경로로 합칩니다. 예: ["api", "auth"] → "api/auth"
  const path = slugArray.join('/')

  // 실제 프로젝트에서는 파일 시스템이나 데이터베이스에서 읽습니다
  const docs: Record<string, Doc> = {
    'getting-started': {
      title: '빠른 시작',
      content: '저희 제품을 사용해 주셔서 감사합니다...'
    },
    'api/authentication': {
      title: 'API 인증',
      content: 'JWT를 사용해 인증합니다...'
    },
    'api/database/queries': {
      title: '데이터베이스 쿼리',
      content: 'Prisma로 데이터베이스를 조회합니다...'
    }
  }

  return docs[path] || null
}

export default async function DocsPage({
  params
}: {
  params: { slug: string[] }
}) {
  const doc = await getDoc(params.slug)

  if (!doc) {
    return <div>문서가 존재하지 않습니다</div>
  }

  return (
    <article>
      <h1>{doc.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: doc.content }} />

      {/* 이동 경로 탐색 */}
      <nav>
        <a href="/docs">문서</a>
        {params.slug.map((segment, i) => {
          const href = `/docs/${params.slug.slice(0, i + 1).join('/')}`
          return (
            <span key={i}>
              {' / '}
              <a href={href}>{segment}</a>
            </span>
          )
        })}
      </nav>
    </article>
  )
}

이 코드의 핵심:

  1. slugArray.join('/')으로 경로 배열을 문자열로 합칩니다.
  2. slice로 경로 접두사를 잘라 이동 경로 탐색을 구현했습니다.
  3. 타입을 params: { slug: string[] }으로 표시하여 잘못 작성했을 때 TypeScript가 검사할 수 있게 했습니다.

선택적 Catch-All 라우트: [[...slug]]

때로는 /docs/docs/*를 모두 일치시키고 싶을 수 있습니다. 일반 Catch-All 라우트는 매개변수가 없는 /docs와 일치하지 않습니다. 이럴 때 선택적 Catch-All 라우트를 사용합니다.

app/
├── docs/
│   └── [[...slug]]/
│       └── page.tsx    ← 대괄호가 두 겹이라는 점에 주의하세요

다음 경로와 일치합니다.

  • /docsslug = undefined
  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authslug = ["api", "auth"]

코드에서는 slugundefined일 수 있는 경우를 처리해야 합니다.

// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
  params
}: {
  params: { slug?: string[] }  // slug는 선택 사항입니다
}) {
  // /docs 홈 페이지인 경우
  if (!params.slug) {
    return <div>문서 센터에 오신 것을 환영합니다</div>
  }

  // 하위 경로 처리
  const doc = await getDoc(params.slug)
  // ...
}

초보자가 자주 빠지는 함정

함정 1: slug가 배열이라는 사실을 잊음

// ❌ 잘못된 방식
<h1>현재 경로: {params.slug}</h1>  // "api,authentication"으로 표시됨

// ✅ 올바른 방식
<h1>현재 경로: {params.slug.join('/')}</h1>  // "api/authentication"으로 표시됨

함정 2: 정적 생성 시 데이터 구조가 잘못됨

// ❌ 잘못된 방식
export function generateStaticParams() {
  return [
    { slug: 'api/auth' }  // 배열이 아니라 문자열입니다!
  ]
}

// ✅ 올바른 방식
export function generateStaticParams() {
  return [
    { slug: ['api', 'auth'] }  // 배열 형식
  ]
}

함정 3: 일반 동적 라우트와 Catch-All 라우트를 혼동함

라우트 유형폴더 이름일치 범위매개변수 타입
동적 라우트[slug]/blog/123string
Catch-All[...slug]/docs/a/b/c(/docs 제외)string[]
선택적 Catch-All[[...slug]]/docs/docs/a/b/cstring[] | undefined

당시 저는 이 세 가지를 뒤섞어 사용했고, 그 결과 어떤 때는 라우트에 접근할 수 있고 어떤 때는 접근할 수 없었습니다. 한참을 확인한 뒤에야 폴더 이름을 잘못 작성했다는 사실을 알게 됐습니다.

실전 팁: 특수 문자 처리

URL에 한국어, 중국어 또는 특수 문자가 있다면 인코딩과 디코딩을 처리해야 합니다.

export default async function Page({
  params
}: {
  params: { slug: string[] }
}) {
  // URL은 자동으로 인코딩되므로 제대로 표시하려면 디코딩해야 합니다
  const decodedSlug = params.slug.map(s => decodeURIComponent(s))

  console.log(params.slug)      // ["api", "%EC%9D%B8%EC%A6%9D"]
  console.log(decodedSlug)      // ["api", "인증"]

  // ...
}

이제 다양한 복잡한 경로 구조를 처리할 수 있습니다. 하지만 아직 중요한 문제가 하나 남아 있습니다. 이런 동적 페이지는 언제 생성될까요? 요청할 때마다 렌더링해야 할까요, 아니면 빌드할 때 미리 생성해야 할까요? 다음 장에서 다룰 generateStaticParams가 바로 이 문제를 해결합니다.

3장: generateStaticParams 심층 분석(언제, 어떻게 사용하나요?)

generateStaticParams가 필요한 이유

블로그에 100개의 글이 있고 각 글이 /blog/[slug] 동적 라우트를 사용한다고 가정해 보겠습니다. 최적화하지 않으면 사용자가 방문할 때마다 다음 작업을 수행해야 합니다.

  1. 데이터베이스를 조회해 글 내용을 가져옵니다.
  2. 서버에서 HTML을 렌더링합니다.
  3. 사용자에게 반환합니다.

이 방식은 응답이 느리고 서버 부담도 큽니다. Next.js는 더 나은 방법을 제공합니다. 빌드할 때 모든 글 페이지를 미리 렌더링하여 정적 HTML로 만드는 것입니다. 이것이 generateStaticParams의 역할입니다.

기본 사용법: 블로그 글 정적 생성

// app/blog/[slug]/page.tsx
interface Post {
  slug: string
  title: string
  content: string
}

// 모든 글의 slug 가져오기
export async function generateStaticParams() {
  // 데이터베이스나 CMS에서 모든 글 가져오기
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())

  // 가능한 모든 매개변수 조합 반환
  return posts.map((post: Post) => ({
    slug: post.slug
  }))
}

// 글 상세 정보 렌더링
export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  // slug에 따라 글 내용 가져오기
  const post = await fetch(`https://api.example.com/posts/${params.slug}`)
    .then(r => r.json())

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

이 코드는 어떤 작업을 할까요?

  1. generateStaticParams는 빌드할 때 실행되어 모든 글의 slug를 반환합니다.
  2. Next.js는 각 slug에 대해 정적 HTML 파일을 미리 렌더링합니다.
  3. 사용자가 방문하면 정적 파일을 바로 반환하므로 매우 빠릅니다.

빌드 결과물:

.next/server/app/blog/
├── hello-world.html
├── nextjs-guide.html
└── typescript-tips.html

generateStaticParams는 언제 사용하나요?

가장 자주 받는 질문입니다. 다음과 같이 간단히 판단할 수 있습니다.

generateStaticParams가 적합한 사례:

  • 블로그 글, 뉴스 상세 페이지(내용이 비교적 고정됨)
  • 상품 상세 페이지(상품 수가 제한적일 때, 예: 10,000개 미만)
  • 문서 페이지, 고객 지원 센터
  • 사용자 프로필 페이지(사용자 수가 많지 않을 때)

적합하지 않은 사례:

  • 검색 결과 페이지(매개변수 조합이 무한함)
  • 실시간 데이터(주식 시세, 스포츠 점수)
  • 사용자 수가 매우 많은 UGC 플랫폼(모든 사용자 페이지를 미리 렌더링할 수 없음)
  • 로그인 상태에 따라 서로 다른 콘텐츠를 표시해야 하는 페이지

고급 사용법 1: Catch-All 라우트 정적 생성

[...slug] 라우트의 경우 반환하는 매개변수는 배열이어야 합니다.

// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
  // 모든 문서 경로
  const docPaths = [
    ['getting-started'],
    ['api', 'authentication'],
    ['api', 'database', 'queries'],
    ['guides', 'deployment', 'vercel']
  ]

  return docPaths.map(slug => ({ slug }))
}

export default async function DocsPage({
  params
}: {
  params: { slug: string[] }
}) {
  // ...
}

주의: 반환 형식은 { slug: ['api', 'auth'] }이며 { slug: 'api/auth' }가 아닙니다.

고급 사용법 2: 다중 매개변수 라우트

/shop/[category]/[productId]처럼 여러 동적 매개변수가 있는 라우트라면 다음과 같은 구조가 됩니다.

app/
├── shop/
│   └── [category]/
│       └── [productId]/
│           └── page.tsx

generateStaticParams는 다음과 같이 작성합니다.

// app/shop/[category]/[productId]/page.tsx
export async function generateStaticParams() {
  const products = [
    { category: 'electronics', productId: 'iphone-15' },
    { category: 'electronics', productId: 'macbook-pro' },
    { category: 'books', productId: 'clean-code' },
    { category: 'books', productId: 'refactoring' }
  ]

  return products.map(p => ({
    category: p.category,
    productId: p.productId
  }))
}

export default async function ProductPage({
  params
}: {
  params: { category: string; productId: string }
}) {
  return (
    <div>
      <h1>카테고리: {params.category}</h1>
      <p>상품 ID: {params.productId}</p>
    </div>
  )
}

고급 사용법 3: 필요할 때 생성하기(fallback 모드)

콘텐츠가 너무 많아(예: 글 10만 개) 전부 미리 렌더링하기 어렵다면 인기 콘텐츠만 생성하고 나머지는 필요할 때 생성할 수 있습니다.

// app/blog/[slug]/page.tsx
export const dynamicParams = true  // 미리 렌더링하지 않은 페이지의 동적 생성 허용

export async function generateStaticParams() {
  // 인기 글 상위 100개만 미리 렌더링
  const topPosts = await fetchTopPosts(100)

  return topPosts.map(post => ({
    slug: post.slug
  }))
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  // 미리 렌더링하지 않았어도 첫 방문 때 페이지를 생성하고 캐시합니다
  const post = await fetchPost(params.slug)

  if (!post) {
    notFound()
  }

  return <article>{/* ... */}</article>
}

dynamicParams = true를 설정하면 다음과 같이 작동합니다.

  • 미리 렌더링한 페이지: 즉시 반환(가장 빠름)
  • 미리 렌더링하지 않은 페이지: 첫 요청 때 생성하고 이후 방문에서는 캐시를 재사용
  • 존재하지 않는 페이지: 404 반환

초보자가 자주 막히는 부분

문제 1: generateStaticParams는 언제 실행되나요?

매 요청마다 실행되는 것이 아니라 빌드할 때(npm run build)만 실행됩니다. 따라서 개발 환경(npm run dev)에서는 효과를 확인할 수 없으며, 빌드한 후에야 정적으로 생성된 파일을 볼 수 있습니다.

문제 2: 데이터가 업데이트되면 어떻게 하나요?

정적 생성이 끝나면 콘텐츠가 고정됩니다. 데이터가 업데이트되면 다시 빌드하고 배포해야 합니다. 해결 방법은 다음과 같습니다.

  • ISR(Incremental Static Regeneration)을 사용해 정기적으로 업데이트합니다.
  • dynamicParams = true와 결합해 필요할 때 업데이트합니다.
  • revalidate로 캐시 만료 시간을 설정합니다.
// 60초마다 페이지 다시 생성
export const revalidate = 60

export default async function Page() {
  // ...
}

문제 3: 빌드 시간이 왜 길어졌나요?

generateStaticParams가 반환하는 경로가 많을수록 빌드 시간도 길어집니다. 빌드가 시간 초과된다면 다음 방법을 사용할 수 있습니다.

  • 미리 렌더링할 페이지 수를 줄입니다(인기 콘텐츠만 렌더링).
  • 증분 빌드를 사용합니다(Vercel/Netlify 지원).
  • 필요할 때 생성하는 방식(dynamicParams = true)을 고려합니다.

여기까지 Next.js 동적 라우팅의 핵심 사용법을 익혔습니다. 마지막 장에서는 많은 개발자를 괴롭히는 문제, 즉 라우트 매개변수에도 TypeScript 타입 힌트를 적용하는 방법을 알아보겠습니다.

4장: 라우트 매개변수 타입 안전성 실전(any와 작별하기)

타입 안전성이 필요한 이유

다음 코드에서 문제를 찾을 수 있나요?

export default async function Page({
  params
}: {
  params: { slug: string }
}) {
  // 숫자 ID라고 가정했지만 타입은 string으로 정의되어 있습니다
  const id = parseInt(params.slug)

  if (isNaN(id)) {
    // 런타임에 이르러서야 타입이 잘못됐음을 알게 됩니다!
    return <div>유효하지 않은 ID</div>
  }

  // ...
}

문제는 params.slug의 타입이 string이지만 실제로는 숫자가 필요하다는 점입니다. 이런 타입 불일치는 컴파일할 때 발견되지 않고 실행 중에야 오류가 드러납니다.

기본 타입 제약

Next.js의 params 객체에 있는 모든 매개변수는 기본적으로 string 또는 string[]입니다. 직접 타입을 정의해 제약을 강화할 수 있습니다.

// app/blog/[slug]/page.tsx
interface BlogParams {
  slug: string
}

export default async function BlogPost({
  params
}: {
  params: BlogParams
}) {
  // TypeScript는 params.slug가 string임을 압니다
  const post = await fetchPost(params.slug)
  // ...
}

별다른 효과가 없어 보일 수도 있지만, 매개변수가 많아지면 큰 도움이 됩니다.

// app/shop/[category]/[productId]/page.tsx
interface ShopParams {
  category: 'electronics' | 'books' | 'clothing'  // 몇 가지 값으로 제한
  productId: string
}

export default async function ProductPage({
  params
}: {
  params: ShopParams
}) {
  // TypeScript는 category가 허용된 값인지 검사합니다
  if (params.category === 'toys') {  // ❌ 컴파일 오류!
    // ...
  }
}

런타임 검증: Zod 결합하기

타입 정의는 컴파일할 때만 확인하므로 런타임에는 여전히 잘못된 값이 들어올 수 있습니다. Zod를 함께 사용해 런타임 검증까지 하면 더 안전합니다.

npm install zod
// app/products/[id]/page.tsx
import { z } from 'zod'
import { notFound } from 'next/navigation'

// 매개변수 schema 정의
const paramsSchema = z.object({
  id: z.string().regex(/^\d+$/, '숫자 ID여야 합니다')
})

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  // 런타임 검증
  const result = paramsSchema.safeParse(params)

  if (!result.success) {
    notFound()  // 잘못된 매개변수는 바로 404 반환
  }

  const { id } = result.data
  const product = await fetchProduct(parseInt(id))
  // ...
}

이 방식의 장점은 다음과 같습니다.

  • 컴파일할 때 타입을 검사합니다.
  • 런타임에 매개변수 형식을 검증합니다.
  • 잘못된 요청은 데이터베이스를 조회하지 않고 바로 404를 반환합니다.

고급 기법: 타입 안전한 generateStaticParams

generateStaticParams의 반환값에도 타입 제약을 추가할 수 있습니다.

// app/blog/[slug]/page.tsx
interface BlogParams {
  slug: string
}

export async function generateStaticParams(): Promise<BlogParams[]> {
  const posts = await fetchAllPosts()

  return posts.map(post => ({
    slug: post.slug
    // slug: post.id처럼 잘못된 타입을 작성하면 TypeScript가 오류를 표시합니다
  }))
}

export default async function BlogPost({
  params
}: {
  params: BlogParams
}) {
  // ...
}

실전 사례: 다국어 블로그 라우트

/[locale]/blog/[slug] 형태의 URL을 사용하는 다국어 블로그를 만든다고 가정해 보겠습니다. 예를 들면 다음과 같습니다.

  • /zh/blog/hello-world
  • /en/blog/hello-world

타입 안전성을 모두 적용한 구현은 다음과 같습니다.

// app/[locale]/blog/[slug]/page.tsx
import { z } from 'zod'
import { notFound } from 'next/navigation'

// 지원 언어 목록
const locales = ['zh', 'en', 'ja'] as const
type Locale = typeof locales[number]  // "zh" | "en" | "ja"

interface PageParams {
  locale: Locale
  slug: string
}

// 런타임 검증 schema
const paramsSchema = z.object({
  locale: z.enum(locales),
  slug: z.string().min(1)
})

export async function generateStaticParams(): Promise<PageParams[]> {
  const posts = await fetchAllPosts()

  // 언어별 경로 생성
  return locales.flatMap(locale =>
    posts.map(post => ({
      locale,
      slug: post.slug
    }))
  )
}

export default async function BlogPost({
  params
}: {
  params: PageParams
}) {
  // 런타임 검증
  const result = paramsSchema.safeParse(params)
  if (!result.success) {
    notFound()
  }

  const { locale, slug } = result.data

  // 해당 언어의 글 가져오기
  const post = await fetchPost(slug, locale)

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

이 코드의 장점:

  1. Locale 타입이 "zh" | "en" | "ja"로 제한되어 잘못 작성하면 오류가 발생합니다.
  2. generateStaticParams의 반환 타입이 PageParams[]이므로 구조가 올바른지 보장합니다.
  3. 런타임에는 Zod로 검증하여 잘못된 요청을 방지합니다.
  4. 타입 정의부터 런타임 검증까지 전체 과정이 엄격하게 관리됩니다.

자주 발생하는 타입 문제 해결

문제 1: params 타입이 Promise<...>이면 어떻게 하나요?

Next.js 15 이후에는 params가 비동기일 수 있습니다. 다음과 같이 작성해야 합니다.

export default async function Page({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params  // 먼저 await 사용
  // ...
}

또는 Next.js 14임이 확실하다면 동기 버전을 사용할 수 있습니다.

export default async function Page({
  params
}: {
  params: { slug: string }
}) {
  // 바로 사용
}

문제 2: 타입 힌트가 정확하지 않음

TypeScript가 paramsany로 표시한다면 다음을 확인하세요.

  1. tsconfig.json에서 strict 모드가 활성화되어 있는지 확인합니다.
  2. Next.js 타입을 올바르게 가져왔는지 확인합니다.
  3. 파일 이름이 올바른지 확인합니다(반드시 page.tsx여야 함).

문제 3: Zod 검증이 실패했을 때 자세한 오류를 보고 싶음

const result = paramsSchema.safeParse(params)

if (!result.success) {
  console.error('매개변수 검증 실패:', result.error.format())
  notFound()
}

타입 안전성 체크리스트

프로젝트에서 다음 항목을 확인하여 타입 안전성을 확보하세요.

  • 모든 동적 라우트 페이지에 params 타입이 정의되어 있습니다.
  • generateStaticParams의 반환값 타입이 params와 일치합니다.
  • 핵심 라우트에 런타임 검증(Zod)을 적용했습니다.
  • TypeScript strict 모드를 활성화했습니다.
  • 복잡한 매개변수에 유니온 타입 또는 리터럴 타입을 사용했습니다.

이 항목을 모두 지키면 라우팅 시스템에서 타입 관련 버그가 발생할 가능성이 거의 없어집니다.

결론

여기까지 따라왔다면 축하합니다. 이제 Next.js 동적 라우팅의 전체 지식 체계를 익혔습니다. 배운 내용을 되짚어 보겠습니다.

기본 동적 라우트: [slug]로 한 단계 경로를 일치시키고 params 매개변수를 가져오는 방식을 이해했습니다.
Catch-All 라우트: [...slug]로 여러 단계의 경로를 처리하고 선택적 매개변수 사용법을 알게 됐습니다.
generateStaticParams: 언제, 어떻게 사용하는지와 필요할 때 생성하는 전략을 이해했습니다.
타입 안전성 실전: 컴파일 타임 타입 제약부터 런타임 검증까지의 전체 방법을 익혔습니다.

더 중요한 점은 App Router와 Pages Router의 차이를 이해하여 두 방식의 사용법을 더 이상 혼동하지 않게 됐다는 것입니다. 언제 미리 렌더링하고 언제 필요할 때 생성해야 하는지도 알게 되어 실제 상황에 적합한 방법을 선택할 수 있습니다.

다음으로 무엇을 할 수 있을까요?

바로 실습하기(미루지 마세요):

  • 프로젝트에 동적 라우트를 하나 만들고 params로 매개변수를 가져와 보세요.
  • 여러 단계의 경로가 필요하다면 Catch-All 라우트를 사용해 보세요.
  • 라우트에 TypeScript 타입 정의와 Zod 검증을 추가하세요.

고급 학습(더 깊이 익히기):

  • 병렬 라우트: 같은 페이지에서 여러 라우트를 불러옵니다(@folder 문법).
  • 인터셉팅 라우트: 현재 페이지를 떠나지 않고 다른 라우트를 표시합니다((.)folder 문법).
  • 라우트 그룹: URL 구조에 영향을 주지 않고 (folder)로 라우트를 구성합니다.
  • 미들웨어: 라우트 단계에서 권한 제어와 리디렉션을 처리합니다.

학습 자료(공식 문서가 가장 정확합니다):

자주 발생하는 문제 빠른 확인표:

문제확인 항목해결 방법
동적 라우트 접근 시 404폴더 이름, generateStaticParams대괄호가 올바른지 확인하고 정적 생성 설정 점검
paramsanyTypeScript 설정strict 모드를 활성화하고 매개변수 타입 정의
빌드 시간이 너무 긺generateStaticParams 반환 개수미리 렌더링하는 페이지를 줄이고 필요할 때 생성하는 방식 사용
데이터가 업데이트되지 않음캐시 전략revalidate 또는 dynamicParams 설정

마지막으로 전하고 싶은 말

Next.js의 라우팅 시스템은 Pages Router에서 App Router로 넘어오며 크게 바뀌었습니다. 저를 포함해 많은 사람이 마이그레이션 과정에서 어려움을 겪었다는 사실을 잘 압니다. 하지만 App Router의 사고방식을 익히고 나면 오히려 더 직관적이고 강력하다는 점을 알게 됩니다.

동적 라우팅은 Next.js의 한 부분에 불과하지만 전체 애플리케이션의 기반이기도 합니다. 라우팅을 제대로 이해하면 이후의 데이터 조회, 캐시 전략, 미들웨어 같은 개념도 훨씬 수월하게 배울 수 있습니다.

실습 중 문제가 생긴다면 다음 순서로 확인하세요.

  1. 먼저 공식 문서의 “Troubleshooting” 섹션을 확인합니다.
  2. Next.js GitHub 저장소에서 관련 Issue를 검색합니다.
  3. Next.js Discord 커뮤니티에 질문합니다(영어로 질문해야 하지만 답변이 빠릅니다).

실수를 두려워하지 마세요. 저도 여러 프로젝트에서 시행착오를 겪은 뒤에야 App Router의 라우팅 메커니즘을 완전히 이해했습니다. 이제는 이 글을 참고할 수 있으니 훨씬 덜 헤맬 수 있을 것입니다.

이제 편집기를 열고 동적 라우트를 만들어 보세요! 🚀

Next.js 동적 라우팅 설정 전체 과정

동적 라우트 생성부터 타입 안전성 적용까지의 전체 단계

⏱️ Estimated time: 2 hr

  1. 1

    Step 1: 동적 라우트 폴더 만들기

    요구 사항에 따라 라우트 유형을 선택합니다.
    • 단일 매개변수: app/posts/[id]/page.tsx
    • 다중 매개변수: app/posts/[category]/[id]/page.tsx
    • Catch-all: app/posts/[...slug]/page.tsx
    • 선택적 catch-all: app/posts/[[...slug]]/page.tsx

    폴더 이름 규칙:
    • [id]: 필수 매개변수
    • [...slug]: 모든 경로 세그먼트 캡처
    • [[...slug]]: 모든 경로 세그먼트를 선택적으로 캡처
  2. 2

    Step 2: 라우트 매개변수 가져오기

    page.tsx에서 매개변수를 가져옵니다.
    • App Router는 params 객체 사용
    • params는 Promise이므로 await 필요
    • 구조 분해 할당으로 구체적인 매개변수 조회

    예시:
    export default async function Page({ params }) {
    const { id } = await params
    return <div>Post {id}</div>
    }

    주의: params에는 반드시 await를 사용해야 하며, 그렇지 않으면 오류가 발생합니다.
  3. 3

    Step 3: 타입 안전성 설정

    TypeScript로 타입을 정의합니다.
    • params 타입 인터페이스 정의
    • Promise<{ params }> 타입 사용
    • generateStaticParams 반환 타입 사용

    예시:
    interface PageProps {
    params: Promise<{ id: string }>
    }

    export default async function Page({ params }: PageProps) {
    const { id } = await params
    // ...
    }
  4. 4

    Step 4: 정적 생성 구현하기(선택 사항)

    generateStaticParams를 사용합니다.
    • 가능한 모든 매개변수 조합 반환
    • async 함수로 데이터 조회 가능
    • 모든 페이지를 정적으로 생성할 때 사용

    예시:
    export async function generateStaticParams() {
    const posts = await getPosts()
    return posts.map(post => ({ id: post.id }))
    }

    주의: 정적 생성에만 사용하며 동적 라우트에는 필요하지 않습니다.
  5. 5

    Step 5: 선택적 매개변수 처리하기

    선택적 catch-all 라우트:
    • [[...slug]] 문법 사용
    • params.slug가 undefined일 수 있음
    • 매개변수 존재 여부 확인 필요

    예시:
    export default async function Page({ params }) {
    const { slug } = await params
    if (!slug) {
    return <div>All posts</div>
    }
    return <div>Category: {slug.join('/')}</div>
    }
  6. 6

    Step 6: 테스트 및 검증

    테스트 항목:
    • 모든 라우트가 정상적으로 작동하는지 테스트
    • 매개변수를 올바르게 가져오는지 검증
    • 타입 힌트가 정상인지 확인
    • 정적 생성이 성공하는지 테스트

    체크리스트:
    • 모든 동적 라우트에 정상적으로 접근 가능
    • 매개변수 타입이 올바르게 정의됨
    • generateStaticParams가 올바른 데이터를 반환함
    • 404 오류가 처리됨

FAQ

동적 라우트 매개변수는 어떻게 가져오나요?
App Router에서는 params 객체로 매개변수를 가져옵니다.

핵심 사항:
• params는 Promise이므로 반드시 await 사용
• 구조 분해 할당으로 구체적인 매개변수 조회
• 타입 정의 필요

예시:
export default async function Page({ params }) {
const { id } = await params
return <div>{id}</div>
}
동적 라우트가 404를 반환하는 이유는 무엇인가요?
가능한 원인:
• 폴더 이름 오류([id]여야 하며 {id}가 아님)
• 경로 불일치(URL과 폴더 구조 확인)
• generateStaticParams가 반환한 데이터가 불완전함
• page.tsx 파일 누락

해결 방법:
• 폴더 이름이 올바른지 확인
• URL 경로와 폴더 구조가 일치하는지 확인
• generateStaticParams 반환값 확인
catch-all 라우트와 선택적 catch-all 라우트의 차이는 무엇인가요?
catch-all 라우트 [...slug]:
• 최소 하나의 경로 세그먼트와 반드시 일치해야 함
• /posts/[...slug]는 /posts/a와 일치하지만 /posts와는 일치하지 않음

선택적 catch-all 라우트 [[...slug]]:
• 0개 이상의 경로 세그먼트와 일치할 수 있음
• /posts/[[...slug]]는 /posts 및 /posts/a/b와 일치함

사용 사례:
• catch-all: 하나 이상의 매개변수가 필요할 때
• 선택적 catch-all: 매개변수가 선택 사항일 때
타입 안전한 동적 라우트는 어떻게 구현하나요?
단계:
1) params 타입 인터페이스 정의
2) Promise<{ params }> 타입 사용
3) generateStaticParams 반환 타입 사용

예시:
interface PageProps {
params: Promise<{ id: string }>
}

export default async function Page({ params }: PageProps) {
const { id } = await params
// ...
}
generateStaticParams는 언제 사용하나요?
가능한 모든 페이지를 정적으로 생성할 때 사용합니다.

적합한 사례:
• 가능한 매개변수 값을 모두 알고 있음
• 모든 페이지를 정적으로 생성해야 함
• 성능 및 SEO 개선

적합하지 않은 사례:
• 매개변수 값이 동적으로 변함
• 매개변수 값이 너무 많아 열거할 수 없음
• 실시간 데이터가 필요함

주의: 정적 생성에만 사용하며 동적 라우트에는 필요하지 않습니다.
Pages Router의 동적 라우트를 어떻게 마이그레이션하나요?
주요 변화:
• getStaticPaths → generateStaticParams
• context.params → params(await 필요)
• 반환 형식이 { paths, fallback }에서 배열로 변경

마이그레이션 단계:
1) getStaticPaths를 generateStaticParams로 변경
2) 매개변수 조회 방식 수정(await params 사용)
3) 타입 정의 업데이트
4) 모든 라우트 테스트
다중 매개변수 동적 라우트는 어떻게 처리하나요?
여러 단계의 폴더를 만듭니다.
app/posts/[category]/[id]/page.tsx

매개변수 가져오기:
export default async function Page({ params }) {
const { category, id } = await params
return <div>{category} - {id}</div>
}

generateStaticParams는 모든 조합을 반환합니다.
export async function generateStaticParams() {
return [
{ category: 'tech', id: '1' },
{ category: 'tech', id: '2' },
// ...
]
}

6분 읽기 · 게시일: 2025년 12월 25일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog