본문으로 건너뛰기

안동민 개발노트

본문 시작

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의 나머지 경로 조각을 하나의 배열 파라미터로 받습니다.

아래 표는 여러 깊이의 URL이 params.slug 배열로 바뀌는 기준을 먼저 보여줍니다.

가변 깊이 URL은 남은 조각을 params.slug 배열로 모은다

문서 사이트처럼 경로 깊이가 달라질 때 catch-all 세그먼트는 URL 뒤쪽 조각을 한 page가 처리할 배열로 바꾼다.

요청 URL기준 경로 뒤 조각params.slug페이지가 할 일
/docs/getting-startedgetting-started["getting-started"]문서 1개 조회
/docs/api/v1/authapi / v1 / auth["api", "v1", "auth"]계층 경로로 문서 조회
/docs/features/dynamic-routesfeatures / dynamic-routes["features", "dynamic-routes"]카테고리와 문서명 분리
/docs없음[...slug] 는 매칭 안 됨문서 홈을 별도 route로 처리
/docs없음[[...slug]] 는 undefined문서 홈도 같은 page에서 처리

Catch-all 세그먼트란?

폴더 이름을 읽을 때는 세 점(...)이 의미하는 매칭 범위와 params.slug 타입을 함께 봐야 합니다.

다음 표는 일반 동적 세그먼트와 catch-all 세그먼트의 차이를 구현 기준으로 정리합니다.

Catch-all은 남은 URL 조각을 하나의 배열로 모은다

고정된 앞부분 뒤에 깊이가 달라지는 문서 경로를 한 route가 처리한다.

  1. /docs/react
    [react]

    slug = ['react']

  2. /docs/react/hooks
    [react, hooks]

    slug 배열 길이 = 2

  3. /docs/a/b/c
    [a, b, c]

    깊이와 무관하게 같은 page 사용

  4. /docs
    매칭 안 됨

    루트도 허용하려면 [[...slug]] 사용

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의 가변적인 부분을 유연하게 처리할 수 있게 해줍니다.

다음 다이어그램은 [...slug][[...slug]]가 어떤 URL을 매칭하는지, 그리고 URL path 조각이 params.slug로 전달되는 흐름을 한눈에 비교합니다.

필수 catch-all과 선택적 catch-all의 차이는 /docs 자체를 받는가이다

둘 다 깊은 URL은 배열로 받지만, 기준 경로만 들어왔을 때의 결과가 다르다.

URL[...slug][[...slug]]설계 의미
/docs매칭 안 됨slug 없음문서 홈을 같은 page에 둘지 결정
/docs/a["a"]["a"]단일 문서 상세
/docs/a/b["a", "b"]["a", "b"]계층 문서 상세
/docs/api/v1/auth["api", "v1", "auth"]["api", "v1", "auth"]깊은 API 문서
잘못된 루트 접근404가 명확함빈 상태 UI 필요사용자 흐름에 맞춰 선택

구현 전에는 "빈 경로를 허용할 것인가"와 "콘텐츠 조회 키를 어떻게 만들 것인가"를 먼저 정리해야 합니다.

아래 다이어그램은 URL이 라우트 파라미터와 콘텐츠 조회 단계로 이어지는 흐름을 분리해 보여줍니다.

URL 조각은 검증을 거쳐 콘텐츠 조회 key가 된다

params 배열을 저장소 경로로 바로 사용하지 않고 안전한 조회 단계로 변환한다.

  1. URL
    /docs/react/hooks

    사용자가 요청한 공개 주소

  2. params
    ['react', 'hooks']

    라우터가 segment 배열로 제공

  3. validate
    허용 문자·깊이

    빈 값·상위 경로·예약어를 거부

  4. normalize
    react/hooks

    저장소 규칙에 맞는 canonical key

  5. lookup
    content or 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가 배열 형태로 추출되고 페이지에 표시되는 것을 확인할 수 있습니다.

/docs로만 접속하면 페이지를 찾을 수 없다는 404 에러가 발생할 것입니다.

이는 [...slug]가 최소한 하나의 세그먼트를 요구하기 때문입니다.


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

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

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

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

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 에러를 방지하거나, 커스텀 에러 페이지를 보여줄 수 있습니다.

마지막으로 필수 Catch-all과 선택적 Catch-all을 고를 때는 URL 매칭 범위와 params.slug의 형태를 함께 비교해야 합니다.

아래 다이어그램은 두 패턴의 차이를 실무 판단 기준으로 정리합니다.

루트 경로가 화면인지 여부로 catch-all 형태를 고른다

빈 segment를 유효한 목록 화면으로 쓸지, 별도 route로 분리할지를 먼저 결정한다.

  1. 아니오
    [...slug]

    한 조각 이상 있을 때만 매칭

  2. [[...slug]]

    빈 배열 또는 undefined까지 같은 page가 처리

  3. 다른 UI
    page + [...slug]

    루트 목록과 상세 콘텐츠 책임을 분리

  4. 공통
    입력 정규화

    빈 값·허용 깊이·notFound 조건을 명시

구현할 때는 폴더 이름만 맞추는 데서 끝나지 않습니다.

params를 꺼내는 방식, 빈 경로 처리, 콘텐츠 조회 키, notFound() 분기까지 함께 점검해야 합니다.

현재 App Router 예시는 params를 await하고 slug 부재를 먼저 분기한다

catch-all 구현의 품질은 폴더 이름보다 params 처리, notFound 분기, 정적 경로 목록에서 갈린다.

구현 지점필수 패턴선택 패턴점검 포인트
props 타입params: Promise<{ slug: string[] }>params: Promise<{ slug?: string[] }>현재 App Router 문법
값 꺼내기const { slug } = await paramsconst { slug } = await params서버 컴포넌트에서 await
빈 경로라우트 매칭 안 됨slug가 undefined 가능홈 화면 또는 빈 상태
조회 키slug.join('/')(slug ?? []).join('/')허용 문자와 깊이 검증
없는 문서notFound()홈이면 목록, 상세면 notFound()404와 빈 화면 구분

마지막으로 어떤 상황에 catch-all을 쓰고, 어떤 상황에서는 일반 동적 세그먼트가 더 나은지 시나리오별로 비교합니다.

catch-all은 계층 URL을 한 화면 책임으로 묶을 때만 쓴다

URL이 길다고 무조건 catch-all을 쓰는 것이 아니라, 같은 데이터 모델과 같은 화면 책임을 공유할 때 선택한다.

시나리오URL 예시추천 패턴주의할 점
문서 사이트/docs/api/v1/auth[[...slug]]홈과 상세 분기
블로그 카테고리/articles/web/nextjs[...slug]카테고리와 글 slug 규칙
파일 기반 CMS/docs/guides/install/windows[...slug]파일 경로 탐색 보안
커스텀 404 대체/anything/unknown[...slug]너무 넓게 잡아 실제 404를 숨기지 않기
상품 상세/products/123[id]한 조각이면 catch-all 과함

Catch-all 세그먼트는 여러 단계의 URL을 하나의 파라미터로 처리할 때 사용합니다.

URL 패턴이 유동적인 페이지에서 라우트 정의를 단순하게 유지할 수 있습니다.

이것으로 동적 라우트와 Catch-all 세그먼트에 대한 설명을 마칩니다.

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