[이제와서 시작하는 Next.js 마스터하기 #7] 동적 라우팅과 고급 패턴
“URL은 단순하지만 화면은 복잡할 수 있습니다.” - App Router의 고급 패턴으로 실제 서비스 구조를 설계해봅니다.
🎯 이 글에서 배울 내용
- Parallel Routes (병렬 라우트)
- Intercepting Routes (가로채기 라우트)
- Route Groups (라우트 그룹)
- Dynamic Routes와 generateStaticParams
- Route Handlers (API Routes)
예상 소요 시간: 45분
🗂️ Route Groups - URL을 바꾸지 않는 폴더 정리
App Router에서 폴더는 기본적으로 URL이 됩니다. 하지만 괄호로 감싼 폴더는 URL에 포함되지 않습니다.
1
2
3
4
5
6
7
8
9
app/
├── (marketing)/
│ ├── layout.tsx
│ └── page.tsx // /
├── (dashboard)/
│ ├── layout.tsx
│ └── dashboard/
│ └── page.tsx // /dashboard
└── layout.tsx
(marketing)과 (dashboard)는 코드 정리를 위한 그룹일 뿐, 실제 URL에는 나타나지 않습니다. 그래서 팀, 기능, 레이아웃 기준으로 폴더를 나눌 수 있습니다.
언제 쓰면 좋을까?
| 상황 | 예시 |
|---|---|
| 공개 페이지와 앱 페이지의 레이아웃이 다름 | (marketing), (app) |
| 관리자/사용자 영역을 분리하고 싶음 | (admin), (account) |
| 팀별 소유권을 폴더에 드러내고 싶음 | (growth), (commerce) |
| 같은 URL 깊이에서 일부 페이지만 다른 layout 필요 | (shop)/cart, checkout |
주의할 점도 있습니다.
- 서로 다른 그룹이 같은 URL을 만들면 충돌합니다.
(marketing)/about/page.tsx와(shop)/about/page.tsx는 둘 다/about입니다. - 여러 root layout을 쓰면 그룹 간 이동에서 전체 페이지 로드가 일어날 수 있습니다.
- top-level
layout.tsx없이 여러 root layout만 둘 경우/페이지가 어느 그룹에 속하는지 분명해야 합니다.
Route Groups는 기능이 화려한 도구라기보다, URL 설계와 코드 소유권을 분리하는 장치라고 이해하면 쉽습니다.
🔀 Parallel Routes - 동시에 여러 페이지 표시
1. 기본 개념
1
2
3
4
5
6
7
app/
├── layout.js
├── page.js
├── @sidebar/
│ └── page.js
└── @main/
└── page.js
1
2
3
4
5
6
7
8
9
10
11
12
// app/layout.js
export default function Layout({ children, sidebar, main }) {
return (
<div className="grid grid-cols-12">
<aside className="col-span-3">{sidebar}</aside>
<main className="col-span-9">
{main}
{children}
</main>
</div>
);
}
@sidebar, @main 같은 폴더는 URL segment가 아니라 slot입니다. 부모 layout은 slot 이름과 같은 props를 받아 화면에 배치합니다.
2. 실전 예제: 대시보드
1
2
3
4
5
6
7
8
app/dashboard/
├── layout.js
├── @analytics/
│ └── page.js // 분석 패널
├── @revenue/
│ └── page.js // 매출 패널
└── @users/
└── page.js // 사용자 패널
1
2
3
4
5
6
7
8
9
10
// app/dashboard/layout.js
export default function DashboardLayout({ analytics, revenue, users }) {
return (
<div className="grid grid-cols-3 gap-4">
<div>{analytics}</div>
<div>{revenue}</div>
<div>{users}</div>
</div>
);
}
각 slot은 독립적인 loading.tsx, error.tsx를 가질 수 있습니다.
1
2
3
4
5
6
7
8
app/dashboard/
├── layout.tsx
├── @analytics/
│ ├── loading.tsx
│ └── page.tsx
└── @users/
├── error.tsx
└── page.tsx
분석 패널이 느려도 사용자 패널을 먼저 보여줄 수 있고, 특정 slot에서 에러가 나도 전체 대시보드가 한 번에 무너지지 않게 설계할 수 있습니다.
3. default.tsx가 필요한 순간
Parallel Routes를 쓰면 새로고침이나 직접 URL 접근에서 특정 slot의 현재 상태를 복원할 수 없는 경우가 있습니다. 이때 default.tsx가 fallback 역할을 합니다.
1
2
3
4
// app/dashboard/@analytics/default.tsx
export default function DefaultAnalytics() {
return <p>분석 패널을 선택해주세요.</p>;
}
default.tsx를 두지 않으면 복원할 수 없는 slot에서 404가 날 수 있습니다. 대시보드, inbox, split-view처럼 slot이 많은 화면에서는 처음부터 fallback UI를 정해두세요.
🎭 Intercepting Routes - 모달 패턴
1. 사진 갤러리 예제
1
2
3
4
5
6
7
8
9
app/
├── photos/
│ ├── page.js // /photos
│ └── [id]/
│ └── page.js // /photos/1
└── @modal/
└── (..)photos/
└── [id]/
└── page.js // 모달로 표시
Intercepting Routes의 핵심은 같은 URL도 진입 방식에 따라 다르게 보여줄 수 있다는 점입니다.
| 진입 방식 | 결과 |
|---|---|
/photos에서 사진 클릭 | 기존 갤러리 위에 모달로 표시 |
/photos/1 URL 직접 입력 | 사진 상세 페이지 전체 표시 |
| 모달에서 새로고침 | 공유 가능한 상세 페이지로 동작 |
| 뒤로가기 | 모달을 닫고 갤러리로 복귀 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/photos/page.js
export default function PhotosPage() {
const photos = [1, 2, 3, 4];
return (
<div className="grid grid-cols-4 gap-4">
{photos.map(id => (
<Link key={id} href={`/photos/${id}`}>
<img src={`/photo-${id}.jpg`} alt={`Photo ${id}`} />
</Link>
))}
</div>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
// app/@modal/(..)photos/[id]/page.js
import Modal from '@/components/Modal';
export default async function PhotoModal({ params }) {
const { id } = await params;
return (
<Modal>
<img src={`/photo-${id}.jpg`} alt={`Photo ${id}`} />
</Modal>
);
}
2. Modal 컴포넌트 구현
위 예제에서 사용한 Modal 컴포넌트를 직접 만들어보겠습니다!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// components/Modal.js
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Modal({ children }) {
const router = useRouter();
const dialogRef = useRef(null);
useEffect(() => {
// 모달을 자동으로 열기
if (dialogRef.current) {
dialogRef.current.showModal();
}
}, []);
function onDismiss() {
router.back(); // 이전 페이지로 돌아가기
}
function onClickOutside(e) {
// 모달 외부 클릭 시 닫기
if (e.target === dialogRef.current) {
onDismiss();
}
}
return (
<dialog
ref={dialogRef}
onClose={onDismiss}
onClick={onClickOutside}
className="backdrop:bg-black backdrop:opacity-50 p-0 rounded-lg shadow-2xl"
>
<div className="relative">
{/* 닫기 버튼 */}
<button
onClick={onDismiss}
className="absolute top-4 right-4 text-white bg-black/50 rounded-full w-8 h-8 flex items-center justify-center hover:bg-black/70"
>
✕
</button>
{/* 모달 내용 */}
<div className="p-6">
{children}
</div>
</div>
</dialog>
);
}
코드 설명 (초보자용):
<dialog>태그: HTML5의 네이티브 모달 요소showModal(): 모달을 화면 중앙에 표시backdrop: 모달 뒤의 어두운 배경
useRouter(): Next.js 라우터 사용router.back(): 이전 페이지로 돌아가기- 모달을 닫으면 갤러리로 돌아감
- 외부 클릭 감지:
onClick={onClickOutside}: 모달 외부 클릭 시 닫기e.target === dialogRef.current: 배경 클릭인지 확인
💡 Tip: 이 Modal 컴포넌트는 다른 곳에서도 재사용 가능합니다!
1
2
3
4
5
// 다른 곳에서 사용
<Modal>
<h2>알림</h2>
<p>저장되었습니다!</p>
</Modal>
Intercepting Routes는 Parallel Routes와 함께 쓸 때 가장 자연스럽습니다. @modal slot에 모달을 띄우고, 직접 접근할 때는 일반 상세 페이지를 렌더링하는 구조가 대표적입니다.
폴더 이름의 의미도 짚고 넘어갑시다.
| 표기 | 의미 |
|---|---|
(.)segment | 같은 segment 레벨에서 가로채기 |
(..)segment | 한 segment 위에서 가로채기 |
(..)(..)segment | 두 segment 위에서 가로채기 |
(...)segment | app 루트 기준으로 가로채기 |
이 표기는 파일 시스템의 폴더 깊이가 아니라 route segment 기준입니다. @modal은 slot이고 URL segment가 아니므로, 실제 폴더 깊이와 계산이 다르게 느껴질 수 있습니다.
🧭 Dynamic Routes와 generateStaticParams
동적 라우트는 대괄호 폴더로 만듭니다.
1
2
3
4
app/blog/[slug]/page.tsx // /blog/hello-nextjs
app/shop/[category]/[id]/page.tsx
app/docs/[...segments]/page.tsx
app/search/[[...query]]/page.tsx
자주 쓰는 패턴은 세 가지입니다.
| 패턴 | 예시 | 의미 |
|---|---|---|
[slug] | /blog/react | 필수 한 segment |
[...slug] | /docs/a/b/c | 필수 catch-all |
[[...slug]] | /docs, /docs/a/b | optional catch-all |
정적 생성할 값이 정해져 있다면 generateStaticParams를 사용합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPostPage({ params }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return <article>{post.title}</article>;
}
Next.js 16의 App Router 예시에서는 params를 await하는 형태가 자연스럽습니다. 동적 페이지에서 없는 데이터를 만났다면 빈 화면을 반환하지 말고 notFound()로 명시하세요.
generateStaticParams를 쓸 때는 다음을 결정해야 합니다.
- 모든 slug를 빌드 시점에 만들 것인가?
- 일부 인기 페이지만 만들고 나머지는 요청 시 처리할 것인가?
- 새 글이 추가될 때 재배포가 필요한가, 아니면 revalidation 전략이 있는가?
- 존재하지 않는 동적 segment에 대해 404를 낼 것인가?
콘텐츠 수가 적은 블로그라면 전체 slug를 반환해도 충분합니다. 상품 수가 많은 쇼핑몰이라면 인기 상품만 미리 만들고 나머지는 캐싱/재검증 전략과 함께 설계하는 편이 현실적입니다.
🛣️ Route Handlers - API 엔드포인트
1. GET 요청
1
2
3
4
5
6
7
8
9
10
11
12
// app/api/posts/route.js
export async function GET(request) {
// 실제로는 데이터베이스에서 가져옵니다
// 예: const posts = await prisma.post.findMany();
// 여기서는 외부 API로 시뮬레이션
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts = await response.json();
// 최근 10개만 반환
return Response.json(posts.slice(0, 10));
}
사용 예시:
1
2
3
4
// 클라이언트에서 호출
const response = await fetch('/api/posts');
const posts = await response.json();
console.log(posts); // 게시물 목록 출력
2. POST 요청
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// app/api/posts/route.js
export async function POST(request) {
const data = await request.json();
// 유효성 검사
if (!data.title || !data.content) {
return Response.json(
{ error: '제목과 내용은 필수입니다' },
{ status: 400 }
);
}
// 실제로는 데이터베이스에 저장
// 예: const post = await prisma.post.create({ data: { title: data.title, content: data.content } });
// 시뮬레이션: 새 게시물 객체 생성
const newPost = {
id: Date.now(),
title: data.title,
content: data.content,
createdAt: new Date().toISOString()
};
console.log('📝 새 게시물 생성:', newPost);
return Response.json(newPost, { status: 201 });
}
사용 예시:
1
2
3
4
5
6
7
8
9
10
11
12
// 클라이언트에서 호출
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '새 게시물',
content: '내용입니다'
})
});
const newPost = await response.json();
console.log('생성됨:', newPost);
3. 동적 라우트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// app/api/posts/[id]/route.js
export async function GET(request, { params }) {
const { id } = await params;
// 실제로는 데이터베이스에서 조회
// 예: const post = await prisma.post.findUnique({ where: { id: parseInt(id) } });
// 외부 API로 시뮬레이션
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!response.ok) {
return Response.json(
{ error: 'Post not found' },
{ status: 404 }
);
}
const post = await response.json();
return Response.json(post);
}
export async function DELETE(request, { params }) {
const { id } = await params;
// 실제로는 데이터베이스에서 삭제
// 예: await prisma.post.delete({ where: { id: parseInt(id) } });
console.log(`🗑️ 게시물 ${id} 삭제`);
return Response.json({ success: true, message: `Post ${id} deleted` });
}
사용 예시:
1
2
3
4
5
6
7
8
9
10
11
// GET: 특정 게시물 조회
const response = await fetch('/api/posts/1');
const post = await response.json();
console.log(post);
// DELETE: 게시물 삭제
const deleteResponse = await fetch('/api/posts/1', {
method: 'DELETE'
});
const result = await deleteResponse.json();
console.log(result); // { success: true, message: 'Post 1 deleted' }
🔍 자주 묻는 질문 (FAQ)
Q1: Parallel Routes는 언제 사용하나요?
사용 케이스:
- 대시보드 (여러 패널 동시 표시)
- 소셜 미디어 (피드 + 사이드바)
- 이커머스 (상품 목록 + 필터)
장점:
- 각 섹션 독립적으로 로딩
- 에러 격리
- 병렬 데이터 페칭
Q2: Intercepting Routes는 언제 쓰나요?
사용 케이스:
- 갤러리 모달
- 로그인/회원가입 모달
- 빠른 미리보기
장점:
- 부드러운 UX
- URL 변경은 되지만 전체 페이지는 안 바뀜
- 뒤로가기 지원
Q3: Route Groups와 URL 폴더는 어떻게 구분하나요?
괄호가 없는 폴더는 URL이 됩니다. 괄호가 있는 폴더는 URL에 들어가지 않는 정리용 그룹입니다.
1
2
app/blog/page.tsx // /blog
app/(marketing)/about/page.tsx // /about
그룹 이름은 사용자에게 보이지 않지만, 같은 URL을 만드는 그룹이 두 개 있으면 충돌합니다. URL 설계는 한 번만 하고, 그룹은 레이아웃과 팀 소유권을 나누는 용도로 쓰세요.
Q4: Parallel Routes와 일반 컴포넌트 조합은 무엇이 다른가요?
일반 컴포넌트 조합은 한 페이지 안에서 직접 import해서 배치합니다. Parallel Routes는 slot마다 독립적인 route tree, loading UI, error boundary를 가질 수 있습니다.
작은 카드 몇 개라면 컴포넌트 조합이 충분합니다. 대시보드, inbox, analytics처럼 각 영역의 로딩과 URL 상태를 따로 관리해야 한다면 Parallel Routes를 검토하세요.
🎯 오늘 배운 내용 정리
- Route Groups
- 괄호 폴더는 URL에 포함되지 않음
- 레이아웃과 팀 소유권 분리에 유용
- 같은 URL을 만드는 그룹 충돌 주의
- Parallel Routes
- @folder 문법
- 여러 페이지 동시 표시
default.tsx로 slot fallback 제공
- Intercepting Routes
- (.)folder 문법
- 모달 패턴
- 소프트 네비게이션과 직접 접근의 화면 차이
- Dynamic Routes
[slug],[...slug],[[...slug]]generateStaticParams와notFound()- 정적 생성 범위와 재검증 전략 결정
- Route Handlers
- RESTful API 생성
- GET/POST/DELETE 등
- 동적 라우트 지원
📚 시리즈 네비게이션
“고급 라우팅은 화려한 문법보다, URL과 화면 상태를 분리해서 생각하는 힘입니다.” 🎨
