Next.js Pages Router와 App Router는 무엇이 다른가? 코드로 깊게 비교하기

Frontend

Next.js를 조금 오래 써본 팀이라면 Pages Router가 익숙합니다.

  • pages/index.tsx
  • pages/blog/[slug].tsx
  • getServerSideProps
  • getStaticProps
  • _app.tsx
  • pages/api/*

반면 최근 Next.js 문서는 거의 App Router를 기준으로 설명합니다.

  • app/page.tsx
  • app/blog/[slug]/page.tsx
  • layout.tsx
  • loading.tsx
  • error.tsx
  • route.ts

그래서 실무에서는 자연스럽게 이런 질문이 나옵니다.

"결국 뭐가 다른 거지?"

"파일 위치만 바뀐 건가?"

"아니면 렌더링 방식 자체가 달라진 건가?"

결론부터 말하면, 둘의 차이는 폴더 이름이 아니라 렌더링과 데이터 흐름의 기본 모델입니다.

Pages Router는 페이지 단위 데이터 함수와 클라이언트 React 앱 감각이 중심이었다면, App Router는 서버 컴포넌트와 레이아웃 계층, 캐시 정책을 중심으로 사고방식이 바뀝니다.

이 글에서는 그 차이를 개념 설명으로 끝내지 않고, 같은 예제를 두 방식으로 나란히 놓고 코드 중심으로 깊게 비교해보겠습니다.

한눈에 보면

먼저 아주 짧게 정리하면 이렇습니다.

항목 Pages Router App Router
라우팅 기준 pages/ 파일 app/ 폴더 + 특수 파일
데이터 패칭 getServerSideProps, getStaticProps 서버 컴포넌트 안 fetch, revalidate
공통 레이아웃 _app.tsx, 패턴 커스텀 필요 layout.tsx로 계층형 구성
로딩/에러 직접 상태 처리 또는 커스텀 패턴 loading.tsx, error.tsx
API pages/api/* app/api/*/route.ts
기본 렌더링 감각 페이지 중심 서버 컴포넌트 + 세그먼트 중심
캐시 모델 함수 기반 렌더링 선택 fetch 단위 캐시/재검증
서버/클라이언트 경계 상대적으로 덜 명시적 매우 명시적

이 표만 보면 "기능 위치가 옮겨졌네" 정도로 보일 수 있습니다. 하지만 실무 체감은 그보다 훨씬 큽니다.

가장 큰 차이: 페이지 중심에서 세그먼트 중심으로 바뀐다

Pages Router는 이름 그대로 페이지 파일 중심입니다.

예를 들어:

pages/
  index.tsx
  blog/
    index.tsx
    [slug].tsx

이 구조에서는 각 파일이 하나의 페이지입니다.

반면 App Router세그먼트와 특수 파일 중심입니다.

app/
  page.tsx
  blog/
    page.tsx
    [slug]/
      page.tsx

표면적으로는 비슷해 보이지만, 실제로는 다릅니다.

App Router에서는 한 경로를 설명할 때:

  • page.tsx
  • layout.tsx
  • loading.tsx
  • error.tsx
  • not-found.tsx

같은 여러 파일이 같이 참여합니다.

즉, Pages Router가 "페이지 파일 하나"에 더 가깝다면, App Router는 "라우트 세그먼트 묶음"에 가깝습니다.

같은 블로그 상세 페이지를 두 방식으로 비교해보자

Pages Router

// pages/blog/[slug].tsx
import { GetServerSideProps } from 'next';
 
type BlogDetailPageProps = {
  post: {
    title: string;
    description: string;
    content: string;
  };
};
 
export default function BlogDetailPage({ post }: BlogDetailPageProps) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.description}</p>
      <div>{post.content}</div>
    </article>
  );
}
 
export const getServerSideProps: GetServerSideProps<BlogDetailPageProps> = async ({ params }) => {
  const response = await fetch(`https://api.example.com/posts/${params?.slug}`);
  const post = await response.json();
 
  return {
    props: {
      post,
    },
  };
};

이 방식의 핵심은 분명합니다.

  • 페이지 컴포넌트는 props를 받고
  • 데이터 패칭은 getServerSideProps가 맡습니다

즉, UI와 데이터 함수가 같은 파일에 있지만 서로 다른 블록으로 나뉘어 있습니다.

App Router

// app/blog/[slug]/page.tsx
type BlogDetailPageProps = {
  params: {
    slug: string;
  };
};
 
