Page Router vs. App Router

작성일: 2025. 10. 08최종 수정: 2025. 10. 09. 15시 01분

Next.js의 진화

Next.js는 2016년 Vercel(당시 Zeit)에서 출시한 React 프레임워크다. 처음부터 파일 시스템 기반 라우팅과 서버 사이드 렌더링을 제공하며 React 생태계에서 독보적인 위치를 차지했다.

초기 Next.js는 pages 디렉토리를 사용하는 단일 라우팅 시스템만 제공했다. 이것이 지금의 Page Router다. 2022년 10월, Next.js 13에서 app 디렉토리를 사용하는 새로운 라우팅 시스템인 App Router가 베타로 공개되었고, 2023년 5월 Next.js 13.4에서 안정 버전이 출시되었다.

App Router는 단순한 개선이 아니라 React 18의 새로운 기능들을 활용하기 위한 근본적인 재설계다. React Server Components, Suspense, Streaming 같은 기능들을 완전히 지원하기 위해 라우팅, 데이터 페칭, 렌더링 방식을 새롭게 구축했다.

Page Router - 전통적인 방식

핵심 개념

Page Router는 pages 디렉토리 안의 파일 구조가 곧 라우트가 되는 방식이다.

plain
pages/
├── index.tsx          → /
├── about.tsx          → /about
├── blog/
│   ├── index.tsx      → /blog
│   └── [slug].tsx     → /blog/:slug
└── _app.tsx           → 모든 페이지를 감싸는 컴포넌트

각 파일은 React 컴포넌트를 default export하며, 이 컴포넌트가 해당 경로의 페이지가 된다.

typescript
// pages/about.tsx
export default function About() {
  return <h1>소개 페이지</h1>
}

데이터 페칭

Page Router는 페이지 수준에서만 데이터를 가져올 수 있다. 세 가지 특수 함수를 제공한다.

typescript
// SSR - 매 요청마다 실행
export async function getServerSideProps(context) {
  const res = await fetch('<https://api.example.com/data>')
  const data = await res.json()

  return {
    props: { data }
  }
}

// SSG - 빌드 시점에 한 번만 실행
export async function getStaticProps(context) {
  const res = await fetch('<https://api.example.com/data>')
  const data = await res.json()

  return {
    props: { data },
    revalidate: 60  // ISR
  }
}

// 동적 라우트의 경로 생성
export async function getStaticPaths() {
  return {
    paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
    fallback: false
  }
}

export default function Page({ data }) {
  return <div>{data.title}</div>
}

이 함수들은 페이지 컴포넌트 파일에만 작성할 수 있다. 중첩된 컴포넌트에서는 데이터를 직접 가져올 수 없고, props로 전달받아야 한다.

레이아웃 시스템

Page Router는 레이아웃을 명시적으로 지원하지 않는다. _app.tsx에서 모든 페이지를 감싸거나, 각 페이지에서 레이아웃 컴포넌트를 수동으로 import해야 한다.

typescript
// pages/_app.tsx
import Layout from '@/components/Layout'

export default function App({ Component, pageProps }) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

문제는 모든 페이지에 같은 레이아웃이 적용된다는 점이다. 블로그 섹션에만 다른 레이아웃을 적용하려면 추가 작업이 필요하다.

typescript
// pages/blog/[slug].tsx
import BlogLayout from '@/components/BlogLayout'

export default function BlogPost({ post }) {
  return (
    <BlogLayout>
      <article>{post.content}</article>
    </BlogLayout>
  )
}

BlogPost.getLayout = function getLayout(page) {
  return <BlogLayout>{page}</BlogLayout>
}

장점

Page Router의 장점은 단순함과 직관성이다. 파일을 만들면 바로 페이지가 되고, 데이터 페칭 방식도 명확하게 분리되어 있다. 문서와 예제가 풍부하고, 커뮤니티에서 오랫동안 사용해온 검증된 방식이다.

SSR, SSG, ISR 같은 다양한 사전 렌더링 방식을 쉽게 선택할 수 있고, 각 페이지마다 독립적으로 렌더링 전략을 설정할 수 있다.

한계

하지만 컴포넌트 레벨의 세밀한 제어가 어렵다는 한계가 있다.

