본문으로 건너뛰기
Advertisement

라우팅 시스템 — app/ 디렉토리, 동적 라우트, 라우트 그룹, 병렬/인터셉트 라우트

App Router란?

Next.js 13부터 도입된 App Routerapp/ 디렉토리를 기반으로 하는 새로운 라우팅 시스템입니다. 기존 pages/ 디렉토리 방식(Pages Router)과 비교해 다음 특징을 갖습니다.

구분Pages RouterApp Router
기본 렌더링클라이언트 컴포넌트서버 컴포넌트
레이아웃_app.js, _document.jslayout.tsx 중첩
데이터 페칭getServerSideProps, getStaticPropsasync 컴포넌트, 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.tsxSuspense 기반 로딩 UI
error.tsxError Boundary 기반 에러 UI
not-found.tsx404 UI
route.tsAPI 엔드포인트 (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>
Advertisement