안동민 개발노트

안동민 개발노트

동적 라우트 생성중첩 라우트와 레이아웃 구성Catch-all 세그먼트 사용라우트 그룹 활용
본문 시작
  1. 홈
  2. 문서
  3. Next.js
  4. 4장 : 라우팅 심화
  5. Catch-all 세그먼트 사용
  1. Next.js
  2. Catch-all 세그먼트 사용

Catch-all 세그먼트 사용

필수·선택 Catch-all 폴더가 가변 깊이 URL을 params 배열로 받는 방식을 문서 경로 예제로 익힙니다.

이전 절에서 동적 라우트 [slug]가 단일 파라미터 처리에 유용하다는 점을 확인했습니다.

하지만 실무에서는 URL 길이가 가변적이거나 중첩된 여러 경로를 한 페이지에서 처리해야 하는 경우가 자주 생깁니다.

예를 들어 문서 사이트의 /docs/getting-started, /docs/api/v1/auth, /docs/features/dynamic-routes처럼 경로 깊이가 달라지는 시나리오가 대표적입니다.

이러한 시나리오를 위해 Next.js App Router는 Catch-all 세그먼트를 제공합니다.

Catch-all 세그먼트는 URL의 나머지 경로 조각을 하나의 배열 파라미터로 받습니다.


Catch-all 세그먼트란?

Catch-all 세그먼트는 폴더 이름을 세 개의 점(...)과 파라미터 이름을 함께 대괄호([])로 감싸서 정의합니다.

  • [...slug] (필수 Catch-all 세그먼트): 이 형태는 해당 세그먼트가 최소한 하나 이상의 값을 포함해야 함을 의미합니다.

    즉, /docs/[...slug]라면 /docs/a는 매칭되지만 /docs는 매칭되지 않습니다.

    URL에서 추출된 값은 문자열 배열로 params 객체에 전달됩니다.

  • [[...slug]] (선택적 Catch-all 세그먼트): 이 형태는 해당 세그먼트가 값이 없어도 매칭됨을 의미합니다.

    즉, /docs/[[...slug]]라면 /docs도 매칭되고 /docs/a/b도 매칭됩니다.

    URL에서 추출된 값이 없을 경우 params 객체에서 해당 키는 undefined가 되며, 값이 있을 경우 [...slug]와 동일하게 문자열 배열로 전달됩니다.

Catch-all의 매칭과 slug 값

Catch-all의 매칭과 slug 값

Catch-all의 매칭과 slug 값
요청 URL필수 [...slug]선택 [[...slug]]
/docs이 라우트에는 매칭하지 않음slug: undefined
/docs/aslug: ["a"]slug: ["a"]
/docs/a/bslug: ["a", "b"]slug: ["a", "b"]
요청 URL: /docs
필수 [...slug]: 이 라우트에는 매칭하지 않음
선택 [[...slug]]: slug: undefined
요청 URL: /docs/a
필수 [...slug]: slug: ["a"]
선택 [[...slug]]: slug: ["a"]
요청 URL: /docs/a/b
필수 [...slug]: slug: ["a", "b"]
선택 [[...slug]]: slug: ["a", "b"]

표는 await params로 꺼낸 값입니다. 라우트가 매칭되었다고 문서가 존재하는 것은 아닙니다. 저장소를 연결할 때는 허용된 경로 조각을 검증하고, 조회 결과가 없으면 notFound()로 처리해야 합니다.


필수 Catch-all 세그먼트 구현하기

필수 Catch-all 세그먼트 [...slug]는 블로그에서 카테고리와 게시물 제목 외에, 임의의 깊이를 가진 서브 카테고리를 처리하고 싶을 때 유용합니다.

예를 들어 /articles/programming/javascript/nextjs-deep-dive와 같은 경로를 처리할 수 있습니다.

예시: 문서 사이트 구현

우리는 /docs/path/to/document와 같은 경로를 처리하는 문서를 만들 것입니다.

src/app/docs 폴더 생성: 먼저 src/app 안에 docs 폴더를 생성합니다.

...

[...slug] 폴더 생성: src/app/docs 안에 [...slug]라는 이름의 폴더를 생성합니다.

page.tsx 파일 생성: src/app/docs/[...slug] 폴더 안에 page.tsx 파일을 생성합니다.

src/app/docs/[...slug]/page.tsx 파일 내용 작성:

src/app/docs/[...slug]/page.tsx
interface DocDetailPageProps {
  params: Promise<{
    slug: string[]; // Catch-all 세그먼트는 문자열 배열로 전달됩니다.
  }>;
}

// 이 컴포넌트는 서버 컴포넌트로 동작합니다.
export default async function DocDetailPage({ params }: DocDetailPageProps) {
  const { slug } = await params; // URL에서 slug 값을 배열로 추출

  // slug 배열을 사용하여 실제 문서 데이터를 불러오는 로직을 여기에 작성합니다.
  // 예: const docContent = await fetchDocContent(slug.join('/'));
  const docPath = slug.join(' / '); // 경로를 가독성 좋게 표시하기 위함

  return (
    <div>
      <h1>문서 상세 페이지</h1>
      <p>
        현재 문서의 경로는 <strong>/docs/{slug.join('/')}</strong> 입니다.
      </p>
      <p>
        추출된 파라미터 (`params.slug`): <code>{JSON.stringify(slug)}</code>
      </p>
      <p>여기에 실제 문서 내용이 표시됩니다.</p>
    </div>
  );
}