첫째, 모든 컴포넌트가 클라이언트 컴포넌트로 번들에 포함된다. 페이지에 상호작용이 없는 정적 컴포넌트가 많아도 전부 JavaScript 번들에 포함되어 브라우저로 전송된다.

typescript
// 이 컴포넌트는 정적이지만 번들에 포함됨
function StaticHeader() {
  return <header>고정된 헤더</header>
}

// 이것도 번들에 포함됨
function StaticFooter() {
  return <footer>고정된 푸터</footer>
}

export default function Page() {
  return (
    <>
      <StaticHeader />
      <InteractiveContent />
      <StaticFooter />
    </>
  )
}

위 코드에서 StaticHeaderStaticFooter는 상호작용이 없어 서버에서 한 번만 렌더링하면 충분하다. 하지만 Page Router에서는 이들도 클라이언트로 전송되고 하이드레이션된다.

둘째, 데이터 페칭이 페이지 최상위에 집중된다. 중첩된 컴포넌트가 각자 데이터를 가져와야 한다면 페이지에서 모든 데이터를 가져와 props로 전달해야 한다. 컴포넌트 트리가 깊어질수록 props drilling이 심해진다.

typescript
export async function getServerSideProps() {
  // 페이지에서 모든 데이터를 가져와야 함
  const user = await getUser()
  const posts = await getPosts()
  const comments = await getComments()

  return {
    props: { user, posts, comments }
  }
}

export default function Page({ user, posts, comments }) {
  return (
    <Layout user={user}>
      <PostList posts={posts} comments={comments} />
    </Layout>
  )
}

셋째, 레이아웃 상태가 유지되지 않는다. 페이지를 이동할 때마다 전체 트리가 리렌더링되므로, 사이드바의 스크롤 위치나 입력 폼의 상태가 사라진다.

App Router - 새로운 패러다임

등장 배경

App Router는 React 18의 새로운 기능들을 완전히 활용하기 위해 설계되었다. 특히 React Server Components가 핵심이다.

React Server Components란?
React 18에서 도입된 새로운 컴포넌트 유형이다. 서버에서만 렌더링되고 클라이언트 번들에 포함되지 않는다. 데이터베이스 직접 조회, 민감한 환경 변수 사용, 대용량 의존성 사용이 가능하면서도 번들 크기에 영향을 주지 않는다.

기존 Page Router는 React의 전통적인 클라이언트 컴포넌트 모델을 기반으로 설계되었기 때문에 Server Components를 제대로 지원할 수 없었다. App Router는 이 새로운 패러다임을 처음부터 고려해서 만들어졌다.

파일 구조와 라우팅

App Router는 폴더가 라우트를 정의하고, 특수 파일들이 각 라우트의 UI를 구성한다.

plain
app/
├── layout.tsx         → 루트 레이아웃 (필수)
├── page.tsx           → / 페이지
├── loading.tsx        → 로딩 UI
├── error.tsx          → 에러 UI
├── not-found.tsx      → 404 UI
└── blog/
    ├── layout.tsx     → 블로그 레이아웃
    ├── page.tsx       → /blog 페이지
    └── [slug]/
        ├── page.tsx   → /blog/:slug 페이지
        └── loading.tsx → /blog/:slug 로딩 UI

page.tsx가 있어야 해당 라우트가 접근 가능해진다. 폴더만 있고 page.tsx가 없으면 라우트로 동작하지 않는다.

typescript
// app/blog/page.tsx
export default function BlogPage() {
  return <h1>블로그</h1>
}

특수 파일의 역할

각 특수 파일은 명확한 책임을 가진다.

layout.tsx는 여러 페이지에서 공유되는 UI를 정의한다. 페이지 이동 시 리렌더링되지 않고 상태를 유지한다.

typescript
// app/blog/layout.tsx
export default function BlogLayout({ children }) {
  return (
    <div className="blog-container">
      <aside>블로그 사이드바</aside>
      <main>{children}</main>
    </div>
  )
}

위 레이아웃은 /blog 하위의 모든 페이지에 적용된다. 페이지를 이동해도 사이드바는 리렌더링되지 않는다.

loading.tsx는 Suspense 경계를 자동으로 생성한다. 페이지나 세그먼트가 로딩 중일 때 표시할 UI를 정의한다.

typescript
// app/blog/loading.tsx
export default function Loading() {
  return <div>블로그 로딩 중...</div>
}

