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도 매칭됩니다.URL에서 추출된 값이 없을 경우
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"]
두 구조는 비교 대상이며 같은 위치에 동시에 만들지 않습니다. 경로 매칭 성공은 문서 존재 확인과 별개입니다. 실제 문서 조회·없음 처리는 현재 출력 예제에 구현되지 않았습니다.
표는 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 경로가 매칭되어 문서 홈 페이지가 표시되고, 다른 경로들도 이전처럼 잘 동작하는 것을 확인할 수 있습니다.
예시: 화면에 합친 경로와 원 배열을 나누기
/docs/api/v1/auth에서 받은 slug는 ['api', 'v1', 'auth']입니다. 예제의 slug.join('/')는 이 배열을 표시용 문자열 api/v1/auth로 합칠 뿐, 세 조각을 다시 하나의 동적 세그먼트로 바꾸지 않습니다. 선택 Catch-all의 /docs에서는 slug가 undefined입니다. 원 JSX의 JSON.stringify(slug)도 이 경우 undefined를 반환하므로 코드 표시 영역에 문자 undefined가 나타나는 것은 아닙니다. 홈 제목과 조건부 문단으로 루트 분기를 확인하고, 배열 값을 화면에 표시하려면 JSON.stringify(slug ?? []) 같은 별도 표시 정책을 선택할 수 있습니다.
독립 설계 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 한 조각은 두 형태 모두 경로상 받을 수 있지만, 데이터 조회 결과가 없다면 내용 없음 처리를 해야 합니다. 라우팅의 매칭 여부와 콘텐츠 존재 여부는 서로 다른 판단입니다. 필수·선택 Catch-all 규약을 배열과 undefined의 타입에 맞춰 확인합니다.
Catch-all 세그먼트의 활용 시나리오
- 동적인 URL 구조 처리: 문서, 블로그, 위키 등 계층적이고 유연한 URL 구조가 필요한 경우에 유용합니다.
- 파일 시스템 기반 CMS: 파일 경로 자체가 콘텐츠의 위치를 나타내는 시스템을 구축할 때 활용될 수 있습니다.
- 폴백(Fallback) 라우트: 특정 경로가 매칭되지 않을 경우, Catch-all 라우트가 해당 요청을 처리하도록 하여 404 에러를 방지하거나, 커스텀 에러 페이지를 보여줄 수 있습니다.
단일 식별자만 필요하면 [id]처럼 한 세그먼트로 표현하고, 동일한 화면이 여러 깊이의 문서 경로를 처리할 때 Catch-all을 선택합니다.
다음 절에서는 라우트 그룹을 사용하여 라우트 구조를 논리적으로 조직하는 방법을 알아보겠습니다.