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 값
| 요청 URL | 필수 [...slug] | 선택 [[...slug]] |
|---|---|---|
/docs | 이 라우트에는 매칭하지 않음 | slug: undefined |
/docs/a | slug: ["a"] | slug: ["a"] |
/docs/a/b | slug: ["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 파일 내용 작성:
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-documenthttp://localhost:3000/docs/category/sub-category/article-titlehttp://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일 수 있다는 점을 고려하여 코드를 수정합니다.
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-documenthttp://localhost:3000/docs/category/sub-category/article-title
/docs 경로가 매칭되어 문서 홈 페이지가 표시되고, 다른 경로들도 이전처럼 잘 동작하는 것을 확인할 수 있습니다.
Catch-all 세그먼트의 활용 시나리오
- 동적인 URL 구조 처리: 문서, 블로그, 위키 등 계층적이고 유연한 URL 구조가 필요한 경우에 유용합니다.
- 파일 시스템 기반 CMS: 파일 경로 자체가 콘텐츠의 위치를 나타내는 시스템을 구축할 때 활용될 수 있습니다.
- 폴백(Fallback) 라우트: 특정 경로가 매칭되지 않을 경우, Catch-all 라우트가 해당 요청을 처리하도록 하여 404 에러를 방지하거나, 커스텀 에러 페이지를 보여줄 수 있습니다.
단일 식별자만 필요하면 [id]처럼 한 세그먼트로 표현하고, 동일한 화면이 여러 깊이의 문서 경로를 처리할 때 Catch-all을 선택합니다.
다음 절에서는 라우트 그룹을 사용하여 라우트 구조를 논리적으로 조직하는 방법을 알아보겠습니다.