Catch-all 세그먼트 사용
필수·선택 Catch-all 폴더가 가변 깊이 URL을 params 배열로 받는 방식을 문서 경로 예제로 익힙니다.
4장 1절에서는 동적 라우트 [slug]가 매개변수 하나를 처리하는 데 쓸모 있다는 점을 확인했습니다. 하지만 실무에서는 URL의 길이가 정해져 있지 않거나, 여러 단계의 경로를 한 페이지에서 처리해야 하는 경우가 자주 생깁니다. 문서 사이트의 /docs/getting-started, /docs/api/v1/auth, /docs/features/dynamic-routes처럼 경로의 깊이가 달라지는 경우가 대표적이며, Next.js App Router는 이를 위해 Catch-all 세그먼트를 제공합니다. Catch-all 세그먼트는 URL의 나머지 경로 조각을 하나의 배열 매개변수로 받습니다.
4장 1절처럼 params Promise를 기다리되, 이번에는 문자열 하나 대신 여러 경로 조각이 들어옵니다. 기존 학습 프로젝트에서 먼저 필수 Catch-all만 만들어 결과를 확인한 뒤, 폴더를 선택 형태로 바꿉니다. 두 구현을 비교하려고 같은 docs 아래에 모두 두면 라우트가 충돌합니다.
Catch-all 세그먼트란?
Catch-all 세그먼트는 점 세 개(...)와 매개변수 이름을 함께 대괄호([])로 감싼 문자열을 폴더 이름으로 써서 정의합니다. [...slug]는 필수 Catch-all 세그먼트로, 그 세그먼트에 값이 하나 이상 있어야 한다는 뜻입니다. 그래서 /docs/[...slug]라면 /docs/a는 매칭되지만 /docs는 매칭되지 않으며, URL에서 꺼낸 값은 문자열 배열로 params에 전달됩니다.
[[...slug]]는 선택적 Catch-all 세그먼트로, 그 세그먼트에 값이 없어도 매칭된다는 뜻입니다. /docs/[[...slug]]라면 /docs도, /docs/a/b도 매칭되며, 꺼낸 값이 없으면 params에서 그 키는 undefined가 되고, 값이 있으면 [...slug]처럼 문자열 배열로 전달됩니다.
NEXT · CH4-3
경로 조각이 없을 때만 매칭이 달라집니다
필수 [...slug]는 한 조각 이상, 선택 [[...slug]]는 /docs 자체도 받습니다. 아래 값은 await params의 결과입니다.
각 화살표는 독립적인 입력 조건과 결과의 대응입니다. 아래 원 비교표와 같은 규약이며 매칭 성공을 문서 존재 확인으로 바꾸지 않습니다.
| 요청 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"]
두 구조는 비교 대상이며 같은 위치에 동시에 만들지 않습니다. 경로 매칭 성공은 문서 존재 확인과 별개입니다. 실제 문서 조회·없음 처리는 현재 출력 예제에 구현되지 않았습니다.
위 그림은 경로 조각이 없을 때만 두 Catch-all의 매칭이 달라진다는 점을 보여 줍니다. 그림의 요약처럼 필수 [...slug]는 한 조각 이상을, 선택 [[...slug]]는 /docs 자체도 받으며, 그림의 값은 await params의 결과입니다. 남은 경로가 /docs/api/v1/auth처럼 세 조각이면 두 설계 모두 매칭되어 slug: ["api", "v1", "auth"]를 얻고, 남은 조각이 없는 /docs 요청은 필수 설계에는 매칭되지 않고 선택 설계에서는 slug: undefined를 얻습니다. 그림의 캡션이 밝히듯 화살표는 입력 조건마다의 대응일 뿐, 한 설계가 다른 설계를 실행하는 순서가 아닙니다.
그림 아래의 표는 요청 URL마다의 결과를 정리합니다. /docs는 필수 설계에서 이 라우트에 매칭되지 않고 선택 설계에서 slug가 undefined이며, /docs/a는 두 설계 모두 slug: ["a"], /docs/a/b는 두 설계 모두 slug: ["a", "b"]입니다. 그림의 마지막 설명처럼 두 구조는 비교 대상일 뿐 같은 위치에 동시에 만들지 않으며, 경로가 매칭되었다고 문서가 있다는 뜻은 아닙니다. 저장소를 연결할 때는 허용된 경로 조각인지 검증하고, 조회 결과가 없으면 notFound()로 처리해야 하며, 지금의 출력 예제에는 실제 문서 조회와 없음 처리가 구현되어 있지 않습니다.
필수 Catch-all 세그먼트 구현하기
필수 Catch-all 세그먼트 [...slug]는 블로그에서 카테고리와 게시물 제목 말고도 깊이가 정해지지 않은 하위 카테고리를 처리하고 싶을 때, 예를 들어 /articles/programming/javascript/nextjs-deep-dive 같은 경로를 처리할 때 쓸모가 있습니다. 여기서는 /docs/path/to/document 같은 경로를 처리하는 문서 사이트를 만들어 봅니다. 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)가 실행 중인 상태에서 http://localhost:3000/docs/first-document, /docs/category/sub-category/article-title, /docs/a/b/c/d에 차례로 들어가 보세요. URL마다 params.slug가 배열로 꺼내져 페이지에 보입니다. 따로 src/app/docs/page.tsx가 없다면 /docs 자체는 404가 되는데, 필수 Catch-all은 조각을 하나 이상 요구하기 때문입니다. 지금의 코드는 경로를 출력하는 예일 뿐 실제 문서 저장소를 조회하지 않습니다.
선택적 Catch-all 세그먼트 구현하기
이제 /docs 경로 자체도 매칭하고 싶을 때 쓰는 [[...slug]]를 구현해 봅니다. 기존의 src/app/docs/[...slug] 폴더 이름을 src/app/docs/[[...slug]]로 바꿉니다. 두 Catch-all 폴더를 같은 수준에 동시에 두지 않으며, 따로 docs/page.tsx가 있다면 선택적 Catch-all의 루트 화면과 충돌하지 않도록 한 곳에서만 /docs를 맡게 합니다.
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;
}이제 http://localhost:3000/docs, /docs/first-document, /docs/category/sub-category/article-title에 들어가 보세요. /docs가 매칭되어 문서 홈 페이지가 보이고, 다른 경로도 이전처럼 잘 동작합니다.
/docs/api/v1/auth에서 받은 slug는 ['api', 'v1', 'auth']입니다. 예제의 slug.join('/')는 이 배열을 화면에 보여 줄 문자열 api/v1/auth로 합칠 뿐, 세 조각을 다시 하나의 동적 세그먼트로 바꾸지 않습니다. 선택적 Catch-all의 /docs에서는 slug가 undefined이며, 코드의 JSON.stringify(slug)도 이때 undefined를 돌려주므로 화면의 code 영역에 문자열 “undefined”가 보이지는 않고 비어 있습니다. 홈 제목과 조건부 문단으로 루트 분기를 확인하고, 배열 값을 화면에 보여 주고 싶다면 JSON.stringify(slug ?? []) 같은 표시 방법을 고를 수 있습니다(Next.js 문서의 동적 세그먼트).
Catch-all 세그먼트의 활용
Catch-all 세그먼트는 문서나 블로그, 위키처럼 계층적이고 유연한 URL 구조가 필요할 때 유용하고, 파일 경로 자체가 콘텐츠의 위치를 나타내는 파일 시스템 기반 CMS를 만들 때도 쓸 수 있습니다. 다른 라우트에 매칭되지 않은 요청을 Catch-all 라우트가 받아 처리하게 해, 404를 피하거나 직접 만든 오류 페이지를 보여 주는 대체(fallback) 라우트로도 씁니다. 식별자가 하나만 필요하다면 [id]처럼 세그먼트 하나로 표현하고, 같은 화면이 여러 깊이의 문서 경로를 처리할 때 Catch-all을 고릅니다.
정리
[...slug]는 필수 Catch-all로 한 조각 이상의 경로를,[[...slug]]는 선택적 Catch-all로 조각이 없는 경로까지 받습니다.- Catch-all의 값은
await params뒤 문자열 배열로 들어오며, 선택적 Catch-all에서 조각이 없으면undefined입니다. - 필수와 선택 Catch-all을 같은 위치에 동시에 두지 않고, 선택적 Catch-all과 같은 경로의
page.tsx를 함께 두지 않습니다. - 경로가 매칭되어도 콘텐츠가 있다는 뜻은 아니므로, 조각을 검증하고 없는 문서는
notFound()로 처리합니다.
연습 문제
1. 조각의 수, 키 이름, 콘텐츠의 존재
별도의 설계 app/manual/[[...parts]]/page.tsx가 /manual과 /manual/http/cache를 받는다면, 각 await params 결과의 키와 값은 무엇일까요? 같은 설계를 [...parts]로 바꾸면 /manual의 매칭은 어떻게 달라질까요? /manual/unknown이 매칭되었다는 사실만으로 실제 문서가 있다고 결론 내릴 수 있을까요?
해설 보기
선택 형태에서 /manual은 parts: undefined, /manual/http/cache는 parts: ['http', 'cache']입니다. 필수 형태는 조각을 하나 이상 요구하므로 /manual이 이 Catch-all에 매칭되지 않으며, 따로 루트 page가 없다면 그 화면은 404입니다.
unknown 한 조각은 두 형태 모두 경로로는 받을 수 있지만, 데이터를 조회한 결과가 없다면 내용이 없는 경우로 처리해야 합니다. 라우팅의 매칭 여부와 콘텐츠의 존재 여부는 서로 다른 판단입니다.
2. 한 조각이면 충분한 경우
상품 상세 페이지의 URL이 언제나 /products/<상품ID> 한 단계뿐입니다. 이 경우에도 [...id]를 쓰는 것이 좋을까요?
해설 보기
좋지 않습니다. 식별자가 하나뿐이라면 [id]처럼 세그먼트 하나로 표현하는 편이 params.id를 문자열로 바로 받을 수 있어 단순합니다. [...id]를 쓰면 값이 배열로 들어와 매번 꺼내야 하고, /products/a/b처럼 의도하지 않은 깊은 경로까지 받아들이게 됩니다.
3. 함께 둘 수 없는 파일
src/app/docs/page.tsx가 이미 있는데 src/app/docs/[[...slug]]/page.tsx를 더했습니다. 어떤 문제가 생길까요?
해설 보기
선택적 Catch-all은 /docs 자체도 받으므로, docs/page.tsx와 [[...slug]]/page.tsx가 같은 /docs 경로를 함께 맡게 되어 라우트가 충돌합니다. 한 곳에서만 /docs를 맡도록, 루트 화면을 선택적 Catch-all의 slug가 없는 분기로 옮기고 docs/page.tsx를 지우거나, 필수 Catch-all [...slug]로 바꾸어 /docs는 docs/page.tsx가 맡게 합니다.
다음 글 라우트 그룹 활용에서는 URL에 영향을 주지 않고 라우트를 논리적으로 묶는 라우트 그룹을 알아봅니다.