Next.js Page Router vs App Router 어떻게 다를까?
Next.js App Router로 전환하면서 가장 혼란스러운 부분은 렌더링 방식의 변화입니다.
Page Router에서 SSR/SSG 기반 렌더링을 사용했다면, App Router에서는 RSC+Client Component 기반 렌더링을 사용하도록 변경되었습니다.
이 글에서는 두 방식의 동작 원리를 비교하고, App Router는 Page Router의 개선 버전이 아닌 서로 다른 패러다임을 가진 Next.js의 렌더링 방식임을 설명하려고 합니다.
Next.js 15부터 fetch()의 기본 캐싱 동작이 변경되었습니다.
- Next.js 14:
fetch()가 기본적으로 캐시됨 (cache: 'force-cache'가 기본값) - Next.js 15:
fetch()가 기본적으로 캐시되지 않음 (cache: 'no-store'가 기본값)
단, 이 글에서 다루는 핵심 내용(RSC vs Client Component의 렌더링 패러다임, JS 번들 전송 여부, Hydration 동작)은 버전과 관계없이 동일합니다.
1. Page Router: 페이지 단위 렌더링
Page Router에서는 getServerSideProps(SSR)와 getStaticProps(SSG)라는 함수로 데이터를 fetching하고, 페이지 전체를 서버에서 렌더링합니다.
이 두 개의 특수 함수는 Page 파일에서만 사용할 수 있습니다. Component 단위에서는 렌더링 결정 함수를 사용하지 못하게 하는 포인트에서 페이지 단위로 렌더링 방식을 결정하는 Next.js의 철학을 느낄 수 있습니다.
만약 이런 특수 함수를 사용하지 않는다면 기본 SSG로 페이지를 생성합니다.
SSR (getServerSideProps)
// pages/product/[id].tsx
export async function getServerSideProps(context) {
const { id } = context.params;
const product = await fetchProduct(id);
return {
props: { product },
};
}
export default function ProductPage({ product }) {
return (
<div>
<Header />
<ProductInfo product={product} />
<AddToCartButton product={product} />
<Reviews productId={product.id} />
</div>
);
}
동작 흐름:
- 페이지 요청이 들어오면
getServerSideProps실행 - 반환된 props로 전체 페이지 HTML 생성
- HTML + 전체 JavaScript 번들을 클라이언트로 전송
- 클라이언트에서 Hydration (이벤트 핸들러 연결)
SSG (getStaticProps)
// pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
const post = await fetchPost(params.slug);
return { props: { post } };
}
export async function getStaticPaths() {
const posts = await fetchAllPosts();
return {
paths: posts.map((post) => ({ params: { slug: post.slug } })),
fallback: false,
};
}
동작 흐름:
- 빌드 타임에
getStaticProps를 실행 - 반환된 props로 빌드 타임에 HTML 생성
- 페이지 요청이 들어오면 빌드 타임에 생성된 HTML 그대로 반환
- 클라이언트에서 Hydration (이벤트 핸들러 연결)
Page Router의 특징
- SSR/SSG 여부와 관계없이 페이지 내 모든 JS 번들이 클라이언트로 전달됩니다. Page Router에서는 컴포넌트 단위로 Hydration을 제어할 수 없기 때문에, 페이지 전체가 하나의 Hydration 단위로 처리됩니다.
- 기본적으로 모든 컴포넌트가 Hydration을 수행하기 때문에 컴포넌트에서 서버 로직을 사용할 수 없습니다.
- 페이지 단위로 렌더링 방식이 결정됩니다. SSR이라면 페이지 전부를 요청 시 만들고, SSG라면 페이지 전부를 빌드 시점에 만들어둡니다.
2. App Router: 컴포넌트 단위 렌더링
App Router에서는 컴포넌트 단위로 렌더링 방식이 결정됩니다. 기본은 RSC(React Server Component)입니다.
별도의 지시어를 사용하지 않으면 RSC로 렌더링되며, "use client" 지시어를 추가하면 Client Component로 렌더링됩니다.
단, Client Component에서 static import로 포함되는 하위 컴포넌트는 지시어 여부와 관계없이 Client Component로 렌더링됩니다. RSC를 Client Component의 children prop으로 전달하면 이 제약을 우회할 수 있습니다.
RSC (React Server Component)
// app/product/[id]/page.tsx
async function ProductInfo({ id }: { id: string }) {
// 서버에서만 실행 - DB 직접 접근 가능
const product = await db.product.findUnique({ where: { id } });
// 서버 전용 라이브러리 사용 가능 (번들에 미포함)
const description = await marked(product.rawDescription);
return (
<div>
<h1>{product.name}</h1>
<div dangerouslySetInnerHTML={{ __html: description }} />
</div>
);
}
동작 흐름:
- 서버에서 컴포넌트 코드를 실행하고 RSC Payload로 변환합니다. (정적 렌더링이면 빌드 시점, 동적 렌더링이면 요청 시점)
- RSC Payload가 클라이언트에 전달됩니다. JS 번들은 전달되지 않고, Hydration도 발생하지 않습니다.
Client Component
상태 관리, 이벤트 핸들러, 브라우저 API가 필요한 컴포넌트는 명시적으로 Client Component로 지정합니다.
// components/AddToCartButton.tsx
"use client";
import { useState } from "react";
export function AddToCartButton({ productId }: { productId: string }) {
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
await addToCart(productId);
setIsAdding(false);
};
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? "추가 중..." : "장바구니에 추가"}
</button>
);
}
동작 흐름:
- 서버에서 초기 HTML을 렌더링합니다. (정적 페이지면 빌드 시점, 동적 페이지면 요청 시점)
- HTML과 해당 컴포넌트의 JS 번들이 클라이언트로 전달됩니다.
- 클라이언트에서 Hydration을 수행합니다.
App Router의 특징
- RSC는 결과값만 RSC Payload로 전달되고 JS 번들은 전송되지 않습니다. Hydration이 발생하지 않습니다.
- Client Component는 RSC 렌더링 과정에서 초기 HTML이 생성되며, 해당 컴포넌트의 JS 번들과 함께 클라이언트로 전달되어 Hydration이 발생합니다.
- 컴포넌트 단위로 렌더링 방식이 결정됩니다.
- 단, Next.js는 페이지 단위 캐싱과 fetch 단위 캐싱 등 여러 캐시 레이어를 관리하며, 이 설정에 따라 RSC가 언제 실행되고 어떤 데이터가 재사용되는지가 결정됩니다.
- 예:
cookies(),searchParams같이 클라이언트 호출에 의해 동적으로 결정되는 값을 RSC에서 사용하고 있다면 페이지를 항상 dynamic route로 설정해 매번 RSC를 새로 실행합니다.
- 예:
RSC Payload와 Flight 프로토콜
Server Component의 렌더링 결과는 HTML이 아닌 RSC Payload라는 특별한 형식으로 전송됩니다.
RSC Payload는 React 팀이 개발한 Flight 프로토콜로 직렬화된 데이터입니다. 클라이언트의 React가 이를 파싱하여 UI를 구성합니다.
// RSC Payload 예시 (간소화)
0:["$","div",null,{"children":[
["$","h1",null,{"children":"상품명"}],
["$","p",null,{"children":"상품 설명..."}],
["$","$L1",null,{"productId":"123"}]
]}]
// $L1은 Client Component에 대한 참조
// 실제 코드는 별도 JS 번들로 로드됨
특징
$L1: Client Component에 대한 참조. 실제 코드는 별도 JS 번들로 로드됨- Server Component 결과는 이미 렌더링된 상태로 포함
- Promise, 순환 참조 등 JSON보다 풍부한 타입 지원
Next.js의 초기 로드 vs 클라이언트 네비게이션
초기 페이지 로드
서버 → 완성된 HTML + RSC Payload (+ 필요시 Client Component JS) → 클라이언트
- SEO를 위해 완성된 HTML 제공
- RSC Payload는 초기 로드 이후의 UI 업데이트와 클라이언트 사이드 네비게이션을 위해 사용됩니다.
클라이언트 사이드 네비게이션
서버 → RSC Payload (+ 필요시 Client Component JS) → 클라이언트
- HTML 없이 RSC Payload 전송
- RSC Payload에는 Client Component에 대한 참조가 포함됨
- 해당 Client Component JS가 이미 로드되어 있으면 캐시 사용, 아니면 lazy load
- React가 기존 UI와 병합하여 업데이트
3. App Router의 캐싱 레이어
App Router에서 RSC가 언제 실행되는지는 Next.js의 캐싱 전략에 따라 결정됩니다.
정적 렌더링 (기본값)
별도 설정이 없으면 빌드 시점에 RSC가 실행되고, 결과가 캐싱됩니다.
// 빌드 시점에 한 번만 실행됨
async function StaticPage() {
const data = await fetchData();
return <div>{data}</div>;
}
동적 렌더링
cookies(), headers(), searchParams 등 요청 시점에만 알 수 있는 값을 사용하면 자동으로 동적 렌더링됩니다.
import { cookies } from "next/headers";
// 매 요청마다 실행됨
async function DynamicPage() {
const token = cookies().get("token");
const data = await fetchUserData(token);
return <div>{data}</div>;
}
캐싱 제어
// ISR - 지정된 시간 동안 캐싱
export const revalidate = 3600; // 1시간
// 강제 동적 렌더링
export const dynamic = "force-dynamic";
// 강제 정적 렌더링
export const dynamic = "force-static";
4. 핵심 차이점 정리