error.tsx는 에러 경계를 만든다. 하위 컴포넌트에서 발생한 에러를 잡아서 처리한다.

typescript
// app/blog/error.tsx
'use client'

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>에러가 발생했습니다</h2>
      <p>{error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  )
}

이 파일들은 자동으로 적절한 React 기능(Suspense, Error Boundary)을 적용해준다. 직접 작성할 필요가 없다.

React Server Components

App Router의 모든 컴포넌트는 기본적으로 Server Component다. 클라이언트에서 실행되어야 하는 컴포넌트만 명시적으로 표시한다.

typescript
// app/blog/page.tsx
// Server Component (기본값)
export default async function BlogPage() {
  // 서버에서 직접 데이터 가져오기
  const posts = await db.post.findMany()

  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <LikeButton postId={post.id} />
        </article>
      ))}
    </div>
  )
}
typescript
// components/LikeButton.tsx
'use client'  // Client Component 명시

import { useState } from 'react'

export default function LikeButton({ postId }) {
  const [liked, setLiked] = useState(false)

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '좋아요 취소' : '좋아요'}
    </button>
  )
}

BlogPage는 Server Component라서 서버에서만 실행된다. 데이터베이스를 직접 조회할 수 있고, 이 컴포넌트의 코드는 클라이언트 번들에 포함되지 않는다.

LikeButton'use client' 지시어로 Client Component임을 명시한다. useState 같은 React 훅을 사용하고, 클릭 이벤트를 처리하는 등 상호작용이 필요하기 때문이다.

이렇게 하면 상호작용이 없는 부분은 서버에서만 처리되고, 필요한 부분만 클라이언트로 전송된다. 번들 크기가 줄어들고 초기 로딩이 빨라진다.

데이터 페칭

Server Component에서는 async/await를 직접 사용해서 데이터를 가져온다. 특별한 함수가 필요 없다.

typescript
// app/posts/[id]/page.tsx
export default async function PostPage({ params }) {
  // 컴포넌트 내부에서 직접 데이터 가져오기
  const post = await fetch(`https://api.example.com/posts/${params.id}`)
    .then(res => res.json())

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

각 컴포넌트가 필요한 데이터를 직접 가져올 수 있다. props drilling이 사라진다.

typescript
// app/blog/page.tsx
export default async function BlogPage() {
  return (
    <div>
      <RecentPosts />
      <PopularPosts />
    </div>
  )
}

// components/RecentPosts.tsx
export default async function RecentPosts() {
  const posts = await fetch('<https://api.example.com/posts/recent>')
    .then(res => res.json())

  return (
    <section>
      <h2>최근 글</h2>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </section>
  )
}

// components/PopularPosts.tsx
export default async function PopularPosts() {
  const posts = await fetch('<https://api.example.com/posts/popular>')
    .then(res => res.json())

  return (
    <section>
      <h2>인기 글</h2>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </section>
  )
}

RecentPostsPopularPosts가 각자 필요한 데이터를 독립적으로 가져온다. 부모 컴포넌트는 데이터 페칭에 대해 알 필요가 없다.

캐싱 전략

fetch API는 자동으로 캐싱된다. 옵션으로 제어할 수 있다.

typescript
// 캐시 사용 (기본값)
const data = await fetch('<https://api.example.com/data>')

// 매번 새로 가져오기 (SSR처럼 동작)
const data = await fetch('<https://api.example.com/data>', {
  cache: 'no-store'
})

// 특정 시간 동안만 캐시 (ISR처럼 동작)
const data = await fetch('<https://api.example.com/data>', {
  next: { revalidate: 60 }  // 60초
})

Page Router의 getServerSideProps, getStaticProps, ISR을 fetch 옵션으로 통합했다.

스트리밍과 Suspense

App Router는 페이지를 한 번에 보내는 대신 준비된 부분부터 점진적으로 전송할 수 있다.

typescript
// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function Dashboard() {
  return (
    <div>
      <h1>대시보드</h1>

      <Suspense fallback={<div>사용자 정보 로딩 중...</div>}>
        <UserInfo />
      </Suspense>

      <Suspense fallback={<div>통계 로딩 중...</div>}>
        <Statistics />
      </Suspense>
    </div>
  )
}

