라우팅 시스템 — app/ 디렉토리, 동적 라우트, 라우트 그룹, 병렬/인터셉트 라우트
App Router란?
Next.js 13부터 도입된 App Router는 app/ 디렉토리를 기반으로 하는 새로운 라우팅 시스템입니다. 기존 pages/ 디렉토리 방식(Pages Router)과 비교해 다음 특징을 갖습니다.
| 구분 | Pages Router | App Router |
|---|---|---|
| 기본 렌더링 | 클라이언트 컴포넌트 | 서버 컴포넌트 |
| 레이아웃 | _app.js, _document.js | layout.tsx 중첩 |
| 데이터 페칭 | getServerSideProps, getStaticProps | async 컴포넌트, fetch |
| 로딩 UI | 직접 구현 | loading.tsx 파일 규약 |
| 에러 처리 | 직접 구현 | error.tsx 파일 규약 |
Next.js 15(2024)에서 App Router가 안정화되었으며, 신규 프로젝트에는 App Router 사용을 권장합니다.
app/ 디렉토리 구조
app/ 폴더 안의 파일 이름이 특별한 의미를 갖습니다.
app/
├── layout.tsx # 루트 레이아웃 (필수)
├── page.tsx # / 라우트
├── globals.css
├── about/
│ └── page.tsx # /about 라우트
├── blog/
│ ├── layout.tsx # /blog 하위 공통 레이아웃
│ ├── page.tsx # /blog 라우트
│ └── [slug]/
│ └── page.tsx # /blog/:slug 동적 라우트
└── dashboard/
├── layout.tsx
├── page.tsx
├── loading.tsx # 로딩 UI
└── error.tsx # 에러 UI
파일 규약 요약
| 파일 | 역할 |
|---|---|
page.tsx | 해당 경로의 실제 UI |
layout.tsx | 하위 페이지 공유 레이아웃 (언마운트 안 됨) |
loading.tsx | Suspense 기반 로딩 UI |
error.tsx | Error Boundary 기반 에러 UI |
not-found.tsx | 404 UI |
route.ts | API 엔드포인트 (Route Handler) |
template.tsx | 매 네비게이션마다 새 인스턴스 생성 |
기본 라우팅
루트 레이아웃 — app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'My App',
description: 'Next.js 15 App Router 예제',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body className={inter.className}>{children}</body>
</html>
);
}
페이지 컴포넌트 — app/page.tsx
// app/page.tsx → /
export default function HomePage() {
return (
<main>
<h1>홈 페이지</h1>
<p>Next.js 15 App Router에 오신 것을 환영합니다.</p>
</main>
);
}
중첩 레이아웃
// app/dashboard/layout.tsx
import Sidebar from '@/components/Sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">{children}</main>
</div>
);
}
// app/dashboard/page.tsx → /dashboard
export default function DashboardPage() {
return <h1>대시보드</h1>;
}
동적 라우트
[param] — 단일 세그먼트
// app/blog/[slug]/page.tsx → /blog/hello-world
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params; // Next.js 15에서 params는 Promise
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 }, // 1시간마다 재검증
});
if (!res.ok) throw new Error('포스트를 불러올 수 없습니다');
return res.json();
}
[...slug] — 캐치올 세그먼트
// app/docs/[...slug]/page.tsx
// /docs/intro, /docs/guide/setup, /docs/guide/advanced/tips 모두 매칭
interface PageProps {
params: Promise<{ slug: string[] }>;
}
export default async function DocsPage({ params }: PageProps) {
const { slug } = await params;
// slug = ['guide', 'setup'] for /docs/guide/setup
return (
<div>
<p>경로: {slug.join(' / ')}</p>
</div>
);
}
[[...slug]] — 선택적 캐치올
// app/shop/[[...categories]]/page.tsx
// /shop (categories = undefined)
// /shop/electronics (categories = ['electronics'])
// /shop/electronics/phones (categories = ['electronics', 'phones'])
interface PageProps {
params: Promise<{ categories?: string[] }>;
}
export default async function ShopPage({ params }: PageProps) {
const { categories } = await params;
if (!categories) {
return <h1>전체 상품</h1>;
}
return <h1>카테고리: {categories.join(' > ')}</h1>;
}
generateStaticParams — 정적 생성
동적 라우트를 빌드 시 정적으로 생성합니다.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.title}</article>;
}
라우트 그룹
폴더명을 (parentheses)로 감싸면 URL 경로에 영향 없이 파일을 그룹화할 수 있습니다.
레이아웃 분리
app/
├── (marketing)/
│ ├── layout.tsx # 마케팅 전용 레이아웃
│ ├── page.tsx # /
│ ├── about/
│ │ └── page.tsx # /about
│ └── pricing/
│ └── page.tsx # /pricing
├── (app)/
│ ├── layout.tsx # 앱 전용 레이아웃 (사이드바 포함)
│ ├── dashboard/
│ │ └── page.tsx # /dashboard
│ └── settings/
│ └── page.tsx # /settings
└── layout.tsx # 루트 레이아웃 (공통)
// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<header>마케팅 헤더 (로그인/회원가입 버튼)</header>
{children}
<footer>마케팅 푸터</footer>
</div>
);
}
// app/(app)/layout.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) redirect('/login');
return (
<div className="flex">
<nav>앱 사이드바</nav>
<main>{children}</main>
</div>
);
}
여러 루트 레이아웃
라우트 그룹마다 독립적인 루트 레이아웃(<html>, <body> 포함)을 가질 수 있습니다.
app/
├── (shop)/
│ ├── layout.tsx # 쇼핑몰 전용 html/body
│ └── page.tsx
└── (blog)/
├── layout.tsx # 블로그 전용 html/body
└── page.tsx
병렬 라우트 (Parallel Routes)
@folder 슬롯을 사용해 같은 레이아웃에서 여러 페이지를 동시에 렌더링합니다.
기본 구조
app/
└── dashboard/
├── layout.tsx # @analytics, @team 슬롯 수신
├── page.tsx
├── @analytics/
│ ├── page.tsx
│ └── default.tsx # 슬롯 기본 UI
└── @team/
├── page.tsx
└── default.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">{children}</div>
<aside>
{analytics}
{team}
</aside>
</div>
);
}
// app/dashboard/@analytics/page.tsx
export default async function AnalyticsPanel() {
const data = await fetchAnalytics();
return (
<div className="p-4 border rounded">
<h2>분석</h2>
<p>방문자: {data.visitors}</p>
</div>
);
}
// app/dashboard/@analytics/default.tsx
// 병렬 라우트에서 매칭되는 서브페이지가 없을 때 표시
export default function AnalyticsDefault() {
return <div>분석 데이터 없음</div>;
}
탭 UI 구현 예
병렬 라우트로 조건부 모달이나 탭을 구현할 수 있습니다.
// app/dashboard/layout.tsx
import Link from 'next/link';
export default function DashboardLayout({
children,
overview,
metrics,
}: {
children: React.ReactNode;
overview: React.ReactNode;
metrics: React.ReactNode;
}) {
return (
<div>
<nav className="flex gap-4 mb-4">
<Link href="/dashboard">개요</Link>
<Link href="/dashboard/metrics">지표</Link>
</nav>
{overview}
{metrics}
{children}
</div>
);
}
인터셉트 라우트 (Intercepting Routes)
현재 컨텍스트를 유지하면서 다른 라우트의 콘텐츠를 모달로 표시합니다. Instagram의 사진 확대 패턴 구현에 사용됩니다.
컨벤션
| 문법 | 의미 |
|---|---|
(.)folder | 같은 레벨 인터셉트 |
(..)folder | 한 레벨 위 인터셉트 |
(..)(..)folder | 두 레벨 위 인터셉트 |
(...)folder | 루트 레벨에서 인터셉트 |
갤러리 + 모달 예제
app/
├── layout.tsx
├── page.tsx # 갤러리 목록 /
├── photos/
│ └── [id]/
│ └── page.tsx # 전체 화면 사진 /photos/1
└── @modal/
├── default.tsx # null 반환 (기본)
└── (.)photos/
└── [id]/
└── page.tsx # 모달로 표시
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
{children}
{modal}
</body>
</html>
);
}
// app/page.tsx — 갤러리 목록
import Link from 'next/link';
const photos = [
{ id: 1, src: '/photo1.jpg', title: '사진 1' },
{ id: 2, src: '/photo2.jpg', title: '사진 2' },
{ id: 3, src: '/photo3.jpg', title: '사진 3' },
];
export default function GalleryPage() {
return (
<div className="grid grid-cols-3 gap-4 p-8">
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img src={photo.src} alt={photo.title} className="w-full rounded" />
</Link>
))}
</div>
);
}
// app/photos/[id]/page.tsx — 전체 화면 (직접 URL 접근 시)
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PhotoPage({ params }: PageProps) {
const { id } = await params;
return (
<div className="flex items-center justify-center min-h-screen bg-black">
<img src={`/photo${id}.jpg`} alt={`사진 ${id}`} className="max-h-screen" />
</div>
);
}
// app/@modal/(.)photos/[id]/page.tsx — 모달로 인터셉트
'use client';
import { useRouter } from 'next/navigation';
import Modal from '@/components/Modal';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PhotoModal({ params }: PageProps) {
const { id } = await params;
return (
<Modal>
<img src={`/photo${id}.jpg`} alt={`사진 ${id}`} className="max-w-2xl" />
</Modal>
);
}
// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect } from 'react';
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const handleClose = useCallback(() => {
router.back();
}, [router]);
// ESC 키로 닫기
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [handleClose]);
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onClick={handleClose}
>
<div onClick={(e) => e.stopPropagation()}>{children}</div>
</div>
);
}
// app/@modal/default.tsx — 인터셉트 없을 때 null
export default function ModalDefault() {
return null;
}
loading.tsx와 error.tsx
loading.tsx — Suspense 기반 로딩 UI
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
);
}
error.tsx — 에러 바운더리
// app/dashboard/error.tsx
'use client'; // 에러 컴포넌트는 반드시 클라이언트 컴포넌트
import { useEffect } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="text-center p-8">
<h2 className="text-xl font-bold text-red-600">오류가 발생했습니다</h2>
<p className="text-gray-600 mt-2">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
다시 시도
</button>
</div>
);
}
Route Handler (API)
route.ts 파일로 API 엔드포인트를 만듭니다.
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
// GET /api/users
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = searchParams.get('page') ?? '1';
const users = await fetchUsers({ page: parseInt(page) });
return NextResponse.json(users);
}
// POST /api/users
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name || !body.email) {
return NextResponse.json(
{ error: '이름과 이메일은 필수입니다' },
{ status: 400 }
);
}
const user = await createUser(body);
return NextResponse.json(user, { status: 201 });
}
// app/api/users/[id]/route.ts
interface RouteContext {
params: Promise<{ id: string }>;
}
// GET /api/users/:id
export async function GET(request: NextRequest, { params }: RouteContext) {
const { id } = await params;
const user = await findUser(id);
if (!user) {
return NextResponse.json({ error: '사용자를 찾을 수 없습니다' }, { status: 404 });
}
return NextResponse.json(user);
}
// DELETE /api/users/:id
export async function DELETE(request: NextRequest, { params }: RouteContext) {
const { id } = await params;
await deleteUser(id);
return new NextResponse(null, { status: 204 });
}
실전 예제 — 블로그 플랫폼 라우팅
app/
├── layout.tsx
├── page.tsx # / 메인
├── (auth)/
│ ├── login/
│ │ └── page.tsx # /login
│ └── register/
│ └── page.tsx # /register
├── (main)/
│ ├── layout.tsx # 헤더/푸터 포함
│ ├── blog/
│ │ ├── page.tsx # /blog 목록
│ │ ├── [slug]/
│ │ │ ├── page.tsx # /blog/:slug
│ │ │ └── not-found.tsx
│ │ └── category/
│ │ └── [category]/
│ │ └── page.tsx # /blog/category/:category
│ └── @modal/
│ ├── default.tsx
│ └── (.)blog/
│ └── [slug]/
│ └── page.tsx # 미리보기 모달
└── api/
├── posts/
│ └── route.ts
└── posts/
└── [slug]/
└── route.ts
// app/(main)/blog/page.tsx
import Link from 'next/link';
import { Suspense } from 'react';
async function PostList() {
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
}).then((r) => r.json());
return (
<ul className="space-y-6">
{posts.map((post: any) => (
<li key={post.slug} className="border-b pb-6">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-bold hover:underline">{post.title}</h2>
</Link>
<p className="text-gray-600 mt-1">{post.excerpt}</p>
<time className="text-sm text-gray-400">{post.publishedAt}</time>
</li>
))}
</ul>
);
}
export default function BlogPage() {
return (
<div className="max-w-2xl mx-auto py-12 px-4">
<h1 className="text-3xl font-bold mb-8">블로그</h1>
<Suspense fallback={<p>포스트 불러오는 중...</p>}>
<PostList />
</Suspense>
</div>
);
}
고수 팁
1. Next.js 15의 params — 항상 await
// Next.js 15에서 params와 searchParams는 Promise
// ❌ 이전 방식
export default function Page({ params }: { params: { id: string } }) {
const { id } = params; // 타입 오류 발생 가능
}
// ✅ Next.js 15 방식
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
}
2. 라우트 그룹으로 미들웨어 범위 제한
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// (app) 그룹 경로에만 인증 적용
if (pathname.startsWith('/dashboard') || pathname.startsWith('/settings')) {
const token = request.cookies.get('token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
3. 병렬 데이터 페칭
// 여러 fetch를 병렬로 실행 — 순차 실행 피하기
async function DashboardPage() {
// ❌ 순차 실행 (느림)
// const user = await fetchUser();
// const posts = await fetchPosts();
// ✅ 병렬 실행 (빠름)
const [user, posts, analytics] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchAnalytics(),
]);
return (
<div>
<h1>안녕하세요, {user.name}님</h1>
<p>포스트: {posts.length}개</p>
<p>방문자: {analytics.visitors}명</p>
</div>
);
}
4. notFound()와 not-found.tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound(); // app/blog/[slug]/not-found.tsx 렌더링
}
return <article>{post.title}</article>;
}
// app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function PostNotFound() {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold">포스트를 찾을 수 없습니다</h2>
<Link href="/blog" className="text-blue-500 mt-4 inline-block">
블로그 목록으로 돌아가기
</Link>
</div>
);
}
5. 타입 안전한 라우팅 — next-safe-navigation
npm install next-safe-navigation
import { createNavigationConfig } from 'next-safe-navigation';
import { z } from 'zod';
// 라우트 타입 정의
export const { routes, useSafeRouter, Link } = createNavigationConfig((defineRoute) => ({
home: defineRoute('/'),
blog: defineRoute('/blog'),
blogPost: defineRoute('/blog/[slug]', {
params: z.object({ slug: z.string() }),
}),
shop: defineRoute('/shop/[[...categories]]', {
params: z.object({ categories: z.array(z.string()).optional() }),
}),
}));
// 사용
import { routes } from '@/lib/navigation';
// ✅ 타입 안전 — slug 누락 시 컴파일 에러
<Link route={routes.blogPost({ slug: 'hello-world' })}>
블로그 포스트
</Link>