export async function generateMetadata({ params }: BlogDetailPageProps) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then((res) =>
    res.json()
  );
 
  return {
    title: post.title,
    description: post.description,
  };
}
 
export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then((res) =>
    res.json()
  );
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.description}</p>
      <div>{post.content}</div>
    </article>
  );
}

여기서 보이는 차이가 중요합니다.

  • 별도 getServerSideProps가 없습니다
  • 페이지 컴포넌트 자체가 서버에서 실행될 수 있습니다
  • 메타데이터도 같은 세그먼트 안에서 다룹니다

즉, App Router에서는 데이터 패칭이 "페이지 바깥 함수"가 아니라, 서버에서 실행되는 컴포넌트 문맥 안으로 들어옵니다.

데이터 패칭 모델은 어떻게 달라질까?

이 부분이 가장 실무적입니다.

Pages Router의 사고방식

Pages Router에서는 렌더링 전략을 페이지 단위 함수로 고릅니다.

  • 요청마다 서버 실행: getServerSideProps
  • 빌드 시 생성: getStaticProps
  • 동적 경로 미리 생성: getStaticPaths

예를 들어 정적 블로그라면:

// pages/blog/[slug].tsx
import { GetStaticPaths, GetStaticProps } from 'next';
 
export default function BlogDetailPage({ post }: { post: { title: string; content: string } }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}
 
export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
 
  return {
    paths: posts.map((post: { slug: string }) => ({ params: { slug: post.slug } })),
    fallback: 'blocking',
  };
};
 
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await fetch(`https://api.example.com/posts/${params?.slug}`).then((res) =>
    res.json()
  );
 
  return {
    props: { post },
    revalidate: 600,
  };
};

이 방식은 여전히 명확합니다. 다만 특징이 있습니다.

  • 렌더링 전략이 페이지 함수에 묶여 있고
  • 데이터 패칭 규칙도 페이지 단위로 결정됩니다

App Router의 사고방식

App Router에서는 렌더링 전략을 페이지 함수 이름으로 나누지 않고, 서버 컴포넌트의 fetch와 세그먼트 설정으로 표현합니다.

// app/blog/[slug]/page.tsx
export default async function BlogDetailPage({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
    next: { revalidate: 600 },
  }).then((res) => res.json());
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

이 차이는 생각보다 큽니다.

  • Pages Router: 페이지가 SSR인지 SSG인지 먼저 고른다
  • App Router: 데이터를 어떤 정책으로 가져올지 먼저 생각한다

즉, App Router는 페이지 전체보다 데이터 요청 단위의 캐시와 재검증이 더 전면에 나옵니다.

레이아웃 모델은 왜 App Router가 더 강하게 느껴질까?

Pages Router에서도 레이아웃을 못 만드는 것은 아닙니다. 다만 표준 문법이 약했습니다.

Pages Router의 레이아웃 패턴

// pages/_app.tsx
import type { AppProps } from 'next/app';
import { DashboardLayout } from '@/components/dashboard-layout';
 
export default function App({ Component, pageProps }: AppProps) {
  const getLayout =
    (
      Component as AppProps['Component'] & {
        getLayout?: (page: React.ReactElement) => React.ReactNode;
      }
    ).getLayout ?? ((page) => page);
 
  return getLayout(<Component {...pageProps} />);
}
// pages/dashboard/index.tsx
export default function DashboardPage() {
  return <div>dashboard</div>;
}
 
DashboardPage.getLayout = function getLayout(page: React.ReactElement) {
  return <DashboardLayout>{page}</DashboardLayout>;
};

이 방식은 충분히 쓸 수 있지만, 팀 규칙을 별도로 맞춰야 합니다.

App Router의 레이아웃 패턴

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard-shell">
      <aside>Sidebar</aside>
      <section>{children}</section>
    </div>
  );
}
// app/dashboard/page.tsx
export default function DashboardPage() {
  return <div>dashboard</div>;
}

이 차이의 핵심은 간단합니다.

  • Pages Router: 레이아웃 패턴을 개발팀이 설계해야 함
  • App Router: 레이아웃이 라우터 문법에 포함됨

그래서 화면 수가 많아질수록 App Router 쪽이 구조적으로 더 자연스럽게 느껴집니다.

로딩과 에러 처리도 차이가 크다

Pages Router

보통은 컴포넌트 안에서 직접 처리하거나, 상태 라이브러리에 의존합니다.