async function UserInfo() {
  const user = await fetch('<https://api.example.com/user>')
    .then(res => res.json())

  return <div>{user.name}</div>
}

async function Statistics() {
  // 느린 API 호출
  const stats = await fetch('<https://api.example.com/stats>')
    .then(res => res.json())

  return <div>{stats.value}</div>
}

위 코드는 다음 순서로 동작한다.

  1. 서버가 헤더와 레이아웃을 즉시 전송
  2. 브라우저가 헤더를 먼저 표시
  3. UserInfo가 완료되면 스트리밍으로 전송하고 fallback을 교체
  4. Statistics가 완료되면 마찬가지로 스트리밍

사용자는 전체 페이지가 완료될 때까지 기다리지 않고 준비된 부분부터 볼 수 있다. 느린 API가 있어도 전체 페이지가 블로킹되지 않는다.

주요 차이점 비교

1. 파일 구조

Page Router는 파일이 곧 라우트다.

plain
pages/
├── index.tsx        → /
└── about.tsx        → /about

App Router는 폴더가 라우트이고, page.tsx가 있어야 접근 가능하다.

plain
app/
├── page.tsx         → /
└── about/
    └── page.tsx     → /about

2. 데이터 페칭 위치

Page Router는 페이지 컴포넌트에서만 가능하다.

typescript
// pages/blog.tsx
export async function getServerSideProps() {
  const posts = await getPosts()
  return { props: { posts } }
}

export default function Blog({ posts }) {
  return <PostList posts={posts} />
}

App Router는 모든 Server Component에서 가능하다.

typescript
// app/blog/page.tsx
export default async function Blog() {
  return <PostList />
}

// components/PostList.tsx
export default async function PostList() {
  const posts = await getPosts()
  return <div>{posts.map(...)}</div>
}

3. 레이아웃 시스템

Page Router는 수동으로 레이아웃을 감싸야 한다.

typescript
// pages/_app.tsx
export default function App({ Component, pageProps }) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

App Routerlayout.tsx가 자동으로 적용된다.

typescript
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>{children}</body>
    </html>
  )
}

중첩 레이아웃도 자동으로 처리된다.

typescript
// app/blog/layout.tsx
export default function BlogLayout({ children }) {
  return (
    <div className="blog-layout">
      {children}
    </div>
  )
}

4. 렌더링 기본값

Page Router는 모든 컴포넌트가 Client Component다. 서버에서 HTML을 생성한 후 클라이언트에서 하이드레이션한다.

App Router는 모든 컴포넌트가 기본적으로 Server Component다. 클라이언트 인터랙션이 필요한 곳만 'use client'를 명시한다.

5. 로딩과 에러 처리

Page Router는 직접 구현해야 한다.

typescript
export default function Page({ data }) {
  if (!data) return <Loading />
  if (data.error) return <Error />
  return <Content data={data} />
}

App Router는 파일 기반으로 처리한다.

typescript
// app/blog/loading.tsx
export default function Loading() {
  return <Skeleton />
}

// app/blog/error.tsx
'use client'
export default function Error({ error, reset }) {
  return <ErrorUI error={error} reset={reset} />
}

어떤 것을 선택할까

Page Router를 사용하는 경우

다음 상황에서는 Page Router가 적합하다.

기존 프로젝트를 유지보수하는 경우다. 이미 Page Router로 구축된 안정적인 서비스라면 굳이 마이그레이션할 필요가 없다. Page Router는 계속 지원되고 업데이트된다.

팀이 Page Router에 익숙한 경우다. App Router는 새로운 개념들이 많아 학습 곡선이 있다. 팀 전체가 학습할 시간이 없다면 익숙한 방식을 사용하는 것이 효율적이다.

단순한 구조의 웹사이트를 만드는 경우다. 복잡한 데이터 페칭이나 중첩 레이아웃이 필요 없는 마케팅 사이트나 랜딩 페이지라면 Page Router가 더 간단할 수 있다.

App Router를 사용하는 경우

다음 상황에서는 App Router가 유리하다.

새 프로젝트를 시작하는 경우다. Next.js 공식 문서도 App Router를 기본으로 안내한다. 앞으로의 새로운 기능들은 App Router를 중심으로 개발될 것이다.

