API/File-system conventions/Dynamic Route Segments

김동현·2026년 3월 8일

next.js 공식문서 번역

목록 보기
71/79

규칙 (Convention)

동적 세그먼트를 만드는 방법은 아주 간단합니다. 폴더 이름을 대괄호([])로 감싸주기만 하면 돼요! 예를 들면 [folderName]과 같이 작성하는 거죠.

블로그를 만든다고 가정해 볼까요? app/blog/[slug]/page.js라는 경로를 만들면, 여기서 [slug]가 바로 블로그 게시글을 위한 동적 세그먼트가 됩니다. 사용자가 접속하는 주소에 따라 저 [slug] 자리에 다양한 값이 들어오게 되는 원리랍니다.

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  return <div>My Post: {slug}</div>
}
export default async function Page({ params }) {
  const { slug } = await params
  return <div>My Post: {slug}</div>
}

💡 강사의 팁: 여기서 slug라는 단어가 생소하실 수 있어요! 웹 개발에서 'slug'란 URL의 끝부분에 들어가는 읽기 쉬운 식별자를 뜻해요. 예를 들어 https://example.com/blog/my-first-post에서 my-first-post 부분이 slug랍니다. 꼭 [slug]로 지을 필요는 없고 [id], [productId] 등 여러분이 원하는 이름으로 지으셔도 전혀 문제없습니다!

이렇게 만들어진 동적 세그먼트는 layout, page, route, 그리고 generateMetadata 함수의 params prop으로 전달됩니다.

어떤 URL을 입력했을 때 params가 어떻게 들어오는지 표로 확인해 볼까요?

라우트 경로 (Route)예시 URL (Example URL)전달되는 params 객체
app/blog/[slug]/page.js/blog/a{ slug: 'a' }
app/blog/[slug]/page.js/blog/b{ slug: 'b' }
app/blog/[slug]/page.js/blog/c{ slug: 'c' }

클라이언트 컴포넌트에서 사용하기 (In Client Components)

클라이언트 컴포넌트인 page 파일에서는 prop으로 전달받은 동적 세그먼트를 React의 use API를 사용해서 접근할 수 있습니다.

'use client'
import { use } from 'react'

export default function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = use(params)

  return (
    <div>
      <p>{slug}</p>
    </div>
  )
}
'use client'
import { use } from 'react'

export default function BlogPostPage({ params }) {
  const { slug } = use(params)

  return (
    <div>
      <p>{slug}</p>
    </div>
  )
}

또는, 클라이언트 컴포넌트 트리의 어느 곳에서나 useParams 훅을 사용하여 params에 접근할 수도 있어요.

💡 강사의 팁: use(params) 방식은 보통 페이지의 최상단 컴포넌트에서 prop으로 바로 받을 때 유용합니다. 반면, 하위 컴포넌트로 계속 prop을 내려주는 것(Prop drilling)이 귀찮을 때는, 하위 컴포넌트에서 곧바로 useParams() 훅을 호출해서 값을 꺼내 쓰는 게 훨씬 깔끔한 코드를 작성하는 비결이죠!

모든 것을 잡아내는 세그먼트 (Catch-all Segments)

동적 세그먼트는 괄호 안에 마침표 세 개(줄임표)를 추가해서 [...folderName] 형태로 작성하면, 그 뒤로 이어지는 모든(catch-all) 후속 세그먼트들까지 전부 잡아내도록 확장할 수 있습니다. 마치 블랙홀처럼요!

예를 들어, app/shop/[...slug]/page.js라는 라우트는 /shop/clothes 뿐만 아니라, /shop/clothes/tops, /shop/clothes/tops/t-shirts 등 깊이가 얼마나 깊어지든 전부 매칭됩니다.

이때 params는 배열 형태로 들어오게 됩니다.

라우트 경로 (Route)예시 URL (Example URL)전달되는 params 객체
app/shop/[...slug]/page.js/shop/a{ slug: ['a'] }
app/shop/[...slug]/page.js/shop/a/b{ slug: ['a', 'b'] }
app/shop/[...slug]/page.js/shop/a/b/c{ slug: ['a', 'b', 'c'] }

선택적 Catch-all 세그먼트 (Optional Catch-all Segments)

방금 배운 Catch-all 세그먼트를 이중 대괄호로 감싸면 [[...folderName]] 형태가 되는데요, 이렇게 하면 이 파라미터가 선택적(optional)으로 변합니다.

이게 무슨 말이냐면, app/shop/[[...slug]]/page.js/shop/clothes, /shop/clothes/tops, /shop/clothes/tops/t-shirts를 매칭하는 것 외에도 파라미터가 아예 없는 /shop 경로 자체도 매칭한다는 뜻입니다.

Catch-all([...])선택적 Catch-all([[...]])의 가장 큰 차이점은, 선택적인 경우에는 파라미터가 없는 기본 라우트(위 예시의 /shop)까지 함께 커버해 준다는 점이에요.

