icon
4장 : 라우팅 심화

Catch-all 세그먼트 사용

이전 절에서 동적 라우트가 [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 세그먼트란?

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]와 동일하게 문자열 배열로 전달됩니다.

이 두 가지 형태 모두 URL의 가변적인 부분을 유연하게 처리할 수 있게 해줍니다.

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

필수 Catch-all 세그먼트 [...slug]는 블로그에서 카테고리와 게시물 제목 외에, 임의의 깊이를 가진 서브 카테고리를 처리하고 싶을 때 유용합니다. 예를 들어 /articles/programming/javascript/nextjs-deep-dive와 같은 경로를 처리할 수 있습니다.

예시: 문서 사이트 구현

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

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

    src/
    └── app/
        ├── docs/  <- 여기에 docs 폴더 생성
        └── ...
  2. [...slug] 폴더 생성: src/app/docs 안에 [...slug]라는 이름의 폴더를 생성합니다.

    src/
    └── app/
        ├── docs/
        │   └── [...slug]/  <- 여기에 [...slug] 폴더 생성
        └── ...
  3. page.tsx 파일 생성: src/app/docs/[...slug] 폴더 안에 page.tsx 파일을 생성합니다.

    src/
    └── app/
        ├── docs/
        │   └── [...slug]/
        │       └── page.tsx  <- 여기에 page.tsx 파일 생성
        └── ...
  4. src/app/docs/[...slug]/page.tsx 파일 내용 작성:

    // src/app/docs/[...slug]/page.tsx
    
    interface DocDetailPageProps {
      params: {
        slug: string[]; // Catch-all 세그먼트는 문자열 배열로 전달됩니다.
      };
    }
    
    // 이 컴포넌트는 서버 컴포넌트로 동작합니다.
    export default function DocDetailPage({ params }: DocDetailPageProps) {
      const { slug } = 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가 배열 형태로 추출되고 페이지에 표시되는 것을 확인할 수 있습니다. /docs로만 접속하면 페이지를 찾을 수 없다는 404 에러가 발생할 것입니다. 이는 [...slug]가 최소한 하나의 세그먼트를 요구하기 때문입니다.

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

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

  1. 기존 [...slug] 폴더를 [[...slug]]로 이름 변경: src/app/docs/[...slug] 폴더의 이름을 src/app/docs/[[...slug]]로 변경합니다. (또는 새롭게 생성합니다.)

    src/
    └── app/
        └── docs/
            └── [[...slug]]/  <- 폴더 이름 변경 (대괄호 두 개)
                └── page.tsx
  2. src/app/docs/[[...slug]]/page.tsx 파일 내용 수정: page.tsx 파일 내용은 거의 동일하지만, params.slugundefined일 수 있다는 점을 고려하여 코드를 수정합니다.

    // src/app/docs/[[...slug]]/page.tsx
    
    interface DocsPageProps {
      params: {
        slug?: string[]; // slug가 선택적(optional)이므로 ? 추가
      };
    }
    
    export default function DocsPage({ params }: DocsPageProps) {
      const { slug } = 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 에러를 방지하거나, 커스텀 에러 페이지를 보여줄 수 있습니다.

Catch-all 세그먼트는 Next.js App Router의 유연성을 극대화하는 강력한 도구입니다. 이를 통해 어떤 URL 패턴도 효율적으로 처리하고, 더욱 견고하고 사용자 친화적인 애플리케이션을 구축할 수 있습니다.

이것으로 동적 라우트와 Catch-all 세그먼트에 대한 설명을 마칩니다. 다음 절에서는 라우트 그룹을 사용하여 라우트 구조를 논리적으로 조직하는 방법에 대해 알아보겠습니다.