export default function TodoPage() {
  const { data, isLoading, isError } = useTodosQuery();
 
  if (isLoading) return <p>loading...</p>;
  if (isError) return <p>error...</p>;
 
  return <TodoList todos={data} />;
}

이 방식은 익숙하지만, 라우트 단위의 로딩/에러 구조와는 분리되어 있습니다.

App Router

// app/todos/loading.tsx
export default function Loading() {
  return <p>loading...</p>;
}
'use client';
 
// app/todos/error.tsx
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>{error.message}</p>
      <button onClick={() => reset()}>retry</button>
    </div>
  );
}

즉, App Router는 로딩과 에러를 컴포넌트 내부 조건문보다 세그먼트 단위 상태 파일로 다루게 만듭니다.

이 점은 큰 프로젝트에서 꽤 강력합니다.

API 라우트도 철학이 조금 다르다

Pages Router

// pages/api/todos.ts
import type { NextApiRequest, NextApiResponse } from 'next';
 
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    return res.status(200).json([{ id: '1', title: 'write article' }]);
  }
 
  if (req.method === 'POST') {
    return res.status(201).json({ id: '2', title: 'new todo' });
  }
 
  return res.status(405).end();
}

App Router

// app/api/todos/route.ts
import { NextResponse } from 'next/server';
 
export async function GET() {
  return NextResponse.json([{ id: '1', title: 'write article' }]);
}
 
export async function POST() {
  return NextResponse.json({ id: '2', title: 'new todo' }, { status: 201 });
}

문법만 보면 큰 차이가 없어 보일 수 있습니다. 하지만 결은 다릅니다.

  • Pages Router: 하나의 handler 안에서 메서드 분기
  • App Router: HTTP 메서드별 export

즉, App Router는 페이지와 마찬가지로 파일 시스템과 역할 분리를 더 강하게 가져갑니다.

서버와 클라이언트 경계는 App Router에서 훨씬 중요해진다

이 부분이 실제 마이그레이션에서 가장 큰 차이입니다.

Pages Router에서는 상대적으로 덜 드러난다

페이지 컴포넌트는 결국 브라우저에서 hydration 되는 React 컴포넌트입니다. 서버 데이터는 props로 들어오지만, 컴포넌트 자체는 클라이언트 React 감각으로 읽는 경우가 많습니다.

export default function DashboardPage({
  initialTodos,
}: {
  initialTodos: { id: string; title: string }[];
}) {
  const [todos, setTodos] = useState(initialTodos);
 
  return <TodoList todos={todos} />;
}

App Router에서는 기본이 서버다

// app/dashboard/page.tsx
import { TodoForm } from './todo-form';
 
export default async function DashboardPage() {
  const todos = await getTodos();
 
  return (
    <>
      <TodoForm />
      <TodoList todos={todos} />
    </>
  );
}
'use client';
 
// app/dashboard/todo-form.tsx
import { useState } from 'react';
 
export function TodoForm() {
  const [title, setTitle] = useState('');
 
  return (
    <form>
      <input value={title} onChange={(event) => setTitle(event.target.value)} />
    </form>
  );
}

이 차이 때문에 App Router에서는 반드시 아래 질문을 하게 됩니다.

  • 이 컴포넌트는 서버에 둘 수 있는가
  • 브라우저 API를 쓰는가
  • 상태와 이벤트가 필요한가
  • 어디까지 서버에서 그리고 어디부터 클라이언트에 맡길 것인가

즉, App Router는 서버/클라이언트 경계를 훨씬 더 명시적으로 드러냅니다.

캐시 모델은 App Router가 더 세밀하지만, 더 어렵기도 하다

Pages Router에서는 비교적 단순했습니다.

  • getServerSideProps: 매 요청
  • getStaticProps: 빌드 시
  • revalidate: ISR

반면 App Router에서는 fetch 단위 캐시, 세그먼트 설정, 재검증이 섞입니다.

export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 300 },
  }).then((res) => res.json());
 
  return <PostList posts={posts} />;
}

이 방식의 장점은:

  • 페이지 전체가 아니라 데이터 요청 단위로 정책을 조정할 수 있다는 점입니다

반면 단점은:

  • 팀이 캐시 모델을 이해하지 못하면 왜 데이터가 안 바뀌는지 헷갈리기 쉽다는 점입니다

즉, App Router는 더 강력하지만, 운영 감각도 더 요구합니다.

같은 Todo 페이지를 두 방식으로 비교해보자

Pages Router 방식