라우트 경로 (Route)예시 URL (Example URL)전달되는 params 객체
app/shop/[[...slug]]/page.js/shop{ slug: undefined }
app/shop/[[...slug]]/page.js/shop/a{ slug: ['a'] }
app/shop/[[...slug]]/page.js/shop/a/b{ slug: ['a', 'b'] }
app/shop/[[...slug]]/page.js/shop/a/b/c{ slug: ['a', 'b', 'c'] }

💡 강사의 팁: 실무에서는 주로 필터링이나 검색 조건이 들어가는 페이지에서 이중 대괄호 [[...slug]]를 자주 씁니다. 예를 들어, 기본 쇼핑몰 홈(/shop)도 보여줘야 하고, 카테고리를 눌렀을 때(/shop/shoes/sneakers)도 같은 UI 구조에서 데이터만 바꿔서 보여주고 싶을 때 아주 유용하죠. 폴더 구조를 확 줄여주는 마법 같은 기능입니다!

타입스크립트 (TypeScript)

타입스크립트를 사용하고 계신다면, 구성된 라우트 세그먼트에 맞게 params에 타입을 추가할 수 있습니다. page, layout, route 파일에서 각각 params의 타입을 지정하려면 PageProps<'/route'>, LayoutProps<'/route'>, 혹은 RouteContext<'/route'> 헬퍼 타입을 사용하시면 됩니다.

라우트 params의 값은 실행 시간(runtime)이 되어서야 어떤 값이 들어올지 알 수 있기 때문에, 타입은 string, string[], 혹은 undefined (선택적 Catch-all 세그먼트의 경우)로 지정됩니다. 사용자가 주소창에 어떤 URL이든 마음대로 입력할 수 있으므로, 이렇게 넓은 범주의 타입을 설정해 두면 애플리케이션 코드가 발생 가능한 모든 경우의 수를 안전하게 처리할 수 있도록 돕습니다.

라우트 경로 (Route)params 타입 정의 (Type Definition)
app/blog/[slug]/page.js{ slug: string }
app/shop/[...slug]/page.js{ slug: string[] }
app/shop/[[...slug]]/page.js{ slug?: string[] }
app/[categoryId]/[itemId]/page.js{ categoryId: string, itemId: string }

만약, 알려진 언어 코드 집합을 가진 [locale] 파라미터처럼 params가 유효한 값들을 정해진 개수만큼만 가져야 하는 라우트에서 작업 중이라면, 런타임 유효성 검사(runtime validation)를 활용해보세요. 사용자가 잘못된 파라미터를 입력했을 때 이를 안전하게 처리하고, 나머지 애플리케이션 코드는 여러분이 미리 정의해 둔 더 구체적인(좁은) 타입을 확신하며 동작하도록 만들 수 있습니다.

import { notFound } from 'next/navigation'
import type { Locale } from '@i18n/types'
import { isValidLocale } from '@i18n/utils'

function assertValidLocale(value: string): asserts value is Locale {
  if (!isValidLocale(value)) notFound()
}

export default async function Page(props: PageProps<'/[locale]'>) {
  const { locale } = await props.params // locale은 string 타입입니다.
  assertValidLocale(locale)
  // 이제 locale은 Locale 타입으로 좁혀졌습니다!
}

동작 방식 (Behavior)

  • params prop은 Promise 객체입니다. 따라서 값을 꺼내 쓰려면 반드시 async/await를 사용하거나 React의 use 함수를 사용해야 합니다.
    • 버전 14 및 그 이전 버전에서는 params가 동기식(synchronous) prop이었습니다. 하위 호환성을 지원하기 위해 Next.js 15에서도 여전히 동기적으로 접근할 수는 있지만, 이러한 동작은 향후 버전에서 더 이상 지원되지 않을 예정(deprecated)입니다.

💡 강사의 팁 (매우 중요): Next.js 15로 넘어오면서 가장 헷갈려하시는 부분입니다! 예전에는 const { slug } = params로 바로 값을 꺼내 썼지만, 이제는 params가 데이터가 준비되기를 기다려야 하는 Promise 객체로 바뀌었습니다. 따라서 const { slug } = await params처럼 반드시 비동기 처리를 해주셔야 에러가 나지 않습니다.

캐시 컴포넌트와 함께 사용하기 (With Cache Components)

캐시 컴포넌트(Cache Components)를 동적 라우트 세그먼트와 함께 사용할 때, params를 다루는 방식은 generateStaticParams 함수를 사용하는지 여부에 따라 달라집니다.

