[Next.js] Pages Router vs App Router 비교 (+ Next.js v16)
작성자 : 오예환 | 작성일 : 2025-12-17 | 수정일 : 2025-12-17
1. 개요
Next.js 13에서 App Router가 도입되면서 라우팅 방식이 크게 변경되었습니다. 이 글에서는 Pages Router (Next.js 12) 와 App Router (Next.js 15) 의 차이점을 비교합니다.
| 구분 | Pages Router | App Router |
|---|---|---|
| 도입 버전 | Next.js 1.0~ | Next.js 13~ |
| 디렉토리 | /pages | /app |
| 기본 렌더링 | CSR/SSR 선택 | Server Components (기본) |
| 데이터 페칭 | getServerSideProps, getStaticProps | async/await in Server Components |
| 레이아웃 | _app.tsx, _document.tsx | layout.tsx, template.tsx |
| 메타데이터 | next/head | metadata export, generateMetadata |
2. 디렉토리 구조 비교
Pages Router (/pages)
/pages
├── _app.tsx # 전역 레이아웃, 상태 관리
├── _document.tsx # HTML 문서 구조
├── index.tsx # / 경로
├── about.tsx # /about 경로
├── posts
│ ├── index.tsx # /posts
│ └── [id].tsx # /posts/:id (동적 라우트)
└── api
└── hello.ts # API 라우트
App Router (/app)
/app
├── layout.tsx # 루트 레이아웃 (필수)
├── page.tsx # / 경로
├── about
│ └── page.tsx # /about 경로
├── posts
│ ├── page.tsx # /posts
│ └── [id]
│ └── page.tsx # /posts/:id (동적 라우트)
└── api
└── hello
└── route.ts # API 라우트
핵심 차이:
- Pages Router: 파일명 = 라우트 (
about.tsx→/about) - App Router: 폴더명 = 라우트,
page.tsx가 실제 페이지 (about/page.tsx→/about)
3. _app.tsx → layout.tsx 마이그레이션
Pages Router: _app.tsx
// pages/_app.tsx
import type { AppProps } from "next/app";
import { useState } from "react";
import "../styles/globals.css";
import Layout from "../components/Layout";
import { ThemeProvider } from "../context/ThemeContext";
export default function App({ Component, pageProps }: AppProps) {
const [user, setUser] = useState(null);
return (
<ThemeProvider>
<Layout>
<Component {...pageProps} user={user} />
</Layout>
</ThemeProvider>
);
}_app.tsx의 역할:
- 전역 CSS import
- 전역 레이아웃 적용
- Context Provider 설정
- 페이지 간 상태 유지
- 페이지 전환 시마다 실행
App Router: layout.tsx
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "./providers";
export const metadata: Metadata = {
title: "My App",
description: "My App Description",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<ThemeProvider>
<nav>공통 네비게이션</nav>
<main>{children}</main>
<footer>공통 푸터</footer>
</ThemeProvider>
</body>
</html>
);
}layout.tsx의 특징:
- Server Component가 기본 (클라이언트 상태 사용 시 별도 처리 필요)
<html>,<body>태그 직접 정의 (_document.tsx 역할 통합)- 중첩 레이아웃 지원
- 페이지 전환 시 리렌더링되지 않음 (상태 유지)
Provider 분리 (Client Component)
App Router에서 Context를 사용하려면 클라이언트 컴포넌트로 분리해야 합니다.
// app/providers.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ReactNode, useState } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system">
{children}
</NextThemesProvider>
);
}
// 전역 상태가 필요한 경우
export function AppProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
return <UserContext.Provider value={{ user, setUser }}>{children}</UserContext.Provider>;
}4. _document.tsx → layout.tsx 통합
Pages Router: _document.tsx
// pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="ko">
<Head>
{/* 전역 폰트, 메타 태그 */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR&display=swap"
rel="stylesheet"
/>
<meta name="theme-color" content="#000000" />
</Head>
<body>
<Main />
<NextScript />
{/* 포탈용 div */}
<div id="modal-root" />
</body>
</Html>
);
}_document.tsx의 역할:
- HTML 문서 구조 정의
<html>,<head>,<body>커스터마이징- 폰트, 외부 스크립트 로드
- 서버에서만 렌더링됨
App Router: layout.tsx에서 통합
// app/layout.tsx
import type { Metadata } from "next";
import { Noto_Sans_KR } from "next/font/google";
import "./globals.css";
// next/font로 폰트 최적화
const notoSansKR = Noto_Sans_KR({
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "My App",
template: "%s | My App", // 하위 페이지: "About | My App"
},
description: "My App Description",
themeColor: "#000000",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={notoSansKR.className}>
<body>
{children}
{/* 포탈용 div */}
<div id="modal-root" />
</body>
</html>
);
}변경점:
_document.tsx파일 불필요next/font로 폰트 자동 최적화metadataexport로 메타 태그 설정
5. 데이터 페칭 비교
Pages Router: getServerSideProps / getStaticProps
// pages/posts/[id].tsx
// SSR: 매 요청마다 서버에서 실행
export async function getServerSideProps(context) {
const { id } = context.params;
const res = await fetch(`https://api.example.com/posts/${id}`);
const post = await res.json();
return {
props: { post },
};
}
// SSG: 빌드 시 실행
export async function getStaticProps(context) {
const { id } = context.params;
const res = await fetch(`https://api.example.com/posts/${id}`);
const post = await res.json();
return {
props: { post },
revalidate: 60, // ISR: 60초마다 재생성
};
}
// 동적 경로 생성 (SSG)
export async function getStaticPaths() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return {
paths: posts.map(post => ({ params: { id: post.id } })),
fallback: "blocking",
};
}
export default function PostPage({ post }) {
return <div>{post.title}</div>;
}App Router: Server Components + fetch
// app/posts/[id]/page.tsx
// 기본: SSR (동적 렌더링)
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
return res.json();
}
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return <div>{post.title}</div>;
}
// SSG: generateStaticParams 사용
export async function generateStaticParams() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return posts.map(post => ({ id: post.id }));
}fetch 캐싱 옵션:
// SSR: 캐시 안 함 (매 요청마다 fetch)
fetch(url, { cache: "no-store" });
// SSG: 영구 캐시 (빌드 시 fetch, 기본값)
fetch(url, { cache: "force-cache" });
// ISR: 시간 기반 재검증
fetch(url, { next: { revalidate: 60 } });
// 태그 기반 재검증
fetch(url, { next: { tags: ["posts"] } });
// revalidateTag('posts')로 수동 재검증비교:
| 기능 | Pages Router | App Router |
|---|---|---|
| SSR | getServerSideProps | cache: 'no-store' |
| SSG | getStaticProps | cache: 'force-cache' (기본) |
| ISR | revalidate 옵션 | next: { revalidate: 초 } |
| 동적 경로 생성 | getStaticPaths | generateStaticParams |
6. 메타데이터 비교
Pages Router: next/head
// pages/about.tsx
import Head from "next/head";
export default function AboutPage() {
return (
<>
<Head>
<title>About | My App</title>
<meta name="description" content="About page description" />
<meta property="og:title" content="About" />
<meta property="og:image" content="/og-image.png" />
</Head>
<div>About Page</div>
</>
);
}App Router: metadata export
// app/about/page.tsx
import type { Metadata } from "next";
// 정적 메타데이터
export const metadata: Metadata = {
title: "About", // layout의 template 적용: "About | My App"
description: "About page description",
openGraph: {
title: "About",
images: ["/og-image.png"],
},
};
export default function AboutPage() {
return <div>About Page</div>;
}동적 메타데이터:
// app/posts/[id]/page.tsx
import type { Metadata } from "next";
type Props = {
params: { id: string };
};
// 동적으로 메타데이터 생성
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.id);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.thumbnail],
},
};
}
export default async function PostPage({ params }: Props) {
const post = await getPost(params.id);
return <div>{post.title}</div>;
}7. API Routes 비교
Pages Router: pages/api
// pages/api/posts/[id].ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
if (req.method === "GET") {
const post = await getPost(id as string);
return res.status(200).json(post);
}
if (req.method === "POST") {
const data = req.body;
const newPost = await createPost(data);
return res.status(201).json(newPost);
}
return res.status(405).json({ message: "Method not allowed" });
}App Router: Route Handlers
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
const post = await getPost(params.id);
return NextResponse.json(post);
}
export async function POST(request: NextRequest) {
const data = await request.json();
const newPost = await createPost(data);
return NextResponse.json(newPost, { status: 201 });
}
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
await deletePost(params.id);
return new NextResponse(null, { status: 204 });
}변경점:
- 파일명:
[id].ts→[id]/route.ts - HTTP 메서드별 함수 export (
GET,POST,PUT,DELETE) NextResponse사용- Request/Response 객체가 Web API 표준과 유사
8. 클라이언트 컴포넌트 vs 서버 컴포넌트
App Router의 핵심 개념
[Server Components - 기본]
- 서버에서만 실행
- 번들에 포함되지 않음
- async/await 사용 가능
- DB 직접 접근 가능
- useState, useEffect 사용 불가
[Client Components - 'use client']
- 클라이언트에서 실행
- 번들에 포함됨
- useState, useEffect 사용 가능
- 이벤트 핸들러 사용 가능
사용 패턴:
// app/posts/page.tsx (Server Component)
import { PostList } from "./PostList";
import { LikeButton } from "./LikeButton";
export default async function PostsPage() {
const posts = await getPosts(); // 서버에서 데이터 페칭
return (
<div>
{posts.map(post => (
<div key={post.id}>
<h2>{post.title}</h2>
<LikeButton postId={post.id} /> {/* 클라이언트 컴포넌트 */}
</div>
))}
</div>
);
}// app/posts/LikeButton.tsx (Client Component)
"use client";
import { useState } from "react";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>{liked ? "좋아요 취소" : "좋아요"}</button>;
}Server Component vs Client Component 만드는 법:
// Server Component (기본) - 아무것도 안 붙여도 Server Component
export default function Page() {
return <div>서버 컴포넌트</div>;
}
// Server Component + 데이터 페칭 - await 쓰려면 async 필요
export default async function Page() {
const data = await fetchData();
return <div>{data}</div>;
}
// Client Component - "use client" 명시
("use client");
export default function Page() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}| 구분 | 방법 |
|---|---|
| Server Component | 기본값 (아무것도 안 붙임) |
| Server Component + 데이터 페칭 | async 붙이고 await 사용 |
| Client Component | "use client" 파일 상단에 명시 |
핵심: async는 Server Component를 만드는 게 아니라, await를 사용하기 위해 붙이는 것입니다. App Router에서는 기본이 Server Component이고, "use client"를 명시해야만 Client Component가 됩니다.
언제 'use client'를 사용해야 하는가?
| 기능 | Server Component | Client Component |
|---|---|---|
| 데이터 페칭 (async/await) | O | X |
| DB, 파일시스템 접근 | O | X |
| useState, useEffect | X | O |
| onClick, onChange 등 이벤트 | X | O |
| 브라우저 API (localStorage 등) | X | O |
| 커스텀 훅 (상태 사용) | X | O |
9. 로딩/에러 UI
Pages Router
// pages/posts/[id].tsx
import { useState, useEffect } from "react";
export default function PostPage({ post }) {
const [loading, setLoading] = useState(false);
if (loading) return <div>Loading...</div>;
return <div>{post.title}</div>;
}App Router: 파일 기반 로딩/에러
/app/posts/[id]
├── page.tsx # 메인 페이지
├── loading.tsx # 로딩 UI (자동 Suspense)
├── error.tsx # 에러 UI (자동 Error Boundary)
└── not-found.tsx # 404 UI
// app/posts/[id]/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="mb-4 h-8 w-3/4 rounded bg-gray-200" />
<div className="mb-2 h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
);
}// app/posts/[id]/error.tsx
"use client"; // Error 컴포넌트는 클라이언트 컴포넌트여야 함
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>문제가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}// app/posts/[id]/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>404 - 페이지를 찾을 수 없습니다</h2>
</div>
);
}
// page.tsx에서 사용
import { notFound } from "next/navigation";
export default async function PostPage({ params }) {
const post = await getPost(params.id);
if (!post) {
notFound(); // not-found.tsx 렌더링
}
return <div>{post.title}</div>;
}10. 네비게이션
Pages Router: next/router
"use client";
import { useRouter } from "next/router";
export default function PostPage() {
const router = useRouter();
const { id } = router.query;
const handleClick = () => {
router.push("/posts");
// router.replace('/posts');
// router.back();
};
return <button onClick={handleClick}>목록으로</button>;
}App Router: next/navigation
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
export default function PostPage() {
const router = useRouter();
const pathname = usePathname(); // 현재 경로
const searchParams = useSearchParams(); // 쿼리 파라미터
const handleClick = () => {
router.push("/posts");
// router.replace('/posts');
// router.back();
// router.refresh(); // 현재 페이지 새로고침 (서버 컴포넌트 다시 실행)
};
return <button onClick={handleClick}>목록으로</button>;
}변경점:
next/router→next/navigationrouter.query→useSearchParams()+paramsproprouter.refresh()추가 (서버 컴포넌트 재실행)
Server Component에서 params 접근:
// app/posts/[id]/page.tsx (Server Component)
export default async function PostPage({
params,
searchParams,
}: {
params: { id: string };
searchParams: { sort?: string };
}) {
const { id } = params;
const { sort } = searchParams;
return (
<div>
Post {id}, Sort: {sort}
</div>
);
}11. 주요 개선사항 요약
성능 개선
| 항목 | Pages Router | App Router |
|---|---|---|
| 기본 렌더링 | 전체 클라이언트 번들 | Server Components (번들 감소) |
| 레이아웃 | 페이지 전환 시 리렌더링 | 레이아웃 유지 (부분 렌더링) |
| 데이터 페칭 | 워터폴 (순차적) | 병렬 페칭 가능 |
| 스트리밍 | 제한적 | Suspense 기반 스트리밍 |
개발자 경험 개선
| 항목 | Pages Router | App Router |
|---|---|---|
| 파일 구조 | _app, _document 분리 | layout.tsx 통합 |
| 메타데이터 | next/head (수동) | metadata export (타입 안전) |
| 로딩 UI | 직접 구현 | loading.tsx (자동) |
| 에러 처리 | 직접 구현 | error.tsx (자동) |
| 코로케이션 | 제한적 | 컴포넌트, 스타일, 테스트 함께 배치 가능 |
번들 사이즈 비교
[Pages Router]
- 모든 컴포넌트가 클라이언트 번들에 포함
- 큰 라이브러리 사용 시 번들 증가
[App Router]
- Server Components는 번들에 포함되지 않음
- 클라이언트 번들 30-50% 감소 가능
12. Next.js 16 주요 신기능
Next.js 16은 Turbopack 기본 활성화, 새로운 캐싱 모델, React 19.2 지원 등 대규모 업데이트가 포함되었습니다.
Turbopack 기본 번들러
Turbopack이 기본 번들러로 설정되었습니다. webpack을 사용하려면 명시적으로 지정해야 합니다.
# 기본: Turbopack 사용
next dev
next build
# webpack 사용하려면
next dev --webpack성능 개선:
- 프로덕션 빌드: 2-5배 빠름
- Fast Refresh: 10배 빠름
- 파일 시스템 캐싱으로 추가 최적화 가능
// next.config.ts - 파일 시스템 캐싱 (베타)
const nextConfig = {
experimental: {
turbopackFileSystemCacheForDev: true,
},
};Cache Components ("use cache")
새로운 캐싱 모델로, "use cache" 지시문을 통해 명시적으로 캐싱을 제어합니다.
// next.config.ts
const nextConfig = {
cacheComponents: true, // 활성화 필요
};// 컴포넌트 단위 캐싱
"use cache";
export default async function CachedComponent() {
const data = await fetchData(); // 이 컴포넌트 전체가 캐시됨
return <div>{data}</div>;
}// 함수 단위 캐싱
async function getData() {
"use cache";
return await db.query("SELECT * FROM posts");
}기존 PPR(Partial Prerendering)은 experimental.ppr 대신 Cache Components로 대체되었습니다.
proxy.ts (기존 middleware.ts)
middleware.ts가 proxy.ts로 이름이 변경되고, Node.js 런타임에서 실행됩니다.
// proxy.ts (루트 디렉토리)
import { NextRequest, NextResponse } from "next/server";
export default function proxy(request: NextRequest) {
// 인증 체크
if (!request.cookies.get("token")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};변경점:
- 파일명:
middleware.ts→proxy.ts - 런타임: Edge → Node.js
- 기존
middleware.ts도 당분간 동작하지만 deprecated
새로운 캐시 API
revalidateTag() 변경:
import { revalidateTag } from "next/cache";
// Next.js 15
revalidateTag("posts");
// Next.js 16 - cacheLife 프로필 필수
revalidateTag("posts", "max"); // 백그라운드 재검증
revalidateTag("news", "hours"); // 시간 단위
revalidateTag("analytics", "days"); // 일 단위
// 커스텀 만료 시간
revalidateTag("products", { expire: 3600 });updateTag() - 새로운 API:
Server Actions에서 캐시 즉시 갱신 (read-your-writes)
"use server";
import { updateTag } from "next/cache";
export async function updateProfile(userId: string, data: Profile) {
await db.users.update(userId, data);
updateTag(`user-${userId}`); // 캐시 만료 + 즉시 새로고침
}refresh() - 새로운 API:
캐시되지 않은 동적 데이터만 새로고침
"use server";
import { refresh } from "next/cache";
export async function markAsRead(id: string) {
await db.notifications.markAsRead(id);
refresh(); // 동적 데이터(알림 카운트 등)만 갱신
}향상된 라우팅
Layout Deduplication:
// 50개 제품 링크 prefetch 시:
Next.js 15: 레이아웃 50회 다운로드
Next.js 16: 레이아웃 1회만 다운로드
Incremental Prefetching:
- 이미 캐시된 부분은 제외하고 필요한 부분만 prefetch
- 뷰포트를 벗어나면 요청 자동 취소
React Compiler 정식 지원
// next.config.ts
const nextConfig = {
reactCompiler: true, // 더 이상 experimental 아님
};자동 메모이제이션으로 useMemo, useCallback 수동 작성 불필요
React 19.2 새 기능
// View Transitions - UI 전환 애니메이션
import { useViewTransition } from "react";
function TabContent({ tab }) {
const { startTransition } = useViewTransition();
return <button onClick={() => startTransition(() => setTab(newTab))}>탭 전환</button>;
}// useEffectEvent - 비반응형 로직 추출
import { useEffectEvent } from "react";
function Chat({ roomId, theme }) {
// theme 변경 시 재연결 안 함
const onConnected = useEffectEvent(() => {
showNotification("연결됨!", theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", onConnected);
return () => connection.disconnect();
}, [roomId]); // theme 의존성 불필요
}Breaking Changes
버전 요구사항:
| 항목 | 최소 버전 |
|---|---|
| Node.js | 20.9.0+ |
| TypeScript | 5.1.0+ |
| Chrome | 111+ |
| Safari | 16.4+ |
제거된 기능:
| 제거됨 | 대안 |
|---|---|
| AMP 지원 | 완전 제거 |
next lint | ESLint/Biome 직접 사용 |
experimental.ppr | Cache Components (cacheComponents) |
serverRuntimeConfig | 환경변수 사용 |
동기 params/searchParams | 반드시 await 사용 |
// Next.js 15까지 (deprecated)
const { id } = props.params;
// Next.js 16 (필수)
const { id } = await props.params;기본값 변경:
| 항목 | Next.js 15 | Next.js 16 |
|---|---|---|
| 기본 번들러 | webpack | Turbopack |
images.minimumCacheTTL | 60초 | 4시간 |
Parallel routes default.js | 선택 | 필수 |
업그레이드 방법
# 자동 마이그레이션 (권장)
npx @next/codemod@canary upgrade latest
# 수동 설치
npm install next@latest react@latest react-dom@latest