번들 크기 최적화가 중요한 경우다. Server Components로 서버 전용 코드를 클라이언트에서 제외할 수 있어 초기 로딩 성능이 개선된다.

복잡한 레이아웃과 데이터 구조를 다루는 경우다. 중첩 레이아웃, 병렬 라우팅, 인터셉팅 라우팅 같은 고급 패턴을 쉽게 구현할 수 있다.

스트리밍과 점진적 렌더링이 필요한 경우다. 대시보드처럼 여러 데이터 소스에서 독립적으로 데이터를 가져와야 하고, 느린 API가 전체 페이지를 블로킹하면 안 되는 상황에 적합하다.

마이그레이션 전략

App Router는 점진적으로 도입할 수 있다. pagesapp 디렉토리를 동시에 사용 가능하다.

plain
my-app/
├── app/
│   └── blog/          → App Router로 관리
│       └── page.tsx
└── pages/
    ├── index.tsx      → Page Router로 관리
    └── about.tsx

위 구조에서 /blog는 App Router가 처리하고, //about은 Page Router가 처리한다. App Router가 우선순위를 가지므로 같은 경로가 있다면 app이 우선 적용된다.

마이그레이션 순서

  1. 루트 레이아웃 생성
typescript
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  )
}
  1. 독립적인 페이지부터 이동

블로그나 문서 같이 다른 페이지와 의존성이 적은 부분부터 시작한다.

typescript
// app/blog/page.tsx
export default async function BlogPage() {
  const posts = await getPosts()
  return <PostList posts={posts} />
}
  1. 데이터 페칭 방식 변경

getServerSideProps를 async 컴포넌트로 변환한다.

typescript
// Before (Page Router)
export async function getServerSideProps() {
  const data = await fetchData()
  return { props: { data } }
}

export default function Page({ data }) {
  return <div>{data}</div>
}

// After (App Router)
export default async function Page() {
  const data = await fetchData()
  return <div>{data}</div>
}
  1. 클라이언트 컴포넌트 분리

상호작용이 필요한 부분만 Client Component로 분리한다.

typescript
// components/InteractiveButton.tsx
'use client'

import { useState } from 'react'

export default function InteractiveButton() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}
  1. 점진적 확장

한 섹션씩 검증하면서 나머지 페이지를 이동한다.

주의사항

클라이언트 전용 코드'use client' 컴포넌트에서만 사용해야 한다. localStorage, window 같은 브라우저 API는 Server Component에서 접근할 수 없다.

typescript
// 잘못된 예: Server Component에서 사용 불가
export default function Page() {
  const theme = localStorage.getItem('theme')  // 에러 발생
  return <div>{theme}</div>
}

// 올바른 예: Client Component에서 사용
'use client'

export default function Page() {
  const theme = localStorage.getItem('theme')
  return <div>{theme}</div>
}

서버 전용 코드는 Server Component에서만 사용한다. 데이터베이스 쿼리나 환경 변수는 클라이언트로 전송되면 안 된다.

typescript
// 올바른 예: Server Component
export default async function Page() {
  const secret = process.env.API_SECRET  // 안전
  const data = await db.query(...)       // 안전
  return <div>{data}</div>
}

Context API는 Server Component에서 사용할 수 없다. Provider는 Client Component여야 한다.

typescript
// providers.tsx
'use client'

import { ThemeProvider } from './theme-context'

export function Providers({ children }) {
  return <ThemeProvider>{children}</ThemeProvider>
}

// app/layout.tsx (Server Component)
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

정리

Page Router는 간단하고 검증된 방식이다. 파일 기반 라우팅과 명확한 데이터 페칭 패턴으로 누구나 쉽게 시작할 수 있다. 기존 프로젝트를 유지하거나 단순한 사이트를 빠르게 만들 때 여전히 좋은 선택이다.

App Router는 React 18의 새로운 패러다임을 완전히 활용한다. Server Components로 번들 크기를 줄이고, 컴포넌트 레벨의 데이터 페칭으로 구조를 단순화하며, 스트리밍으로 사용자 경험을 개선한다. 새 프로젝트나 복잡한 애플리케이션에는 App Router가 더 적합하다.

둘 중 무엇을 선택하든 Next.js는 계속 두 방식을 모두 지원한다. 프로젝트의 요구사항과 팀의 상황에 맞는 것을 선택하면 된다.

참고 자료