generateStaticParams를 사용하지 않는다면, 미리 렌더링(prerendering)하는 동안에는 param 값들을 알 수 없습니다. 따라서 params는 런타임(프로그램이 실행될 때) 데이터가 됩니다. 이 경우에는 값을 불러오는 동안 대체 UI(fallback UI)를 보여주기 위해, param에 접근하는 부분을 반드시 <Suspense> 바운더리로 감싸주어야 합니다.

반대로 generateStaticParams를 사용하면, 빌드할 때 사용할 샘플 param 값들을 미리 제공하게 됩니다. 빌드 프로세스는 동적 콘텐츠와 다른 런타임 API들이 올바르게 처리되는지 먼저 검증한 다음, 제공된 샘플들을 위한 정적인(static) HTML 파일들을 생성합니다. 런타임 params로 렌더링된 페이지들은 첫 번째 요청이 성공적으로 완료된 이후에 디스크에 저장(캐싱)됩니다.

아래 섹션에서 두 가지 패턴을 모두 확인해 보겠습니다.

generateStaticParams를 사용하지 않을 때 (Without generateStaticParams)

이 경우 모든 params는 런타임 데이터입니다. param에 접근하는 코드는 반드시 Suspense의 fallback UI로 감싸져야 하죠. Next.js는 빌드 타임에 정적인 껍데기(static shell)만 생성하고, 실제 콘텐츠는 매 요청마다 로드됩니다.

알아두면 좋은 점 (Good to know): 페이지 단위의 fallback UI가 필요하다면 loading.tsx 파일을 활용하실 수도 있습니다.

import { Suspense } from 'react'

export default function Page({ params }: PageProps<'/blog/[slug]'>) {
  return (
    <div>
      <h1>Blog Post</h1>
      <Suspense fallback={<div>Loading...</div>}>
        {params.then(({ slug }) => (
          <Content slug={slug} />
        ))}
      </Suspense>
    </div>
  )
}

async function Content({ slug }: { slug: string }) {
  const res = await fetch(`https://api.vercel.app/blog/${slug}`)
  const post = await res.json()

  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
    </article>
  )
}

generateStaticParams를 사용할 때 (With generateStaticParams)

페이지를 빌드 타임에 미리 렌더링(prerender)하기 위해 파라미터 값들을 사전에 제공하는 방식입니다. 여러분의 필요에 따라 전체 라우트를 미리 렌더링할 수도 있고, 일부 라우트만 골라서 할 수도 있습니다.

빌드 과정 중에, HTML 결과물을 수집하기 위해 각각의 샘플 파라미터를 가지고 라우트가 실행됩니다. 만약 동적인 콘텐츠나 런타임 데이터에 잘못된 방식으로 접근하면 빌드는 실패하게 됩니다.

import { Suspense } from 'react'

export async function generateStaticParams() {
  return [{ slug: '1' }, { slug: '2' }, { slug: '3' }]
}

export default async function Page({ params }: PageProps<'/blog/[slug]'>) {
  const { slug } = await params

  return (
    <div>
      <h1>Blog Post</h1>
      <Content slug={slug} />
    </div>
  )
}

async function Content({ slug }: { slug: string }) {
  const post = await getPost(slug)
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
    </article>
  )
}

async function getPost(slug: string) {
  'use cache'
  const res = await fetch(`https://api.vercel.app/blog/${slug}`)
  return res.json()
}

💡 강사의 팁: 만약 과거에 Next.js Pages 라우터를 써보셨다면 이 기능이 낯설지 않으실 겁니다. getStaticPaths와 거의 동일한 역할을 하는 함수가 바로 generateStaticParams랍니다. 블로그 글이 100개라면, 100개의 페이지를 미리 HTML로 구워내서 사용자가 접속했을 때 빛의 속도로 보여주는 강력한 최적화 기법이죠!

빌드 타임 검증은 샘플 파라미터를 사용해 실행되는 코드 경로(code paths)만 커버합니다. 만약 샘플에 없는 특정 param 값에 대해 런타임 API에 접근하는 조건부 로직(conditional logic)이 라우트에 존재한다면, 그 부분은 빌드 타임에 검증되지 않습니다. 아래 코드를 보실까요?

import { cookies } from 'next/headers'

export async function generateStaticParams() {
  return [{ slug: 'public-post' }, { slug: 'hello-world' }]
}

export default async function Page({ params }: PageProps<'/blog/[slug]'>) {
  const { slug } = await params

  if (slug.startsWith('private-')) {
    // 이 분기는 빌드 타임에는 절대 실행되지 않습니다.
    // 'private-*' 형태의 slug로 들어오는 런타임 요청은 에러를 발생시킵니다.
    return <PrivatePost slug={slug} />
  }

  return <PublicPost slug={slug} />
}

async function PrivatePost({ slug }: { slug: string }) {
  const token = (await cookies()).get('token')
  // ... 인증용 토큰을 사용해 비공개 게시글을 가져오고 렌더링합니다.
}

