$ yh.log
[Next.js] Pages Router vs App Router 비교 (+ Next.js v16)

[Next.js] Pages Router vs App Router 비교 (+ Next.js v16)

Next.jsReactApp RouterPages Router

작성자 : 오예환 | 작성일 : 2025-12-17 | 수정일 : 2025-12-17

1. 개요

Next.js 13에서 App Router가 도입되면서 라우팅 방식이 크게 변경되었습니다. 이 글에서는 Pages Router (Next.js 12)App Router (Next.js 15) 의 차이점을 비교합니다.

구분Pages RouterApp Router
도입 버전Next.js 1.0~Next.js 13~
디렉토리/pages/app
기본 렌더링CSR/SSR 선택Server Components (기본)
데이터 페칭getServerSideProps, getStaticPropsasync/await in Server Components
레이아웃_app.tsx, _document.tsxlayout.tsx, template.tsx
메타데이터next/headmetadata 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로 폰트 자동 최적화
  • metadata export로 메타 태그 설정

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 RouterApp Router
SSRgetServerSidePropscache: 'no-store'
SSGgetStaticPropscache: 'force-cache' (기본)
ISRrevalidate 옵션next: { revalidate: 초 }
동적 경로 생성getStaticPathsgenerateStaticParams

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 ComponentClient Component
데이터 페칭 (async/await)OX
DB, 파일시스템 접근OX
useState, useEffectXO
onClick, onChange 등 이벤트XO
브라우저 API (localStorage 등)XO
커스텀 훅 (상태 사용)XO

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/routernext/navigation
  • router.queryuseSearchParams() + params prop
  • router.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 RouterApp Router
기본 렌더링전체 클라이언트 번들Server Components (번들 감소)
레이아웃페이지 전환 시 리렌더링레이아웃 유지 (부분 렌더링)
데이터 페칭워터폴 (순차적)병렬 페칭 가능
스트리밍제한적Suspense 기반 스트리밍

개발자 경험 개선

항목Pages RouterApp 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.tsproxy.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.tsproxy.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.js20.9.0+
TypeScript5.1.0+
Chrome111+
Safari16.4+

제거된 기능:

제거됨대안
AMP 지원완전 제거
next lintESLint/Biome 직접 사용
experimental.pprCache Components (cacheComponents)
serverRuntimeConfig환경변수 사용
동기 params/searchParams반드시 await 사용
// Next.js 15까지 (deprecated)
const { id } = props.params;
 
// Next.js 16 (필수)
const { id } = await props.params;

기본값 변경:

항목Next.js 15Next.js 16
기본 번들러webpackTurbopack
images.minimumCacheTTL60초4시간
Parallel routes default.js선택필수

업그레이드 방법

# 자동 마이그레이션 (권장)
npx @next/codemod@canary upgrade latest
 
# 수동 설치
npm install next@latest react@latest react-dom@latest

참고 자료