// pages/todos.tsx
import { GetServerSideProps } from 'next';
 
type Todo = {
  id: string;
  title: string;
};
 
export default function TodosPage({ todos }: { todos: Todo[] }) {
  return (
    <section>
      <h1>Todos</h1>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </section>
  );
}
 
export const getServerSideProps: GetServerSideProps<{ todos: Todo[] }> = async () => {
  const todos = await fetch('https://api.example.com/todos').then((res) => res.json());
 
  return {
    props: {
      todos,
    },
  };
};

App Router 방식

// app/todos/page.tsx
type Todo = {
  id: string;
  title: string;
};
 
export default async function TodosPage() {
  const todos: Todo[] = await fetch('https://api.example.com/todos', {
    next: { revalidate: 60 },
  }).then((res) => res.json());
 
  return (
    <section>
      <h1>Todos</h1>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </section>
  );
}

겉으로는 App Router 쪽 코드가 더 단순해 보입니다. 하지만 그 단순함 뒤에는 전제가 있습니다.

  • 이 컴포넌트는 서버에서 실행된다
  • fetch는 캐시 정책을 가진다
  • 페이지는 서버 컴포넌트다

즉, 코드 줄 수는 줄어들 수 있지만, 개념은 더 깊어집니다.

마이그레이션할 때 가장 많이 부딪히는 지점

1. getServerSideProps를 어디로 옮겨야 하지?

가장 흔한 질문입니다.

답은 "같은 기능을 다른 이름으로 옮긴다"가 아니라, 서버 컴포넌트 안으로 사고방식을 옮긴다에 가깝습니다.

즉:

  • getServerSideProps -> 서버 컴포넌트 안 fetch
  • getStaticProps + revalidate -> fetch(..., { next: { revalidate } })
  • pages/api/* -> app/api/*/route.ts

2. _app.tsx, _document.tsx는 어떻게 되지?

대부분의 공통 구조는 app/layout.tsx로 갑니다.

즉, 전역 레이아웃과 HTML 골격을 layout.tsx 계층으로 생각해야 합니다.

3. 기존 클라이언트 패턴이 많으면 곧바로 이점이 안 보인다

프로젝트가 원래 아래 패턴에 많이 기대고 있었다면:

  • 브라우저 이벤트 중심
  • 클라이언트 상태 중심
  • React Query 중심
  • 무거운 인터랙션 UI 중심

App Router로 옮겨도 처음에는 'use client'가 많아질 수 있습니다.

이때는 "왜 이렇게 복잡하지?"라는 반응이 나오기 쉽습니다.

즉, App Router는 프로젝트 성격에 따라 이점이 크게 다르게 느껴집니다.

그래서 언제 무엇을 선택하면 좋을까?

Pages Router가 여전히 괜찮은 경우

  • 기존 프로젝트가 안정적으로 운영 중인 경우
  • getServerSideProps, getStaticProps 모델이 팀에 익숙한 경우
  • 당장 큰 구조 전환보다 기능 개발 속도가 중요한 경우
  • 이미 클라이언트 중심 패턴으로 잘 굴러가는 경우

App Router가 더 잘 맞는 경우

  • 신규 프로젝트인 경우
  • 공개 웹과 앱이 함께 있는 경우
  • 계층형 레이아웃과 상태 파일을 라우터 문법으로 관리하고 싶은 경우
  • 서버 컴포넌트와 캐시 모델을 적극적으로 활용하고 싶은 경우

즉, 선택 기준은 "최신이냐 아니냐"보다 우리 팀이 어떤 렌더링 모델을 감당하고, 어디서 이점을 얻는가에 가깝습니다.

정리하면

Pages RouterApp Router의 차이를 한 줄로 줄이면 이렇습니다.

Pages Router는 페이지 함수 중심 모델이고, App Router는 서버 컴포넌트와 세그먼트 중심 모델입니다.

그래서 둘의 차이는 단순히 폴더 이름이 아닙니다.

  • 데이터 패칭 방식이 달라지고
  • 레이아웃을 다루는 방식이 달라지고
  • 로딩과 에러를 처리하는 위치가 달라지고
  • API 라우트 문법이 달라지고
  • 무엇보다 서버와 클라이언트 경계를 보는 시선이 달라집니다

실무에서 중요한 것은 둘 중 하나를 무조건 우월하다고 보는 것이 아니라, 현재 프로젝트가 어느 모델에 더 잘 맞는지를 판단하는 것입니다.

같이 보면 좋은 글