Next.js의 동적 slug, catch-all, optional catch-all과 searchParams는 어떻게 다른가요?
[id]·[slug]는 URL 한 조각을 문자열로, [...slug]는 여러 조각을 배열로 받습니다. [[...slug]]는 기본 경로도 허용합니다. 본문은 params를 await한 뒤 읽으며 ?id= 같은 검색 조건은 searchParams로 구분합니다.
적용 조건·범위: React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.
독립 실습: catch-all 배열과 기본 경로 비교하기
React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.
npm run dev 후 /docs와 /docs/react/state를 각각 엽니다.
app/docs/[[…slug]]/page.tsx 전체 코드
export default async function Page({ params }: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug = [] } = await params;
return <p>경로: {slug.length ? slug.join(' > ') : '문서 홈'}</p>;
}
기대 결과: /docs는 문서 홈, /docs/react/state는 react > state입니다.
자가 확인: [[…slug]]를 […slug]로 바꾸면 /docs 자체가 더 이상 매칭되지 않는 이유를 설명합니다.
기본 예제
app/tasks/[id]/page.tsx
→ /tasks/task-1
→ /tasks/task-2
export default async function TaskPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <h1>{id}</h1>;
}
catch-all
app/docs/[...slug]/page.tsx
/docs/react/state
→ slug = ['react', 'state']
자주 발생하는 오류
- 폴더 이름과 params 키 불일치
- catch-all 값을 문자열로 가정
- query string과 동적 segment 혼동
- Pages Router 예제를 그대로 적용
/docs/react/state의 catch-all 값은 react와 state 두 조각입니다. /docs 자체에서는 optional catch-all의 slug가 undefined일 수 있으므로 기본 문서 화면을 선택합니다. 폴더의 대괄호 이름과 params 키를 함께 맞추고 배열을 단일 문자열처럼 사용하지 않습니다.
공식 원리: Next.js 동적 라우트 slug params catch-all 관련 문서 (2026-10-12 확인). 아래 결과는 실행 전 기대 기준입니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.
8편에 이미 만든 [id] 경로를 읽으며 동적 segment를 복습합니다. 존재하지 않는 ID는 notFound로 처리합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Next.js 프로젝트 적용
시작 체크포인트: 이전: Next.js Intercepting Routes 모달 라우팅의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
직접 확인: ID와 URL 매개변수
독자 질문: /tasks/task-1의 task-1은 어디서 읽으며 ?id=task-1과 무엇이 다를까요? TaskFlow의 app/(app)/tasks/[id]/page.tsx는 8회차에 이미 만들어졌습니다. 9회차는 기존 코드를 읽으며 동적 경로를 복습합니다.
params: Promise<{ id: string }>,await params,getTask(id), 없는 ID의notFound()를 찾습니다./tasks/task-1직접 접근은 ‘App Router 공부’ 제목·설명·상태의 전체 상세를 예상합니다. 목록 Link 이동은 8회차 모달과 구분합니다./tasks/task-does-not-exist는 존재하지 않는 데이터로 404 경로를 확인합니다./tasks?id=task-1의 query string은[id]segment가 아닙니다.- 상단 optional 예제와 비교하는 일반 catch-all 경로
app/docs/[...slug]/page.tsx에서/docs/react/state의slug는['react', 'state']입니다.[...slug]는/docs자체를 포함하지 않고[[...slug]]는 포함할 수 있습니다. 이 docs 경로는 TaskFlow 완료 ZIP에 없는 문법 예제입니다. npm run typecheck,npm run build결과와 직접 URL·목록 Link 결과를 각각 기록합니다.
오류 점검
params.slug가 undefined면 실제 폴더명이 [id]인지 [slug]인지 확인합니다. catch-all은 배열이고 query string은 별도 searchParams입니다.
검증 상태: 문서·완료 코드를 대조했습니다. 이번 편집에서는 빌드·개발 서버·브라우저를 실행하지 않아 실제 화면과 404 결과는 미확인입니다.
다음 학습
10회차에서 Promise 형태의 params를 기다려야 하는 이유를 다룹니다.
완료 코드 ZIP 다운로드 — next-step-09-dynamic-route
이전 체크포인트: next-step-08-intercepting-routes
이번 회차의 변경
8편에 이미 만든 [id] 경로를 읽으며 동적 segment를 복습합니다. 존재하지 않는 ID는 notFound로 처리합니다. catch-all은 개념 예제이며 작업 상세는 단일 [id]를 사용합니다.
이 회차는 이전 프로젝트의 실행 코드를 유지하고 설정·오류·운영 기준을 점검합니다.
전체 파일·코드 보기
실행과 검증
Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.
npm install
npm run dev
npm run build
npm run start
전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.
완료 체크포인트: next-step-09-dynamic-route
실습 프로젝트 전체 코드
.gitignore
node_modules
.next
.env*
!.env.example
*.tsbuildinfo
app/(app)/@modal/(.)tasks/[id]/page.tsx
import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import Modal from "@/app/ui/modal";
export default async function TaskModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
if (!task) notFound();
return (
<Modal>
<h1>{task.title}</h1>
<p>{task.description}</p>
<p>상태: {task.completed ? "완료" : "진행 중"}</p>
</Modal>
);
}
app/(app)/@modal/[…catchAll]/page.tsx
export default function ResetModal() {
return null;
}
app/(app)/@modal/default.tsx
export default function Default() {
return null;
}
app/(app)/layout.tsx
import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
export default async function AppLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<>
<SiteHeader />
{children}
{modal}
</>
);
}
app/(app)/settings/page.tsx
export default function SettingsPage() {
return (
<main>
<h1>설정</h1>
<p>TaskFlow 설정 화면입니다.</p>
</main>
);
}
app/(app)/tasks/[id]/page.tsx
import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
export default async function TaskDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
if (!task) notFound();
return (
<main>
<h1>{task.title}</h1>
<p>{task.description}</p>
<p>상태: {task.completed ? "완료" : "진행 중"}</p>
<Link href="/tasks">목록</Link>
</main>
);
}
app/(app)/tasks/page.tsx
import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>작업 목록</h1>
<TaskSearch />
<ul>
{tasks.map((task) => (
<li key={task.id}>
<Link href={"/tasks/" + task.id}>{task.title}</Link>
<span> · {task.completed ? "완료" : "진행 중"}</span>
</li>
))}
</ul>
</main>
);
}
app/(app)/template.tsx
import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
return <>{children}</>;
}
app/(public)/page.tsx
import Link from "next/link";
export default function HomePage() {
return (
<main>
<h1>TaskFlow Next.js</h1>
<p>App Router 기반 작업 관리 서비스입니다.</p>
<Link href="/tasks">작업 목록 보기</Link>
</main>
);
}
app/_components/SiteHeader.tsx
import Link from "next/link";
export default function SiteHeader() {
return (
<header>
<nav aria-label="주 메뉴">
<Link href="/">홈</Link>
<Link href="/tasks">작업</Link>
<Link href="/settings">설정</Link>
</nav>
</header>
);
}
app/globals.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #171717;
background: #fafafa;
font-family: Arial, sans-serif;
line-height: 1.6;
}
header,
main {
width: min(880px, calc(100% - 32px));
margin: 24px auto;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
a {
color: inherit;
text-underline-offset: 3px;
}
button,
input,
select {
min-height: 44px;
padding: 8px 12px;
font: inherit;
border: 1px solid #aaa;
background: #fff;
color: #171717;
max-width: 100%;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
input {
display: block;
width: 100%;
}
label {
display: block;
}
form,
section,
aside {
margin: 20px 0;
}
ul {
padding: 0;
list-style: none;
}
li {
padding: 12px;
margin: 12px 0;
border: 1px solid #ccc;
overflow-wrap: anywhere;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.controls form {
margin: 0;
}
.modal {
width: min(640px, calc(100% - 32px));
border: 1px solid #777;
padding: 24px;
background: #fff;
color: #171717;
}
.modal::backdrop {
background: rgb(0 0 0 / 55%);
}
:focus-visible {
outline: 3px solid #555;
outline-offset: 3px;
}
[data-theme="dark"] {
background: #202020;
color: #fafafa;
padding: 16px;
}
[data-density="compact"] li {
padding: 8px;
margin: 8px 0;
}
img {
max-width: 100%;
height: auto;
}
app/layout.tsx
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
app/lib/data.ts
import type { Task } from "./types";
const tasks: Task[] = [
{
id: "task-1",
title: "App Router 공부",
description: "서버와 브라우저의 역할을 구분합니다.",
completed: false,
ownerId: "demo",
},
{
id: "task-2",
title: "TaskFlow 만들기",
description: "누적 실습을 완성합니다.",
completed: true,
ownerId: "demo",
},
];
export async function getTasks() {
return tasks;
}
export async function getTask(id: string) {
return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
const ts = await getTasks();
return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}
app/lib/types.ts
export type Task = {
id: string;
title: string;
description: string;
completed: boolean;
ownerId: string;
};
app/ui/modal.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDialogElement>(null);
const router = useRouter();
useEffect(() => {
const dialog = ref.current;
if (dialog && !dialog.open) dialog.showModal();
return () => {
dialog?.close();
};
}, []);
return (
<dialog
className="modal"
ref={ref}
aria-label="작업 상세"
onCancel={(event) => {
event.preventDefault();
router.back();
}}
>
<button type="button" onClick={() => router.back()}>
닫기
</button>
{children}
</dialog>
);
}
app/ui/task-search.tsx
"use client";
import { useState } from "react";
export default function TaskSearch() {
const [keyword, setKeyword] = useState("");
return (
<label>
작업 검색
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="검색어 입력"
/>
</label>
);
}
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;
package.json
{
"name": "taskflow-next",
"version": "1.0.9",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
글 소개·학습 준비·전체 목차

Next.js · 9/38 · TaskFlow 누적 실습
Next.js 동적 라우트 slug params catch-all
Next.js · 9 / 38 · 필수 학습 · 동적 라우트
[id]·[slug]와 catch-all은 어떤 값을 받으며 query string과 어떻게 다를까요? 단일 동적 segment는 문자열, […slug]는 경로 조각의 배열을 받고 [[…slug]]는 그 조각이 없는 기본 경로도 허용합니다. params를 기다린 뒤 읽으며 ?id=처럼 붙은 검색 매개변수는 별도 searchParams로 다룹니다.
이번 글에서 배우는 내용
- [id], [slug]
- params
- catch-all […slug]
- optional catch-all [[…slug]]
- query string과 동적 segment 차이
목차
다음 학습
다음 글에서는 최신 App Router에서 params를 동기 객체처럼 읽을 때 발생하는 params should be awaited 오류를 해결합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 39개 · 필수 38개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.