// SSG를 위해 미리 생성할 경로들을 정의합니다. (선택 사항)
export async function generateStaticParams() {
  const paths = [
    { slug: ['getting-started'] },
    { slug: ['api', 'v1', 'auth'] },
    { slug: ['features', 'dynamic-routes'] },
  ];
  return paths;
}

실습: 개발 서버(npm run dev)가 실행 중인 상태에서 브라우저를 열고 다음 URL로 접속해 보세요.

  • http://localhost:3000/docs/first-document
  • http://localhost:3000/docs/category/sub-category/article-title
  • http://localhost:3000/docs/a/b/c/d

각 URL에 따라 params.slug가 배열 형태로 추출되고 페이지에 표시되는 것을 확인할 수 있습니다.

별도 src/app/docs/page.tsx가 없다면 /docs 자체에는 404가 나옵니다. 필수 Catch-all은 한 조각 이상을 요구하기 때문입니다. 현재 코드는 경로를 출력하는 예시이며 실제 문서 저장소를 조회하지 않습니다.


선택적 Catch-all 세그먼트 구현하기

이제 /docs 경로 자체도 매칭시키고 싶을 때 사용하는 [[...slug]]를 구현해 보겠습니다.

기존 [...slug] 폴더를 [[...slug]]로 이름 변경: src/app/docs/[...slug] 폴더의 이름을 src/app/docs/[[...slug]]로 변경합니다. 두 Catch-all 폴더를 같은 레벨에 동시에 두지 않습니다. 별도 docs/page.tsx가 있다면 선택적 Catch-all의 루트 화면과 충돌하지 않도록 한 곳에서만 /docs를 담당하게 합니다.

src/app/docs/[[...slug]]/page.tsx 파일 내용 수정: page.tsx 파일 내용은 거의 동일하지만, params.slug가 undefined일 수 있다는 점을 고려하여 코드를 수정합니다.

src/app/docs/[[...slug]]/page.tsx
interface DocsPageProps {
  params: Promise<{
    slug?: string[]; // slug가 선택적(optional)이므로 ? 추가
  }>;
}

export default async function DocsPage({ params }: DocsPageProps) {
  const { slug } = await params; // slug가 없을 경우 undefined

  // slug 배열이 존재하면 경로를 합치고, 없으면 "홈"으로 표시
  const docPath = slug ? slug.join(' / ') : '홈';

  return (
    <div>
      <h1>문서 페이지 ({docPath})</h1>
      {slug ? (
        <p>
          현재 문서의 경로는 <strong>/docs/{slug.join('/')}</strong> 입니다.
        </p>
      ) : (
        <p>문서 홈 페이지입니다. 시작하려면 왼쪽 메뉴를 선택하세요.</p>
      )}
      <p>추출된 파라미터 (`params.slug`): <code>{JSON.stringify(slug)}</code></p>
      <p>여기에 실제 문서 내용이 표시됩니다.</p>
    </div>
  );
}

// generateStaticParams도 마찬가지로 빈 배열을 포함할 수 있습니다.
export async function generateStaticParams() {
  const paths = [
    { slug: ['getting-started'] },
    { slug: ['api', 'v1', 'auth'] },
    // 루트 문서 페이지도 미리 생성하려면 빈 배열을 추가합니다.
    { slug: [] },
  ];
  return paths;
}

실습: 이제 다음 URL로 접속해 보세요.

  • http://localhost:3000/docs (매칭 성공!)
  • http://localhost:3000/docs/first-document
  • http://localhost:3000/docs/category/sub-category/article-title

/docs 경로가 매칭되어 문서 홈 페이지가 표시되고, 다른 경로들도 이전처럼 잘 동작하는 것을 확인할 수 있습니다.


Catch-all 세그먼트의 활용 시나리오

  • 동적인 URL 구조 처리: 문서, 블로그, 위키 등 계층적이고 유연한 URL 구조가 필요한 경우에 유용합니다.
  • 파일 시스템 기반 CMS: 파일 경로 자체가 콘텐츠의 위치를 나타내는 시스템을 구축할 때 활용될 수 있습니다.
  • 폴백(Fallback) 라우트: 특정 경로가 매칭되지 않을 경우, Catch-all 라우트가 해당 요청을 처리하도록 하여 404 에러를 방지하거나, 커스텀 에러 페이지를 보여줄 수 있습니다.

단일 식별자만 필요하면 [id]처럼 한 세그먼트로 표현하고, 동일한 화면이 여러 깊이의 문서 경로를 처리할 때 Catch-all을 선택합니다.

다음 절에서는 라우트 그룹을 사용하여 라우트 구조를 논리적으로 조직하는 방법을 알아보겠습니다.

중첩 라우트와 레이아웃 구성

이전 페이지

라우트 그룹 활용

다음 페이지

이 페이지의 목차

Catch-all 세그먼트란?필수 Catch-all 세그먼트 구현하기선택적 Catch-all 세그먼트 구현하기Catch-all 세그먼트의 활용 시나리오