NextJS Browser Rendering 과정
Next.js 환경에서 개발하다 보면 화면이 어떻게 그려지고, 상호작용(이벤트 핸들러 등)이 어떻게 연결되는지 헷갈릴 때가 많습니다. 특히 기존의 Page Router와 새로운 App Router는 브라우저가 화면을 렌더링하고 React 트리를 활성화(하이드레이션)하는 방식에서 완전히 다른 패러다임을 가지고 있습니다.
이번 글에서는 두 라우터가 초기 접속(Cold Load) 시점과 페이지 이동(SPA Navigation) 시점에 브라우저 내부에서 각각 어떻게 동작하는지 파헤쳐 보겠습니다.
1. Page Router: 통째로 받고, 통째로 깨우기 (전체 하이드레이션)
Page Router의 렌더링 방식은 "페이지 하나 = 완성된 HTML 하나 + 전체 React 트리" 라는 공식을 따릅니다.
① 초기 접속 시 (Initial Load)
- 대기와 HTML 완성: 사용자가 접속하면 서버는
getServerSideProps등을 실행해 데이터를 모두 가져올 때까지 기다립니다. 이후 완성된 단일 HTML을 한 번에 브라우저로 전송합니다. - 데이터 매립: 서버에서 가져온 JSON 데이터는 브라우저가 쓸 수 있도록 HTML 하단의
<script type="application/json" id="__NEXT_DATA__">태그 안에 통째로 박아 넣습니다. - 전체 하이드레이션 (Full Hydration): 브라우저가 HTML을 화면에 그리고 나면 자바스크립트 번들이 로드됩니다. 이때 React는 페이지 컴포넌트 전체(
_app부터 말단 컴포넌트까지)를 대상으로 함수를 다시 실행(Hydrate)하여 이벤트를 붙입니다. 즉, 렌더링에 관여하는 모든 코드가 클라이언트 JS 번들에 포함되어야 합니다.
② 페이지 이동 시 (SPA Navigation)
Page Router에서 <Link>를 클릭하면 화면이 하얗게 깜빡이는 전체 새로고침은 일어나지 않습니다. 하지만 내부적으로는 사실상 새로운 페이지를 처음부터 다 그려내는 수준의 무거운 작업이 발생합니다.
- 두 번의 네트워크 요청: 새 페이지를 그리기 위한 JS 청크 파일과, 서버에서 실행된 데이터 결과물(
_next/data/.../[page].json)을 별도로 요청해서 받아옵니다. - 기존 화면 파기 및 새로 생성 (Unmount & Mount): 데이터가 도착하면 React는 공통 껍데기인
_app.tsx정도만 남겨두고, 기존 페이지에 있던 **모든 컴포넌트 트리를 완전히 파기(Unmount)**시킵니다. 그리고 방금 받아온 데이터를 집어넣어 새 페이지 트리를 처음부터 끝까지 새로 생성(Mount) 합니다. - 상태(State) 초기화: 기존 트리를 통째로 부수고 새로 짓는 방식이므로, 이전 페이지에서 입력해둔 검색어나 스크롤 위치 같은 로컬 상태들이 보존되지 않고 전부 날아갑니다.
💡 비유하자면: Page Router의 이동은 "기존 집을 다 허물고, 새 자재(JSON)를 가져와서, 새 집을 통째로 다시 짓는 연산" 과 같습니다.
2. App Router: 조각조각 흘려보내고, 필요한 곳만 깨우기 (부분 하이드레이션)
App Router는 React Server Components(RSC)와 스트리밍(Streaming) 아키텍처를 기반으로 동작합니다. 들어오는 대로 화면을 채워 넣는 '라이브 뉴스 방송' 방식입니다.
① 초기 접속 시 (Streaming & Inline Flight)
- 정적 셸(Static Shell) 우선 전송: 서버는 무거운 데이터 페칭을 끝까지 기다리지 않습니다. 헤더나 사이드바 같은 뼈대(Shell) HTML을 브라우저에 즉시 스트리밍하여 빈 화면을 보는 시간을 줄입니다.
- 두 개의 스트림 병렬 전송: HTML 소스 코드 하단에는
<script>self.__next_f.push([...])</script>형태로 **Flight 페이로드(직렬화된 React 트리 명세서)**가 잘게 쪼개져서 인라인으로 함께 도착합니다. - 콘텐츠 치환: 대기 중이던 서버 데이터가 준비되면, 서버는 추가 청크를 응답 스트림에 덧붙여 보냅니다. 브라우저는 이를 받아 로딩 스피너 자리를 실제 콘텐츠로 즉시 교체합니다.
② 부분 하이드레이션 (Partial Hydration)
가장 극적인 차이는 하이드레이션 과정입니다. App Router에서는 서버 컴포넌트의 자바스크립트 코드가 클라이언트 번들에 아예 포함되지 않습니다.
- 브라우저의 React는 도착한 Flight 스트림을 읽어 트리를 재구성합니다.
- 이때 서버 컴포넌트가 있던 자리는 이미 서버에서 평가가 끝난 단순한 엘리먼트로 취급하여 함수를 다시 실행하지 않고 건너뜁니다.
- 오직 Flight 데이터 안에 마커처럼 박혀 있는 클라이언트 컴포넌트 참조(
I[...]) 위치에서만 동적으로 모듈을 불러와 훅(Hooks)과 이벤트를 연결하는 부분 하이드레이션을 수행합니다. 다운로드하고 실행해야 할 JS 연산 비용이 획기적으로 줄어듭니다.
③ 페이지 이동 시 (SPA Navigation) ✨ 모듈식 부분 교체
사용자가 <Link>를 클릭하면 App Router는 Page Router의 무거운 작업을 하지 않습니다.
- Flight 데이터 단독 요청: 브라우저는 전체 HTML을 요청하지 않고, URL 뒤에
?_rsc=...를 붙여 변경된 세그먼트의 Flight 데이터(직렬화된 RSC 트리)만을 별도로 요청합니다. - 라우터 캐시 병합 및 레이아웃 보존: 서버에서 날아온 이 'Flight'를 클라이언트 메모리상의 라우터 캐시(CacheNode 트리) 해당 위치에 끼워 넣듯이 병합합니다.
- 기존 트리를 통째로 파기하던 Page Router와 달리, 변경되지 않은 공통 부모 레이아웃은 그대로 둡니다. 덕분에 컴포넌트가 언마운트되지 않아 사이드바의 스크롤 위치나 재생 중인 비디오가 매끄럽게 보존됩니다.
결론
| 렌더링/하이드레이션 단계 | Page Router (기존 방식) | App Router (RSC 기반) |
|---|---|---|
| 초기 HTML 전송 | 데이터 로딩이 다 끝난 후 완성된 HTML 한 덩어리 전송 | 정적 셸(Shell) HTML 먼저 전송 후 준비되는 대로 콘텐츠 스트리밍 |
| 데이터 전달 방식 | <script id="__NEXT_DATA__">에 통짜 JSON 매립 | self.__next_f.push를 통한 Flight 페이로드 인라인 스트리밍 |
| 클라이언트 JS 번들 | 렌더링에 관여하는 모든 페이지 컴포넌트 코드 포함 | 'use client'가 선언된 클라이언트 컴포넌트 코드만 포함 |
| 하이드레이션 범위 | 페이지 컴포넌트 트리 전체 재실행 | 상호작용이 필요한 클라이언트 컴포넌트만 선택적 실행 |
| 페이지 이동 메커니즘 | (무거움) 기존 트리 전체 파기(Unmount) → 새 JSON 수신 → 새 트리 전체 마운트 | (가벼움) 변경된 세그먼트의 RSC 페이로드 수신 → 공통 레이아웃 유지하며 캐시 트리에 부분 병합 |
단순한 정적 페이지 위주의 사이트라면 구조가 직관적인 Page Router도 여전히 좋은 선택입니다. 하지만 화면 곳곳에서 비동기 데이터 갱신이 일어나고, 클라이언트의 자바스크립트 연산 부담을 줄이면서 매끄러운 레이아웃 상태 보존이 필요한 모던 웹 애플리케이션이라면, RSC와 부분 하이드레이션으로 무장한 App Router가 성능상 압도적인 이점을 가져다줄 것입니다.