
Next.js 13에서 도입된 App Router는 React 18의 Server Components를 활용해 기존 Page Router 대비 상당한 성능 향상을 제공한다. 가장 큰 차이점은 서버와 클라이언트의 역할 분담이 명확해지면서 번들 크기 최적화와 렌더링 효율성이 크게 개선된 것이다.
getServerSideProps, getStaticProps로만 처리 가능'use client' 필요)async/await 사용 가능.next/
├── server/ # 서버 번들 (Node.js 환경)
│ ├── app/
│ │ └── page.js # Server Components 코드
│ └── chunks/
└── static/ # 클라이언트 번들 (브라우저로 전송)
└── chunks/
└── client.js # Client Components만 포함
기존 SSR과 달리 App Router는 클라이언트로 HTML이 아닌 직렬화된 React Element Tree를 전송한다.
// Server Component - 서버에서만 실행
async function ProductList() {
const products = await db.getProducts();
return (
<div>
{products.map(product => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>{product.price}</p>
</div>
))}
</div>
);
}
생성되는 RSC Payload:
M1:{"id":"./src/app/products/page.js","chunks":["app/products/page"],"name":""}
0:["$","div",null,{"children":[
["$","div",null,{"children":[
["$","h3",null,{"children":"iPhone 15"}],
["$","p",null,{"children":"$999"}]
],"key":"1"}],
["$","div",null,{"children":[
["$","h3",null,{"children":"Samsung Galaxy"}],
["$","p",null,{"children":"$899"}]
],"key":"2"}]
]}]
server component + server component 또한 플레이스홀더 없이 RSC Payload가 생성된다.
// Server Component
async function HomePage() {
const posts = await fetchPosts();
return (
<div>
<h1>Posts</h1>
{posts.map(post => <div key={post.id}>{post.title}</div>)}
<ClientButton count={5} /> {/* Client Component */}
</div>
);
}
생성되는 RSC Payload:
M1:{"id":"./src/app/page.js","chunks":["app/page"],"name":""}
M2:{"id":"./src/components/ClientButton.js","chunks":["components/ClientButton"],"name":"ClientButton"}
0:["$","div",null,{"children":[
["$","h1",null,{"children":"Posts"}],
["$","div",null,{"children":"React 18 Guide"},"key":"1"],
["$","div",null,{"children":"Next.js Tutorial"},"key":"2"],
["$","$M2",null,{"count":5}] // ← Client Component 플레이스홀더
]}]
Client Component 처리:
$M2)와 props만 포함M2 모듈(ClientButton)을 로드하여 해당 위치에 렌더링Page Router의 문제:
// 모든 코드가 클라이언트 번들에 포함
import heavyDataProcessor from 'heavy-lib'; // 📦 클라이언트 번들에 포함
import { dbUtils } from 'database-utils'; // 📦 클라이언트 번들에 포함
export default function Products({ products }) {
const processed = heavyDataProcessor(products); // 🖥️ 클라이언트에서 실행
return <div>{/* 렌더링 */}</div>;
}
App Router의 해결:
// Server Component - 무거운 코드는 서버에만
import heavyDataProcessor from 'heavy-lib'; // ❌ 클라이언트 번들에 포함 안됨
import { dbUtils } from 'database-utils'; // ❌ 클라이언트 번들에 포함 안됨
export default async function Products() {
const products = await fetchProducts(); // 🖥️ 서버에서만 실행
const processed = heavyDataProcessor(products); // 🖥️ 서버에서만 실행
return (
<div>
{processed.map(item => <Item key={item.id} data={item} />)}
<ClientButton /> {/* 필요한 인터랙션만 클라이언트에서 */}
</div>
);
}
| 항목 | Page Router | App Router |
|---|---|---|
| 서버 번들 | A + B + C + D | A + B (서버 코드만) |
| 클라이언트 번들 | A + B + C + D | C + D (클라이언트 코드만) |
| 브라우저 다운로드 | 전체 번들 | 클라이언트 번들 + RSC Payload |
| 코드 중복 | 있음 | 없음 |
Page Router (기존 SSR):
1. 서버에서 HTML 생성
2. 클라이언트로 HTML + 전체 JavaScript 번들 전송
3. 하이드레이션으로 전체 페이지를 다시 연결
App Router (RSC):
1. 서버에서 Server Components 실행
2. 직렬화된 React Element Tree + 필요한 Client Components만 전송
3. Server Component 부분은 하이드레이션 없이 Virtual DOM에 직접 삽입
4. Client Component만 선택적 하이드레이션
아주 간단하게 내용을 정리하면
서버 컴포넌트는 RSC Payload로 결과만 전달하므로, 브라우저가 서버 로직까지 다운로드할 필요가 없다는 점이다.
이처럼 App Router는 단순한 라우팅 방식의 변화가 아니라, 서버와 클라이언트의 역할을 근본적으로 재정의한 아키텍처이다.
핵심은 "같은 기능, 더 적은 클라이언트 번들"이다. 총 코드량은 비슷하지만 브라우저가 다운로드해야 하는 JavaScript의 양을 크게 줄임으로써, 초기 로딩 속도와 전반적인 사용자 경험을 개선한다.