Next.js 13에서 도입되어 14, 15에서 안정화된 App Router는 기존 Pages Router와는 근본적으로 다른 패러다임을 가져왔습니다. React Server Components를 기반으로 서버와 클라이언트의 경계를 명확히 나누고, 중첩 레이아웃과 스트리밍으로 사용자 경험을 한 단계 끌어올립니다. 이 글에서는 App Router의 핵심 개념부터 실전 활용법까지 Pages Router와 비교하며 살펴봅니다.
App Router vs Pages Router — 무엇이 다를까
Pages Router에서는 pages/ 디렉토리 아래 파일을 만들면 그게 곧 라우트였습니다. App Router는 app/ 디렉토리를 사용하며 훨씬 강력한 파일 컨벤션을 제공합니다.
| 특성 | Pages Router | App Router |
|---|---|---|
| 기본 렌더링 | 클라이언트 컴포넌트 | 서버 컴포넌트 |
| 레이아웃 | _app.js / _document.js | layout.js (중첩 가능) |
| 데이터 패칭 | getServerSideProps / getStaticProps | 컴포넌트 내 async/await |
| 스트리밍 | 미지원 | Suspense + loading.js |
파일 컨벤션 이해하기
App Router는 특별한 파일명으로 역할을 부여합니다. 자주 쓰는 것들을 정리하면 다음과 같습니다.
app/
├── layout.js # 공유 레이아웃 (필수)
├── page.js # 라우트 UI
├── loading.js # 로딩 UI (Suspense 자동)
├── error.js # 에러 UI (Error Boundary 자동)
├── not-found.js # 404 UI
├── route.js # API 엔드포인트
└── blog/
├── layout.js # blog/ 전용 레이아웃
├── page.js # /blog 페이지
└── [slug]/
└── page.js # /blog/:slug 페이지
React Server Components — 기본이 서버
App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다. 서버에서만 실행되므로 DB에 직접 접근하거나 API 키를 안전하게 사용할 수 있고, 번들 크기에도 영향을 미치지 않습니다.
// app/posts/page.tsx — 서버 컴포넌트 (기본값)
// 'use client' 없음 → 서버에서만 실행
async function PostsPage() {
// DB 직접 접근 가능, API 키 노출 없음
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
return (
<main>
<h1>블로그</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</main>
);
}
export default PostsPage;
서버 컴포넌트에서는
useState, useEffect, 이벤트 핸들러를 사용할 수 없습니다. 상호작용이 필요한 부분만 'use client' 지시어로 클라이언트 컴포넌트로 분리하세요.
'use client' — 클라이언트 컴포넌트
상태, 이벤트, 브라우저 API가 필요한 컴포넌트는 파일 최상단에 'use client'를 선언합니다. 이 컴포넌트와 그 자식들은 클라이언트 번들에 포함됩니다.
'use client';
// app/components/LikeButton.tsx
import { useState } from 'react';
export function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
const [liked, setLiked] = useState(false);
const handleLike = async () => {
setLiked(true);
setCount(prev => prev + 1);
await fetch('/api/like', { method: 'POST' });
};
return (
<button onClick={handleLike} aria-pressed={liked}>
{liked ? '❤️' : '🤍'} {count}
</button>
);
}
중첩 레이아웃
App Router의 가장 강력한 기능 중 하나는 중첩 레이아웃입니다. 각 디렉토리에 layout.js를 두면 해당 경로와 하위 경로에 자동으로 적용됩니다. 페이지 이동 시 레이아웃은 리렌더링 없이 유지됩니다.
// app/layout.tsx — 루트 레이아웃
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<nav>사이트 전체 네비게이션</nav>
{children}
<footer>사이트 전체 푸터</footer>
</body>
</html>
);
}
// app/dashboard/layout.tsx — 대시보드 전용 레이아웃
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard">
<aside>사이드바 메뉴</aside>
<main>{children}</main>
</div>
);
}
데이터 패칭 — async/await 컴포넌트
Pages Router의 getServerSideProps는 사라졌습니다. 서버 컴포넌트는 그냥 async 함수로 선언하고 내부에서 await로 데이터를 가져옵니다.
// app/posts/[slug]/page.tsx
interface Props {
params: { slug: string };
}
async function PostPage({ params }: Props) {
// 동시 데이터 패칭 — Promise.all로 병렬 처리
const [post, comments] = await Promise.all([
fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json()),
fetch(`https://api.example.com/posts/${params.slug}/comments`).then(r => r.json()),
]);
if (!post) notFound(); // not-found.js 렌더링
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<CommentList comments={comments} />
</article>
);
}
// 정적 경로 생성 (SSG)
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}
export default PostPage;
스트리밍과 Suspense
느린 데이터 패칭이 있어도 페이지 전체를 기다리지 않고, 준비된 부분부터 먼저 보여줄 수 있습니다. loading.js는 해당 경로의 로딩 상태를 자동으로 처리합니다.
// app/dashboard/loading.tsx — 자동 Suspense fallback
export default function DashboardLoading() {
return (
<div className="skeleton">
<div className="skeleton-title" />
<div className="skeleton-body" />
</div>
);
}
// 컴포넌트 단위 스트리밍
import { Suspense } from 'react';
async function Dashboard() {
return (
<div>
<h1>대시보드</h1>
{/* 빠른 통계는 즉시 렌더링 */}
<QuickStats />
{/* 느린 차트는 별도로 스트리밍 */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
);
}
Route Handlers — API 엔드포인트
Pages Router의 pages/api/는 App Router에서 route.js로 대체되었습니다. HTTP 메서드별로 named export를 사용합니다.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = searchParams.get('page') ?? '1';
const posts = await db.post.findMany({
skip: (Number(page) - 1) * 10,
take: 10,
});
return NextResponse.json({ posts });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await db.post.create({ data: body });
return NextResponse.json(post, { status: 201 });
}
메타데이터 API
App Router는 SEO를 위한 메타데이터를 layout.js나 page.js에서 직접 내보낼 수 있습니다. 동적 메타데이터도 generateMetadata 함수로 처리합니다.
// 정적 메타데이터
export const metadata = {
title: 'My Blog',
description: '개발 블로그',
openGraph: {
title: 'My Blog',
description: '개발 블로그',
images: ['/og-image.png'],
},
};
// 동적 메타데이터
export async function generateMetadata({ params }: Props) {
const post = await getPost(params.slug);
return {
title: `${post.title} — My Blog`,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.coverImage],
},
};
}
신규 프로젝트라면 App Router를 선택하세요. 서버 컴포넌트, 스트리밍, 중첩 레이아웃의 이점이 명확합니다. 기존 Pages Router 프로젝트는 점진적 마이그레이션이 가능합니다 — 두 라우터는 같은 Next.js 앱에서 공존할 수 있습니다.