| 구분 | Page Router (SSR/SSG) | App Router (RSC) |
|---|---|---|
| 렌더링 단위 | 페이지 전체 | 컴포넌트 단위 |
| 데이터 fetching | getServerSideProps 등 특수 함수 | 컴포넌트 내 async/await |
| JS 번들 | 모든 컴포넌트 코드 전송 | Client Component만 전송 |
| 서버 라이브러리 | 번들에 포함됨 | 번들에 미포함 |
| 네비게이션 | 전체 페이지 리로드/Hydration | RSC Payload로 부분 업데이트 |
| 클라이언트 상태 | 페이지 전환 시 리셋 | 유지 가능 |
| Suspense | 제한적 지원 | 서버 스트리밍과 완전 통합 |
결론
Page Router의 SSR/SSG와 App Router의 RSC는 "서버에서 렌더링한다"는 표면적 유사성만 있을 뿐, 근본적으로 다른 모델입니다.
| 관점 | Page Router | App Router |
|---|---|---|
| 사고 단위 | 페이지 전체 | 컴포넌트 |
| 데이터 위치 | 최상단 (getServerSideProps) | 필요한 곳에서 직접 |
| 번들 크기 | 전체 컴포넌트 포함 | Client Component만 |
| 복잡도 | 단순하고 예측 가능 | 유연하지만 캐싱 이해 필요 |
언제 무엇을 선택할까?
Page Router가 적합한 경우:
- 대부분의 컴포넌트가 상태 관리나 이벤트 핸들러를 필요로 할 때
- 팀이 전통적인 SSR/SSG 패턴에 익숙할 때
- 단순하고 예측 가능한 렌더링 모델을 선호할 때
App Router가 적합한 경우:
- 대규모 서버 라이브러리(예: markdown 파서, 이미지 처리)를 사용하지만 번들 크기를 줄이고 싶을 때
- 컴포넌트별로 독립적인 데이터 fetching이 필요할 때
- Streaming과 Suspense를 활용한 점진적 로딩이 필요할 때
- 정적/동적 렌더링을 컴포넌트 단위로 세밀하게 제어하고 싶을 때
결국 두 방식은 트레이드오프가 다릅니다. Page Router는 단순함과 예측 가능성을, App Router는 유연함과 최적화 가능성을 제공합니다. 프로젝트의 특성과 팀의 상황에 맞게 선택하면 됩니다.