Next.js App Router vs. Pages Router 파헤치기 3: 네비게이션과 RSC Payload
앞선 두 편에서는
- 1편: 라우팅 매칭 알고리즘 자체는 Page Router와 App Router가 거의 같다. 정규식 변환도, 매처도, trie 정렬도 같은 코드를 공유한다.
- 2편: 진짜 차이는 번들 분할에 있다. Page Router는 페이지 파일을 기준으로, App Router는
'use client'경계를 기준으로 번들을 쪼갠다.
이제 마지막 퍼즐 조각이 남았어요. 런타임에 사용자가 링크를 클릭하면 네트워크에서 실제로 무엇이 오갈까요? 이 부분을 네트워크 탭을 직접 확인하면서 파헤쳐볼게요.
이 글에서 인용한 소스 코드는 Next.js 15.5 기준이에요. 최신은 16.2까지 나왔지만 이 블로그가 쓰는 Cloudflare Pages 어댑터(
@cloudflare/next-on-pages) 호환성 때문에 15.5에 머물러 있어요. 버전이 올라가면 파일 경로나 구현 세부가 달라질 수 있으니 참고해주세요.
1. Page Router의 <Link> 동작
뷰포트 진입시 Prefetch
Page Router의 <Link>는 useIntersection 훅으로 뷰포트 진입을 감지해요. 링크가 화면에 들어오는 순간 prefetch가 시작돼요.
const [setIntersectionRef, isVisible, resetVisible] = useIntersection({
rootMargin: '200px', // 뷰포트보다 200px 넓게 잡아 화면에 들어오기 전부터 prefetch
});packages/next/src/client/link.tsx#L520-L522
rootMargin: '200px'이라 실제 뷰포트보다 200px 넉넉하게 감지해요. 사용자가 스크롤하다가 링크가 완전히 보이기 전부터 미리 prefetch가 시작하는 거예요.
뷰포트 진입이 "이 링크가 곧 보인다"는 약한 신호라면 호버나 터치는 "지금 누를 수도 있다"는 더 강한 의도예요. 그래서 priority: true로 즉시 prefetch를 시작해요.
문제는 뷰포트 진입과 호버가 같은 링크에서 겹치면 prefetch가 두 번 나갈 수 있다는 점이에요. 그래서 <Link>는 한 번 prefetch한 항목을 prefetched: Set<string>에 기록해 두고 같은 키는 무시해요. 키는 href + '%' + as + '%' + locale 조합이에요. dynamic route는 같은 href(/blog/[slug])여도 as(/blog/hello)가 다르면 다른 페이지여서 둘 다 키에 들어가요. locale은 i18n에서 같은 경로가 언어별로 다른 페이지가 되니 함께 들어가요.
_next/data/{BUILD_ID}/path.json 요청
Page Router가 실제로 prefetch하는 것은 두 가지예요.
- 데이터 JSON (
getStaticProps또는getServerSideProps결과) - 페이지 JS 청크
데이터 URL은 이런 식으로 조립돼요.
const getHrefForSlug = (path) => {
const dataRoute = getAssetPathFromRoute(
removeTrailingSlash(addLocale(path, locale)),
'.json', // getStaticProps/getServerSideProps 결과를 JSON으로 받음
);
// build ID가 URL에 박혀 있어서 배포가 바뀌면 URL이 통째로 갈리고 CDN 캐시도 자연 무효화
return addBasePath(`/_next/data/${this.buildId}${dataRoute}${search}`, true);
};packages/next/src/client/page-loader.ts#L164-L173
this.buildId는 빌드 시 생성되는 해시 값이에요. BUILD_ID 파일에 저장돼 있고 배포마다 달라져요. 실제 요청 URL은 이런 모양이에요.
/_next/data/uPM2SEyDG0F8G-QMoDsCV/blog/hello.json
Build ID가 바뀌면 URL 자체가 바뀌기 때문에 CDN 캐시를 자연스럽게 무효화할 수 있어요.
fetchRetry(dataHref, isServerRender ? 3 : 1, {
headers: Object.assign(
{},
isPrefetch ? { purpose: 'prefetch' } : {}, // DevTools에서 prefetch 요청을 식별하는 마커
isPrefetch && hasMiddleware ? { 'x-middleware-prefetch': '1' } : {},
),
method: params?.method ?? 'GET',
});packages/next/src/shared/lib/router/router.ts#L503-L513
요청 헤더에 purpose: prefetch가 붙어요. 네트워크 탭에서 이 헤더를 보면 "아 prefetch된 거구나" 하고 바로 알 수 있어요.
_app 재렌더링 없이 페이지 교체
Page Router의 라우터는 this.components 라는 맵에 페이지 컴포넌트를 캐싱해요.
this.components['/_app'] = {
Component: App as ComponentType,
styleSheets: [],
};packages/next/src/shared/lib/router/router.ts#L772-L777
네비게이션 시 새 경로의 컴포넌트만 로드하고 _app은 캐시된 인스턴스를 재사용해요. 덕분에 _app은 앱 전체에서 한 번만 마운트돼요. 이게 Page Router에서 _app에 둔 전역 상태가 페이지 전환에도 보존되는 이유예요.
따라 해보려면 production 빌드가 필요해요.
next dev에서는 prefetch가 비활성화되거든요. dev 모드는 페이지를 on-demand로 컴파일하는데 prefetch가 활성화되면 안 누를 페이지까지 빌드되어 메모리와 빌드 시간이 늘어나요. 아래 네트워크 탭 관찰은 모두next build && next start환경 기준이에요.
네트워크 탭에서 보이는 것
Chrome DevTools를 열고 네트워크 탭 필터를 Fetch/XHR로 두면 이런 식으로 보여요.
Request: GET /_next/data/uPM2SEyDG0F8G.../blog.json
Headers: purpose: prefetch
Response: Content-Type: application/json
Body: { "pageProps": { ... }, "__N_SSG": true }
그리고 별도로 JS 청크 요청도 있어요.
Request: GET /_next/static/chunks/pages/blog-[hash].js
Response: Content-Type: application/javascript
두 요청이 분리되어 있다는 점이 특징이에요.
2. App Router의 <Link> 동작
세분화된 Prefetch 전략
App Router의 prefetch는 전략이 훨씬 다양해요. prefetch prop 값과 라우트의 특성에 따라 동작이 달라져요.
| 케이스 | prefetch 대상 | 클라이언트 캐시 수명 |
|---|---|---|
Static 라우트 (prefetch="auto") | 전체 route | 앱 리로드까지 (기본) |
loading.js가 있는 Dynamic 라우트 | 레이아웃 + 첫 loading 바운더리까지 | 30초 (설정 가능) |
loading.js 없는 Dynamic 라우트 | 스킵 | - |
prefetch={true} | 강제 전체 prefetch | - |
prefetch={false} | 스킵 | - |
내부적으로 prefetch="auto"로 prefetch가 발동되면 Next-Router-Prefetch: 1 헤더가 붙어요.
RSC Payload 요청
App Router의 <Link>는 같은 URL에 특별한 헤더를 붙여서 요청을 보내요.
const headers = {
[RSC_HEADER]: '1', // 같은 URL이지만 HTML 대신 RSC payload를 달라는 신호
[NEXT_ROUTER_STATE_TREE_HEADER]: prepareFlightRouterStateForRequest(
flightRouterState, // 클라이언트가 가진 현재 트리 상태 — 서버는 바뀐 부분만 보내면 됨
options.isHmrRefresh,
),
};
if (nextUrl) {
headers[NEXT_URL] = nextUrl; // 인터셉팅 라우트에서 현재 URL 컨텍스트가 필요할 때
}packages/next/src/client/components/router-reducer/fetch-server-response.ts#L108-L134
여기에 setCacheBustingSearchParam이 URL에 _rsc=... 쿼리 파라미터를 추가해요. CDN 캐싱 키를 유니크하게 만들기 위한 장치예요.
실제 요청 예시
네트워크 탭에서 관찰되는 App Router의 prefetch는 이렇게 돼요.
Request Method: GET
Request URL: https://example.com/blog/hello?_rsc=1a2b3
Request Headers:
RSC: 1
Next-Router-State-Tree: %5B%22%22%2C%7B...
Next-Router-Prefetch: 1
Next-Url: /current-path
Response Headers:
Content-Type: text/x-component
Transfer-Encoding: chunked
Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
Page Router와 차이가 명확해요.
- URL이
_next/data/...가 아니라 실제 페이지 URL 에 쿼리만 붙었어요 - Content-Type이
application/json이 아니라text/x-component예요 Transfer-Encoding: chunked로 스트리밍이에요
3. RSC Payload는 어떻게 생겼을까
2편에서 'RSC Payload는 React Flight 프로토콜로 직렬화된 텍스트 스트림'이라고 짧게 짚고 넘어갔어요. 여기서 본격적으로 파헤쳐볼게요. text/x-component 응답의 본문이 바로 그 React Flight 프로토콜 로 인코딩되어 있어요.
기본 구조
Flight 프로토콜은 newline으로 구분된 row 기반 스트림이에요. 각 row는 이런 형식이에요.
<16진수 id>:<tag><JSON 또는 문자열 페이로드>\n
주요 태그
Flight 프로토콜에 쓰이는 주요 태그를 정리해봤어요.
| 태그 | 의미 | 예시 |
|---|---|---|
| (없음) | Model chunk: 기본 JSON 모델 | 1:["$","div",null,{...}] |
I | Import: 클라이언트 컴포넌트 레퍼런스 | 2:I["./chunks/abc.js",["chunks/page.js"],"LikeButton"] |
H | Hint: 프리로드 힌트 | 3:HL["/_next/static/css/app.css","style"] |
T | Text chunk | 4:T42,some streamed text |
E | Error chunk | 5:E{"message":"...","digest":"..."} |
P | Postpone chunk (PPR) | 7:P"postponed-data" |
그리고 row 내부에서 다른 row를 참조할 때는 $ 프리픽스를 써요.
| 토큰 | 의미 |
|---|---|
$L + id | Lazy reference (Suspense 경계) |
$@ + id | Promise |
$ | React 엘리먼트 마커 |
$D + iso | Date |
$n + number | BigInt |
$u | undefined |
실제 페이로드 예시
<div className="hero"><LikeButton likes={10} /></div> 를 직렬화하면 대략 이런 모양이 돼요.
1:I["/static/chunks/app/page-abc123.js",["static/chunks/app/page.js"],"LikeButton"]
0:["$","div",null,{"className":"hero","children":["$","$L1",null,{"likes":10}]}]
두 줄을 분석해볼게요.
- 1번 row:
I태그로 client reference를 먼저 선언해요. 배열은[청크 경로, 의존 청크, export 이름]순서예요 - 0번 row: 실제 서버 트리예요.
$L1은 "row 1을 여기에 끼워 넣어라"라는 뜻이에요
React 엘리먼트는 ["$", type, key, props, owner, debugStack, validated] 튜플로 직렬화돼요. 서버 컴포넌트는 이미 렌더링된 결과(DOM 트리 구조)가 전달되고 클라이언트 컴포넌트는 위치 표시자만 남아요.
스트리밍과 Suspense
Flight 응답은 Transfer-Encoding: chunked로 열려 있어요. Suspense의 fallback은 먼저 $L<id> 플레이스홀더로 내려간 다음 서버에서 데이터가 준비되면 뒤따라 도착하는 row가 그 자리를 채워요.
// 먼저 도착
0:["$","$Sreact.suspense",null,{"fallback":"Loading...","children":"$L2"}]
// 데이터가 준비되면 뒤따라 도착
2:["$","ul",null,{"children":[["$","li",null,{"children":"Post 1"}],...]}]
DevTools에서 Response 탭(Preview 아님)을 열어 raw 텍스트로 보면 이 row들이 순차적으로 쌓이는 걸 관찰할 수 있어요.
초기 HTML에도 같은 포맷이 들어가요
흥미로운 부분이 있어요. 최초 페이지 HTML을 열어보면 안에 Flight 데이터가 인라인되어 있어요.
<script>self.__next_f=self.__next_f||[]</script>
<script>self.__next_f.push([1,"1:I[\"./chunks/...\",...]\n"])</script>
<script>self.__next_f.push([1,"0:[\"$\",\"div\",...]\n"])</script>push([kind, data])의 첫 인자는 chunk 종류이고 두 번째가 Flight row 문자열이에요. 클라이언트 런타임이 이 배열을 읽어서 트리를 hydrate해요.
결국 서버에서 클라이언트로 가는 모든 경로에 Flight 프로토콜이 깔려 있어요. 초기 HTML 안의 인라인 스트림이든 네비게이션 시 text/x-component 응답이든 같은 포맷이에요.
4. 캐싱 계층
App Router의 캐싱은 크게 네 계층이에요. 이 구조를 이해해야 "왜 같은 페이지로 돌아왔는데 네트워크 요청이 안 나가지?" 같은 의문이 풀려요.
Router Cache (클라이언트 메모리)
브라우저 메모리에 RSC Payload를 segment 단위로 캐싱해요.
const { cache, tree, nextUrl } = state;
const matchingHead = useMemo(() => {
// 현재 트리에 매칭되는 head(메타데이터)를 캐시에서 찾음 — segment 단위로 prefetch된 결과를 그대로 재사용
return findHeadInCache(cache, tree[1]);
}, [cache, tree]);packages/next/src/client/components/app-router.tsx#L439-L443
stale time 상수가 router-reducer/prefetch-cache-utils.ts에 정의돼 있어요.
// env 변수에서 읽어 ms 단위로 노출 — 기본값은 각각 5분 / 0초
export const DYNAMIC_STALETIME_MS =
Number(process.env.__NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME) * 1000
export const STATIC_STALETIME_MS =
Number(process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME) * 1000packages/next/src/client/components/router-reducer/prefetch-cache-utils.ts#L397-L401
설정으로 조정할 수 있어요.
// next.config.ts
{
experimental: {
staleTimes: {
dynamic: 30,
static: 180,
},
},
}뒤로가기 버튼으로 이전 페이지에 돌아왔을 때 네트워크 요청 없이 즉시 렌더링되는 게 이 Router Cache 덕분이에요.
Full Route Cache (서버, 정적 경로)
generateStaticParams로 prerender된 정적 경로의 RSC Payload가 .next/server/app/... 아래에 .rsc 파일로 저장돼요. 각 정적 경로마다 HTML + RSC Payload 쌍이 만들어져요.
revalidate, revalidateTag, revalidatePath로 무효화할 수 있어요.
Data Cache (fetch 결과)
fetch(url, { next: { revalidate, tags } })로 호출한 결과를 캐싱해요. 시간 기반 revalidation이나 태그 기반 on-demand revalidation을 지원해요. 자체 호스팅 시 파일 시스템에 Vercel에서는 분산 캐시에 저장돼요.
Request Memoization (React cache)
이건 Next.js가 아니라 React의 기본 기능이에요. 단일 요청 내에서만 유효하고 동일한 인자로 호출한 함수의 결과를 중복 제거해요. fetch()는 Next.js가 자동으로 memoize해줘요.
네 계층이 서로 다른 수명과 범위를 가져요. 이 구조를 이해하면 App Router의 동작이 훨씬 명확해져요.
5. 네트워크 탭 체크리스트
직접 확인해볼 수 있는 체크리스트를 만들어봤어요. App Router 페이지에서 DevTools를 열고 순서대로 해보시면 돼요.
App Router 체크리스트
- 네트워크 탭 필터를
Fetch/XHR로 설정 <Link>가 있는 페이지를 스크롤해서 링크를 뷰포트에 진입시키기?_rsc=...쿼리가 붙은 요청이 뜨는지 확인- 해당 요청 클릭 → Request Headers에서 확인:
RSC: 1Next-Router-State-Tree: ...Next-Router-Prefetch: 1
- Response Headers에서
Content-Type: text/x-component확인 - Response 탭 (Preview 아님)을 열어 raw 텍스트 확인:
0:,1:I[,2:["$"같은 Flight row들이 보여야 해요
- 링크 클릭 → prefetch된 경우 추가 네트워크 요청이 없음을 확인
- 동적 라우트의 경우 클릭 시 풀 RSC 스트림이 새로 나가는 것 관찰
Page Router와 비교
같은 기능을 Page Router로 만든 프로젝트가 있다면 같은 흐름으로 관찰해보세요.
/_next/data/{BUILD_ID}/{path}.json요청이 잡혀요- Response는 JSON이에요
- 페이지 JS 청크는 별도 요청이에요
purpose: prefetch헤더가 붙어요
전송량 비교
두 라우터의 전송량을 비교하면 흥미로운 점이 있어요.
- Static 페이지: App Router의 RSC Payload 쪽이 일반적으로 더 컴팩트해요. HTML 중복이 없으니까요
- Dynamic 페이지 + loading.js: App Router가 훨씬 빨라요. layout과 loading 부분만 먼저 받기 때문에 TTFB가 짧아요
- 클라이언트 컴포넌트 비중이 높은 페이지: JS 청크 크기는 비슷해요. RSC는 props를 텍스트 스트림으로, Page Router는
__NEXT_DATA__JSON 블록으로 받는 차이 정도예요
6. 마무리
세 편을 거쳐 Page Router와 App Router의 차이를 살펴봤어요. 처음에 궁금했던 질문으로 돌아가볼게요.
"App Router는 Page Router보다 왜 빠를까?"
이유는 한 가지가 아니에요. 세 편에서 확인한 지점들을 모아 정리하면 이렇게 답할 수 있어요.
- 라우팅 매칭 자체는 거의 같다. 빠르고 느림의 차이가 여기서는 거의 없어요.
- 번들 분할 방식이 다르다. App Router는
'use client'덕분에 서버 컴포넌트 코드가 클라이언트 번들에 포함되지 않아요. 초기 고정 비용은 크지만 페이지를 잘 설계하면 전체 번들 크기는 더 작아질 수 있어요. - 네비게이션 포맷이 다르다. Page Router는 데이터 JSON과 JS 청크를 따로 받지만 App Router는 RSC Payload 하나로 서버 렌더링 결과와 클라이언트 컴포넌트 레퍼런스를 함께 스트리밍해요. 특히
loading.js와 함께 쓰면 TTFB가 눈에 띄게 개선돼요.
App Router가 무조건 옳을까
꼭 그렇지는 않아요. App Router를 제대로 활용하려면 몇 가지 주의가 필요해요.
'use client'를 상위 컴포넌트에 무심코 달면 그 아래 트리 전체가 클라이언트 번들에 포함돼요. 최대한 말단에 두는 게 좋아요- 서버 컴포넌트와 클라이언트 컴포넌트의 설계를 적절히 해야 해요. props로 뭘 넘길 수 있고 뭘 못 넘기는지, 언제 children으로 넘겨야 하는지 같은 패턴을 이해해야 해요
- 초기 번들 크기 자체는 App Router가 더 무거워요. 간단한 랜딩 페이지 같은 경우에는 Page Router가 오히려 가벼울 수 있어요
소스 코드를 읽은 후기
세 편을 쓰면서 Next.js 소스 코드를 파헤칠 수 있는 기회가 되었고 의문을 해결할 수 있었어요. 가장 크게 느낀 점은 추상화 계층이 잘 짜여져 있다 는 거예요.
예를 들어 라우팅 매처는 Page Router와 App Router가 같은 코드를 공유해요. 두 라우터를 구현한다고 해서 완전히 다른 시스템을 만들지 않았어요. 공통된 부분은 공유하고 다른 부분만 특화했죠. 번들 분할도 마찬가지예요. webpack이라는 같은 기반 위에 로더와 플러그인을 다르게 조합했을 뿐이에요.
'새로운 메이저 버전은 완전히 새로운 시스템이다'라고 생각하기 쉽지만 실제로는 기존 시스템 위에 레이어를 쌓은 경우가 많다는 걸 확인했어요. 프레임워크를 쓰는 입장에서도 이런 연속성을 이해하고 있으면 마이그레이션이나 디버깅에서 도움이 될 거라고 생각해요.
글 읽어주셔서 감사해요. 두 라우터의 차이를 이해하시는 데 도움이 되었길 바라요.


