Next.js App Router vs Pages Router 완벽 비교
개요
Next.js는 두 가지 라우팅 시스템을 제공합니다. Pages Router는 Next.js 초기부터 사용된 전통적인 파일 기반 라우팅이고, App Router는 Next.js 13에서 도입된 새로운 라우팅 시스템입니다.
App Router는 React Server Components를 기본으로 사용하여 서버 우선 렌더링, 개선된 데이터 페칭, 향상된 레이아웃 관리를 제공합니다. 반면 Pages Router는 검증된 안정성과 풍부한 생태계를 가지고 있습니다.
배경
Pages Router의 역사
Pages Router는 2016년 Next.js 출시와 함께 등장했습니다. pages/ 디렉토리에 파일을 생성하면 자동으로 라우트가 생성되는 직관적인 방식으로 많은 개발자들의 사랑을 받았습니다.
pages/
├── index.js → /
├── about.js → /about
└── blog/
├── index.js → /blog
└── [slug].js → /blog/:slugApp Router가 필요했던 이유
Next.js 팀은 다음과 같은 문제들을 해결하기 위해 App Router를 설계했습니다:
- 중복되는 레이아웃 코드: 모든 페이지에서 동일한 레이아웃을 반복 작성
- 비효율적인 데이터 페칭: getServerSideProps와 getStaticProps의 제한적인 사용성
- 클라이언트 번들 크기: 모든 컴포넌트가 클라이언트로 전송되는 문제
- 복잡한 중첩 라우팅: 깊은 라우팅 구조 관리의 어려움
핵심 차이점
1. 디렉토리 구조
Pages Router
pages/
├── _app.js # 전역 App 컴포넌트
├── _document.js # HTML 문서 커스터마이징
├── index.js # 홈페이지
├── about.js # /about 페이지
└── api/
└── hello.js # API 라우트App Router
app/
├── layout.js # 루트 레이아웃
├── page.js # 홈페이지
├── about/
│ └── page.js # /about 페이지
├── blog/
│ ├── layout.js # 블로그 레이아웃
│ ├── page.js # /blog 페이지
│ └── [slug]/
│ └── page.js # /blog/:slug 페이지
└── api/
└── hello/
└── route.js # API 라우트주요 차이:
- App Router는 폴더 기반, Pages Router는 파일 기반
- App Router는
page.js,layout.js등 특수 파일 사용 - App Router는 같은 경로에 여러 파일 공존 가능 (layout, loading, error 등)
2. 라우트 정의 방식
Pages Router
// pages/products/[id].js
export default function Product({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
export async function getServerSideProps(context) {
const { id } = context.params;
const res = await fetch(`https://api.example.com/products/${id}`);
const product = await res.json();
return {
props: { product }
};
}App Router
// app/products/[id]/page.js
async function getProduct(id) {
const res = await fetch(`https://api.example.com/products/${id}`, {
cache: 'no-store' // SSR
});
return res.json();
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}주요 차이:
- App Router는 컴포넌트 내부에서 직접 데이터 페칭
- getServerSideProps, getStaticProps 불필요
- async/await를 컴포넌트에서 직접 사용 가능
3. 레이아웃 관리
Pages Router
// pages/_app.js
import Layout from '../components/Layout';
export default function App({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
// 페이지별 레이아웃 커스터마이징이 복잡함App Router
// app/layout.js (루트 레이아웃)
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<header>전역 헤더</header>
{children}
<footer>전역 푸터</footer>
</body>
</html>
);
}
// app/dashboard/layout.js (중첩 레이아웃)
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<nav>대시보드 네비게이션</nav>
<main>{children}</main>
</div>
);
}
// app/dashboard/page.js
export default function DashboardPage() {
return <h1>대시보드</h1>;
// 자동으로 DashboardLayout과 RootLayout에 감싸짐
}주요 차이:
- App Router는 중첩 레이아웃을 자연스럽게 지원
- 레이아웃은 리렌더링되지 않음 (성능 향상)
- 각 경로별로 독립적인 레이아웃 정의 가능
4. 데이터 페칭 전략
Pages Router
// SSR (Server-Side Rendering)
export async function getServerSideProps() {
const data = await fetch('https://api.example.com/data');
return { props: { data } };
}
// SSG (Static Site Generation)
export async function getStaticProps() {
const data = await fetch('https://api.example.com/data');
return { props: { data }, revalidate: 60 };
}
// ISR (Incremental Static Regeneration)
export async function getStaticProps() {
return {
props: { data },
revalidate: 10 // 10초마다 재생성
};
}App Router
// SSR (기본 동작)
async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store' // 매 요청마다 새로 가져옴
});
return res.json();
}
// SSG (캐시 사용)
async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache' // 빌드 시 캐시되어 정적으로 제공
});
return res.json();
}
// ISR (시간 기반 재검증)
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 } // 60초마다 재검증
});
return res.json();
}
// 온디맨드 재검증
import { revalidatePath } from 'next/cache';
export async function POST() {
revalidatePath('/posts');
return Response.json({ revalidated: true });
}주요 차이:
- App Router는 fetch API의 옵션으로 캐싱 전략 제어
- 더 세밀한 캐시 제어 가능
- 온디맨드 재검증으로 유연한 데이터 갱신
5. Server Components vs Client Components
Pages Router
모든 컴포넌트는 기본적으로 클라이언트 컴포넌트입니다.
// pages/profile.js
import { useState } from 'react';
export default function Profile() {
const [count, setCount] = useState(0); // 문제없음
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}App Router
기본적으로 Server Components이며, 필요할 때만 Client Components 사용
// app/profile/page.js (Server Component - 기본)
async function getUser() {
const res = await fetch('https://api.example.com/user');
return res.json();
}
export default async function ProfilePage() {
const user = await getUser();
return (
<div>
<h1>{user.name}</h1>
<Counter /> {/* Client Component */}
</div>
);
}
// app/profile/counter.js (Client Component)
'use client'; // 이 지시어가 필요함
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}주요 차이:
- App Router는 서버 컴포넌트가 기본값
- 인터랙티브가 필요한 부분만 ‘use client’ 지시어로 클라이언트 컴포넌트화
- 번들 크기 감소 및 초기 로딩 속도 향상
6. 로딩 및 에러 처리
Pages Router
// pages/posts.js
import { useState, useEffect } from 'react';
export default function Posts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('https://api.example.com/posts')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러 발생: {error.message}</div>;
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}App Router
// app/posts/loading.js (자동 로딩 UI)
export default function Loading() {
return <div>로딩 중...</div>;
}
// app/posts/error.js (자동 에러 처리)
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>에러 발생: {error.message}</h2>
<button onClick={() => reset()}>다시 시도</button>
</div>
);
}
// app/posts/page.js
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
if (!res.ok) throw new Error('데이터를 가져올 수 없습니다');
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}주요 차이:
- App Router는
loading.js,error.js파일로 자동 처리 - Suspense 기반의 선언적 로딩 상태 관리
- 에러 바운더리 자동 적용
7. 메타데이터 관리
Pages Router
// pages/posts/[id].js
import Head from 'next/head';
export default function Post({ post }) {
return (
<>
<Head>
<title>{post.title} | 블로그</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
</Head>
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
</>
);
}App Router
// app/posts/[id]/page.js
// 정적 메타데이터
export const metadata = {
title: '블로그 포스트',
description: '블로그 설명'
};
// 동적 메타데이터
export async function generateMetadata({ params }) {
const post = await getPost(params.id);
return {
title: `${post.title} | 블로그`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.image]
}
};
}
export default async function PostPage({ params }) {
const post = await getPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}주요 차이:
- App Router는 메타데이터를 객체로 내보내기
- Head 컴포넌트 불필요
- 타입 안정성과 자동 완성 지원
실제 사용 사례 비교
사례 1: 블로그 애플리케이션
Pages Router 구조
pages/
├── index.js # 홈
├── posts/
│ ├── index.js # 포스트 목록
│ └── [slug].js # 포스트 상세
└── api/
└── posts.js # APIApp Router 구조
app/
├── page.js # 홈
├── layout.js # 루트 레이아웃
├── posts/
│ ├── layout.js # 포스트 레이아웃
│ ├── loading.js # 로딩 UI
│ ├── error.js # 에러 UI
│ ├── page.js # 포스트 목록
│ └── [slug]/
│ ├── page.js # 포스트 상세
│ └── loading.js # 상세 로딩 UI
└── api/
└── posts/
└── route.js # APIApp Router는 더 명확한 구조와 UI 상태 관리를 제공합니다.
사례 2: 대시보드 애플리케이션
Pages Router 접근
// pages/_app.js
export default function App({ Component, pageProps }) {
// 모든 페이지에 동일한 레이아웃 강제
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
// 대시보드 전용 레이아웃을 원할 경우 복잡한 조건 처리 필요App Router 접근
// app/layout.js (루트 레이아웃)
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
);
}
// app/(marketing)/layout.js (마케팅 페이지 레이아웃)
export default function MarketingLayout({ children }) {
return (
<>
<MarketingNav />
{children}
</>
);
}
// app/(dashboard)/layout.js (대시보드 레이아웃)
export default function DashboardLayout({ children }) {
return (
<>
<DashboardSidebar />
<main>{children}</main>
</>
);
}App Router는 라우트 그룹 (폴더명) 기능으로 레이아웃을 유연하게 분리할 수 있습니다.
성능 비교
초기 로딩 속도
Pages Router
- 모든 JavaScript가 클라이언트로 전송
- 하이드레이션 필요
- 평균 번들 크기: 100-200KB (gzip)
App Router
- Server Components는 서버에서만 실행
- 필요한 JavaScript만 전송
- 평균 번들 크기: 50-100KB (gzip)
- 40-60% 번들 크기 감소
데이터 페칭 효율성
Pages Router
// 순차적 데이터 페칭
export async function getServerSideProps() {
const user = await fetchUser();
const posts = await fetchPosts(user.id); // user 이후에만 실행 가능
return { props: { user, posts } };
}App Router
// 병렬 데이터 페칭
export default async function Page() {
// Promise.all로 동시 실행
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts()
]);
return (
<>
<User data={user} />
<Posts data={posts} />
</>
);
}마이그레이션 가이드
점진적 마이그레이션 전략
Next.js는 두 라우터를 동시에 사용할 수 있어 점진적 마이그레이션이 가능합니다:
my-app/
├── app/ # App Router (새 기능)
│ ├── dashboard/
│ └── settings/
└── pages/ # Pages Router (기존 기능)
├── index.js
└── about.js마이그레이션 우선순위
-
먼저 마이그레이션하기 좋은 페이지:
- 정적 콘텐츠 페이지 (about, pricing 등)
- 새로운 기능/섹션
- 복잡한 레이아웃이 필요한 페이지
-
나중에 마이그레이션하는 페이지:
- 복잡한 클라이언트 인터랙션이 많은 페이지
- 외부 라이브러리에 강하게 의존하는 페이지
- 이미 최적화가 잘 된 핵심 페이지
단계별 마이그레이션
1단계: 레이아웃 분리
Before (Pages Router)
// pages/_app.js
export default function App({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}After (App Router)
// app/layout.js
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}2단계: 데이터 페칭 변환
Before (Pages Router)
export async function getServerSideProps() {
const data = await fetchData();
return { props: { data } };
}
export default function Page({ data }) {
return <div>{data.title}</div>;
}After (App Router)
async function getData() {
const res = await fetch('...', { cache: 'no-store' });
return res.json();
}
export default async function Page() {
const data = await getData();
return <div>{data.title}</div>;
}3단계: 클라이언트 컴포넌트 분리
After (App Router)
// app/posts/page.js (Server Component)
async function getPosts() {
return await fetch('...').then(r => r.json());
}
export default async function PostsPage() {
const posts = await getPosts();
return <PostsList posts={posts} />;
}
// app/posts/posts-list.js (Client Component)
'use client';
import { useState } from 'react';
export default function PostsList({ posts }) {
const [filter, setFilter] = useState('');
const filtered = posts.filter(post =>
post.title.includes(filter)
);
return (
<>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="검색..."
/>
<ul>
{filtered.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</>
);
}장점과 한계
Pages Router
장점
- ✅ 안정성: 오랜 기간 검증된 솔루션
- ✅ 생태계: 풍부한 라이브러리와 예제
- ✅ 학습 곡선: 상대적으로 낮은 진입 장벽
- ✅ 문서화: 방대한 자료와 커뮤니티 지원
- ✅ 호환성: 대부분의 React 라이브러리와 즉시 호환
한계
- ⚠️ 번들 크기: 모든 코드가 클라이언트로 전송
- ⚠️ 레이아웃 관리: 복잡한 중첩 레이아웃 구현이 어려움
- ⚠️ 데이터 페칭: getServerSideProps 등의 제약적인 API
- ⚠️ 성능: 클라이언트 하이드레이션 오버헤드
- ⚠️ 코드 중복: 레이아웃과 데이터 페칭 로직 반복
App Router
장점
- ✅ 성능: Server Components로 번들 크기 대폭 감소
- ✅ 레이아웃: 중첩 레이아웃과 라우트 그룹 지원
- ✅ 데이터 페칭: 컴포넌트 레벨의 유연한 페칭
- ✅ Streaming: React Suspense 기반의 점진적 렌더링
- ✅ 캐싱: 세밀한 캐시 제어와 재검증
한계
- ⚠️ 학습 곡선: Server/Client Components 개념 이해 필요
- ⚠️ 생태계: 일부 라이브러리가 아직 미지원
- ⚠️ 복잡성: ‘use client’ 경계 관리의 어려움
- ⚠️ 디버깅: 서버/클라이언트 경계에서의 디버깅 어려움
- ⚠️ 마이그레이션: 기존 코드 변환에 시간 필요
선택 가이드
Pages Router를 선택해야 하는 경우
-
기존 프로젝트 유지보수
- 이미 Pages Router로 구축된 안정적인 프로덕션 앱
- 마이그레이션 비용 대비 이익이 불분명한 경우
-
빠른 프로토타입
- 검증된 패턴으로 빠르게 개발해야 할 때
- 팀원 모두가 Pages Router에 익숙한 경우
-
특정 라이브러리 의존성
- App Router를 아직 지원하지 않는 핵심 라이브러리 사용 시
- 클라이언트 전용 라이브러리 다수 사용
App Router를 선택해야 하는 경우
-
새로운 프로젝트
- 최신 Next.js 기능 활용
- 장기적인 유지보수성 고려
-
성능이 중요한 경우
- 초기 로딩 속도 최적화 필요
- SEO가 중요한 콘텐츠 중심 사이트
-
복잡한 레이아웃 구조
- 여러 수준의 중첩 레이아웃 필요
- 라우트별 다른 레이아웃 적용
-
대규모 데이터 처리
- 효율적인 서버 사이드 데이터 페칭
- Streaming 및 Suspense 활용
마이그레이션 체크리스트
마이그레이션 전 확인사항
- Next.js 13.4 이상으로 업그레이드
- 사용 중인 라이브러리의 App Router 호환성 확인
- 팀원들의 Server Components 개념 학습
- 테스트 커버리지 확인 및 보강
마이그레이션 중
-
app/디렉토리 생성 - 루트 레이아웃 작성 (
app/layout.js) - 한 페이지씩 점진적 마이그레이션
- Server/Client Components 경계 명확히 정의
- 메타데이터 API로 전환
- 데이터 페칭 방식 전환
- 로딩/에러 상태 처리 개선
마이그레이션 후
- 성능 메트릭 비교 (번들 크기, LCP, FCP)
- 모든 기능 정상 작동 확인
- 사용자 피드백 수집
- 모니터링 및 에러 추적 설정
실전 팁
Tip 1: Server/Client 경계 최적화
// ❌ 나쁜 예: 전체를 Client Component로 만듦
'use client';
export default function Page() {
const [state, setState] = useState();
return (
<>
<StaticContent /> {/* 서버에서 렌더링 가능 */}
<InteractiveForm /> {/* 클라이언트 필요 */}
</>
);
}
// ✅ 좋은 예: 필요한 부분만 Client Component
export default function Page() { // Server Component
return (
<>
<StaticContent /> {/* 서버 렌더링 */}
<InteractiveForm /> {/* 'use client' 내부 */}
</>
);
}Tip 2: 캐싱 전략 활용
// 정적 데이터 (빌드 시 한 번만)
await fetch('...', { cache: 'force-cache' });
// 동적 데이터 (매 요청마다)
await fetch('...', { cache: 'no-store' });
// 재검증 데이터 (60초마다 갱신)
await fetch('...', { next: { revalidate: 60 } });
// 태그 기반 재검증
await fetch('...', { next: { tags: ['posts'] } });
// 다른 곳에서 재검증
revalidateTag('posts');Tip 3: 병렬 데이터 페칭
// ❌ 순차 실행 (느림)
export default async function Page() {
const user = await getUser();
const posts = await getPosts();
const comments = await getComments();
return <div>...</div>;
}
// ✅ 병렬 실행 (빠름)
export default async function Page() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments()
]);
return <div>...</div>;
}Tip 4: Suspense 경계 설정
import { Suspense } from 'react';
export default function Page() {
return (
<>
{/* 빠른 컴포넌트는 즉시 표시 */}
<FastComponent />
{/* 느린 컴포넌트는 Suspense로 감싸기 */}
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
{/* 또 다른 느린 컴포넌트는 독립적으로 */}
<Suspense fallback={<Skeleton />}>
<AnotherSlowComponent />
</Suspense>
</>
);
}추가 리소스
공식 문서
학습 자료
커뮤니티
결론
Pages Router는 검증된 안정성과 풍부한 생태계를 가진 신뢰할 수 있는 선택입니다. 기존 프로젝트를 유지보수하거나 빠른 개발이 필요한 경우 여전히 훌륭한 옵션입니다.
App Router는 Next.js의 미래이며, 더 나은 성능, 개선된 개발 경험, 그리고 현대적인 React 패턴을 제공합니다. 새로운 프로젝트나 성능이 중요한 애플리케이션에서는 App Router를 적극 권장합니다.
두 라우터를 동시에 사용할 수 있으므로, 점진적 마이그레이션을 통해 리스크를 최소화하면서 새로운 기능을 도입할 수 있습니다. 팀의 상황과 프로젝트 요구사항을 고려하여 최적의 선택을 하세요.
문서 버전: 1.0.0 마지막 업데이트: 2026년 2월 대상 독자: Next.js 개발자 (중급)