generateStaticParams에서 반환하지 않은 런타임 params에 대해서는, 사용자의 첫 번째 요청이 들어올 때 검증이 발생합니다. 위 예제에서 private-로 시작하는 slug에 대한 요청은 실패하게 됩니다. 왜냐하면 PrivatePost 컴포넌트가 Suspense 바운더리로 감싸져 있지 않은 상태에서 cookies()에 접근했기 때문입니다. 이 조건부 분기 로직을 타지 않는 다른 일반적인 런타임 params들은 성공적으로 렌더링되어 다음 요청을 위해 디스크에 저장됩니다.

이 문제를 해결하려면 PrivatePost를 Suspense로 감싸주면 됩니다.

import { Suspense } from 'react'
import { cookies } from 'next/headers'

export async function generateStaticParams() {
  return [{ slug: 'public-post' }, { slug: 'hello-world' }]
}

export default async function Page({ params }: PageProps<'/blog/[slug]'>) {
  const { slug } = await params

  if (slug.startsWith('private-')) {
    return (
      <Suspense fallback={<div>Loading...</div>}>
        <PrivatePost slug={slug} />
      </Suspense>
    )
  }

  return <PublicPost slug={slug} />
}

async function PrivatePost({ slug }: { slug: string }) {
  const token = (await cookies()).get('token')
  // ... 인증용 토큰을 사용해 비공개 게시글을 가져오고 렌더링합니다.
}

활용 예시 (Examples)

generateStaticParams 사용하기 (With generateStaticParams)

generateStaticParams 함수를 사용하면, 요청이 들어왔을 때 온디맨드(on-demand)로 페이지를 생성하는 대신 빌드할 때 라우트를 정적으로 생성(statically generate) 할 수 있습니다.

export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())

  return posts.map((post) => ({
    slug: post.slug,
  }))
}
export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())

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

generateStaticParams 함수 내부에서 fetch를 사용할 경우, 동일한 요청은 자동으로 중복 제거(deduplicated) 됩니다. 덕분에 여러 Layout, Page, 그리고 다른 generateStaticParams 함수들에서 같은 데이터를 요구하더라도 불필요한 네트워크 호출을 막아주어 빌드 시간을 크게 단축시켜 줍니다. 아주 스마트하죠?

generateStaticParams를 활용한 동적 GET 라우트 핸들러 (Dynamic GET Route Handlers with generateStaticParams)

generateStaticParams는 단순히 페이지뿐만 아니라, 빌드 타임에 API 응답을 정적으로 생성하기 위해 동적 라우트 핸들러(Route Handlers)와 함께 사용하는 것도 가능합니다.

export async function generateStaticParams() {
  const posts: { id: number }[] = await fetch(
    'https://api.vercel.app/blog'
  ).then((res) => res.json())

  return posts.map((post) => ({
    id: `${post.id}`,
  }))
}

export async function GET(
  request: Request,
  { params }: RouteContext<'/api/posts/[id]'>
) {
  const { id } = await params
  const res = await fetch(`https://api.vercel.app/blog/${id}`)

  if (!res.ok) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }

  const post = await res.json()
  return Response.json(post)
}
export async function generateStaticParams() {
  const posts = await fetch(
    'https://api.vercel.app/blog'
  ).then((res) => res.json())

  return posts.map((post) => ({
    id: `${post.id}`,
  }))
}

export async function GET(request, { params }) {
  const { id } = await params
  const res = await fetch(`https://api.vercel.app/blog/${id}`)

  if (!res.ok) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }

  const post = await res.json()
  return Response.json(post)
}

이 예제에서는 generateStaticParams에서 반환된 모든 블로그 게시글 ID에 대한 라우트 핸들러가 빌드할 때 정적으로 생성됩니다. 여기에 포함되지 않은 다른 ID에 대한 요청이 들어오면, 그때는 런타임에 동적으로 처리된답니다.


다음 단계 (Next Steps)

다음에 무엇을 학습해야 할지 더 많은 정보를 원하신다면, 아래 섹션들을 살펴보시길 추천해 드려요.

  • generateStaticParams
    • generateStaticParams 함수에 대한 상세 API 레퍼런스 문서입니다.

전체 문서에 대한 의미론적인 개요(semantic overview)를 보시려면 https://nextjs.org/docs/sitemap.md를 확인해 주세요.

이용 가능한 모든 문서의 색인(index)을 보시려면 https://nextjs.org/docs/llms.txt를 확인해 주세요.


자, 여기까지 Next.js의 동적 라우팅에 대해 꼼꼼히 살펴보았는데요. 혹시 번역된 내용 중에서 더 깊이 알고 싶거나, 여러분의 프로젝트에 직접 적용해 보고 싶은 부분이 있다면 언제든 말씀해 주세요. 제가 도와드릴까요?

profile
프론트에_가까운_풀스택_개발자

0개의 댓글