Next.js 16.3 Instant Navigation - Deep Dive 1

클릭했는데 왜 아무 일도 안 일어나는가

Next.js 16.3이 8월 3일에 배포되었다.

이번에 stable이 된 기능 중 관심이 있던 Instant Navigation에서 흥미로운 포인트들이 있어서 탐구했던 사고과정들을 기록하는 글이다.


느린 네트워크에서 탭을 하나 눌렀다. 그 뒤 약 0.9초 동안 화면에는 아무 변화가 없었다.

스피너도 없었고, 로딩 바도 없었다. 눌린 탭이 바뀌지도 않았다. 그런데 같은 라우트를 새로고침으로 열면 약 0.5초 만에 로딩 UI가 나타났다. 완성된 화면이 뜨는 데 걸린 시간이 아니라, 클릭이 화면에 처음 반영되기까지의 시간이 달랐다.

처음에는 단순히 서버 응답이 느린 문제처럼 보인다. 하지만 같은 서버와 같은 지연을 두고 진입 방식만 바꿨는데 결과가 달라졌다. 더 이상 네트워크 하나로는 설명되지 않았다.

이 글은 그 차이를 따라간 기록이다. SPA와 MPA는 클릭 뒤 다음 화면을 어떻게 준비하는지, Pages Router는 둘 사이에서 무엇을 가져왔는지, App Router와 Server Components가 그 전제를 어떻게 바꿨는지부터 시작한다. 그다음 Next.js 16.3이 왜 링크별 프리페치 대신 라우트별 App Shell을 만들었는지 소스와 PR을 따라가 본다.

먼저 범위를 분명히 해두고 싶다. 16.3은 App Router의 모든 느린 네비게이션을 자동으로 고치는 기능이 아니다. 내가 본 0.9초의 정지에는 서로 다른 원인이 섞여 있었고, 16.3은 그중 하나를 다룬다.

실험 환경

  • next@16.3.0, 커밋 d73f5622e2
  • next build && next start
  • Chromium, Slow 4G(400kbps, RTT 400ms)
  • 각 항목 3회 반복 후 중앙값
  • DOM 변화는 MutationObserver, 화면 변화는 CDP Page.startScreencast가 전달한 프레임으로 각각 측정

Next.js는 개발 모드에서 실제 프리페치를 수행하지 않는다. 그래서 프리페치가 포함된 비교는 프로덕션 빌드에서 진행했다.

이 글에서 다루는 설정의 위치와 역할은 다음과 같다.

API 위치 16.3에서의 역할
cacheComponents next.config 최상위 정식 opt-in. PPR과 use cache를 포함한 렌더링·캐시 모델을 켠다
partialPrefetching next.config 최상위 16.3에서 추가된 정식 opt-in. 기본 프리페치 단위를 App Shell로 바꾼다
export const instant 라우트 세그먼트 네비게이션이 즉시 UI를 낼 수 있는지 검증할 기대값을 선언한다
experimental.instantInsights.validationLevel experimental 아래 개발 중 검증 범위를 조절하는 실험 설정이다

instant는 네비게이션을 빠르게 만드는 스위치가 아니다. instant = true는 검증을 켜고, instant = false는 해당 세그먼트가 막혀도 된다고 표시한다. 실제 화면을 즉시 만들려면 캐시되었거나 Suspense fallback으로 스트리밍할 수 있는 UI가 있어야 한다.

측정 재현성을 위해 지연은 전부 고정값이다. 랜덤이 없다.

typescript
// lib/slow.ts
export async function slowFetch<T>(value: T, ms: number): Promise<T> {
  await new Promise((r) => setTimeout(r, ms))
  return value
}

export const LINK_DATA_MS= 800     // 링크마다 달라지는 데이터 (params 의존)
export const SESSION_DATA_MS= 300  // 방문자마다 달라지는 데이터 (cookies 의존)
export const CACHED_DATA_MS= 1200  // 캐시 대상 데이터
export const LINK_COUNT= 20        // 목록에 나열할 링크 수

LINK_DATA_MS = 800을 기억해 두면 좋다. 뒤에 나오는 818ms 같은 수치가 “데이터를 전부 기다렸다”는 뜻인지 바로 알아볼 수 있다.

폴더 구조

shell
instant-nav-lab/

├── app/                            # App Router 실험 케이스
   ├── page.tsx                    # baseline: 동일 라우트 링크 20개
   ├── chat/[id]/page.tsx          # case: page-level Suspense
   ├── tabs/
   ├── layout.tsx              # case: layout-level Suspense
   └── [tab]/page.tsx          # case: no boundary
   ├── cached/[id]/page.tsx        # case: use cache
   ├── blocked/[id]/page.tsx       # case: blocked streaming
   ├── mixed/[id]/page.tsx         # case: cookie + params dependency
   └── static/[id]/page.tsx        # case: generateStaticParams

├── lib/
   └── slow.ts                     # artificial delay

├── config/                         # Next.js profiles (A/B/C)

├── scripts/
   ├── use-profile.mjs             # switch → clean build → verify logs
   └── serve.mjs                   # production runner

└── measure/                        # performance measurement
    ├── prefetch-counts.mjs         # prefetch request count
    ├── ttfc.mjs                    # click → first DOM mutation
    └── frames.mjs                  # frame capture via CDP

실험 페이지 구성

링크 20개짜리 목록

홈은 /chat/[id]로 가는 링크 20개다. 같은 라우트를 향하는 링크가 많은 화면 — 채팅방 목록, 피드, 검색 결과 같은 것을 최소한으로 흉내낸 것이다.

typescript
// app/page.tsx
{ROOM_IDS.map((id) => (
  <li key={id}>
    <Link href={`/chat/${id}`}>Chat{id}</Link>
  </li>
))}

실험용 홈 화면. 같은 라우트를 향한 Chat 1~20 링크와 맨 아래 다른 라우트 링크 다섯 개

맨 아래 blocked/7 cached/7 mixed/7 static/7 tabs/a는 다른 라우트로 가는 링크다.

각 라우트당 하나씩만 뒀다. 여러 개 두면 프리페치 요청 수 집계가 오염되기 때문이다.

실험이니 스타일은 전혀 안 썼다.

경계 위치

7개 라우트 중 이 글의 주인공은 둘이다. 그리고 둘의 차이는 <Suspense>의 위치 하나뿐이다.

/chat/[id]

경계가 페이지 안에 있다

typescript
// app/chat/[id]/page.tsx
export default function ChatPage(props: PageProps<'/chat/[id]'>) {
  return (
    <main>
      <h1>Chat</h1>
      <SiblingLinks base="/chat" ids={[1, 2, 3]} />
      <Suspense fallback={<p>Loading messages…</p>}>   {/* ← 페이지 안 */}
        <Messages params={props.params} />
      </Suspense>
    </main>
  )
}

/tabs/[tab]

경계가 공유 레이아웃에 있다

typescript
// app/tabs/layout.tsx
<h1>Tabs</h1>
<nav><Link href="/tabs/a">Tab A</Link> <Link href="/tabs/b">Tab B</Link></nav>
<Suspense fallback={<p>Loading tab…</p>}>   {/* ← 레이아웃 */}
  {children}
</Suspense>

// app/tabs/[tab]/page.tsx — 경계 없음
export default async function TabPage(props: PageProps<'/tabs/[tab]'>) {
  const { tab} = await props.params
  const data= await slowFetch(`tab${tab} content`, LINK_DATA_MS)
  return <p>{data}</p>
}

트리로 그리면 차이가 한눈에 들어온다.

두 라우트의 경계 위치

flowchart TB
  subgraph C["/chat/[id] · 경계가 페이지 안"]
    direction TB
    C1["layout"] --> C2["page"] --> C3["Suspense 경계"] --> C4["Messages<br/>params 의존 · 800ms"]
  end
  subgraph T["/tabs/[tab] · 경계가 공유 레이아웃"]
    direction TB
    T1["layout"] --> T2["Suspense 경계"] --> T3["page"] --> T4["TabPage<br/>params 의존 · 800ms"]
  end

두 라우트 모두 <Suspense>가 있고, 두 라우트 모두 800ms 지연이 있다. 그러니 새로고침으로 열면 둘 다 같은 방식으로 동작한다.

fallback이 떴다가 콘텐츠로 바뀐다.

‘/tabs/[tab]’

fallback:

‘/tabs/[tab]’ 새로고침 중 화면. 탭 링크 아래 “Loading tab…” fallback이 떠 있다

로딩 완료:

‘/tabs/[tab]’ 로딩이 끝난 화면

‘/chat/[id]’

fallback:

‘/chat/[id]’ 새로고침 중 화면. “Loading messages…” fallback이 떠 있다

로딩 완료:

‘/chat/[id]’ 로딩이 끝난 화면

여기까지는 똑같다. 이 글은 이 둘이 갈라지는 순간에 관한 것이다.

나머지 라우트

라우트 무엇을 검증하나
/chat/[id] 경계가 페이지 안 — 기준 케이스
/tabs/[tab] 경계가 공유 레이아웃에만
/blocked/[id] 경계가 아예 없다
/cached/[id] 'use cache'로 감싼 데이터
/mixed/[id] cookies() 의존과 params 의존을 한 페이지에
/static/[id] generateStaticParams로 완전 정적

/cached/mixed는 2편에서, /static은 3편에서 쓴다. 1편에서는 //chat/tabs/blocked 넷만 등장한다.

같은 앱, 설정만 바꾼다

비교의 핵심은 앱을 고정하고 next.config만 바꾸는 것이다. 프로필 셋을 준비했다.

프로필 설정
A experimental.useCache 비교 기준 (baseline)
B cacheComponents Cache Components만
C cacheComponents + partialPrefetching 16.3 기능 전부

프로필 A에 experimental.useCache가 들어간 이유가 있다. 이걸 켜지 않으면 'use cache'를 쓴 /cached 라우트가 빌드되지 않아 프로필 간 비교 자체가 불가능해진다. 이 옵션은 PPR도 프리페치 동작도 바꾸지 않는다.

무엇을, 어떻게 재나

측정 방법 답하는 질문
프리페치 요청 수 요청 가로채기 · 3회 반복 링크 20개에 요청이 몇 건 나가나
TTFC (클릭 → 첫 DOM 변화) MutationObserver + performance.now() 사용자가 멈칫함을 느끼는 구간
렌더 프레임 CDP Page.startScreencast 브라우저가 실제로 픽셀을 그린 시점

공통 조건next build && next start, Chromium, Slow 4G(400kbps / RTT 400ms), 3회 중앙값.

프로덕션 빌드인 게 중요하다. 개발 모드에서는 프리페치가 아예 일어나지 않는다.

스로틀링 시점도 중요하다. 초기 로드가 아니라 네비게이션부터 건다.

초기 로드부터 걸면 JS 하이드레이션이 끝나기 전에 클릭하게 되고, 라우터가 아직 없어 하드 네비게이션이 일어난다.

측정 대상이 아예 달라진다. 실제로 첫 시도에서 이 문제로 전 케이스가 실패했다.

TTFC와 렌더 프레임을 둘 다 재는 이유는 뒤에서 드러난다. 두 값이 꽤 다르게 나오는데, 둘 다 맞다. 재는 것이 다르기 때문이다.

1. 무엇을 느리다고 부를 것인가

실험 앱에는 /tabs/[tab] 라우트가 있다. 탭은 두 개이고 데이터에는 고정 800ms 지연을 넣었다. Suspense 경계는 공유 레이아웃에만 두었다. 탭 UI는 레이아웃에 남고 페이지 본문만 교체되는 구조다.

같은 라우트 패턴을 두 방식으로 열었다.

  • /tabs/a를 주소창에서 직접 여는 새로고침
  • /tabs/a에서 /tabs/b 링크를 누르는 클라이언트 네비게이션

CDP가 전달한 screencast 프레임에서 첫 화면 변화를 찾으면 다음과 같았다.

진입 방식 첫 번째로 관측된 화면 변화
/tabs/a 새로고침 468ms
/tabs/a/tabs/b 클릭 912ms

‘/tabs/a’에서 ‘/tabs/b’로 이동한 성능 트레이스. RSC 요청이 818.75ms 걸리는 동안 프레임 스트립은 그대로다

대조군으로 만든 /chat/[id]는 Suspense 경계를 페이지 안에 두었다. 그 외 조건은 같았다. 이 라우트의 클라이언트 네비게이션에서는 첫 화면 변화가 119ms에 관측됐다.

‘/chat/[id]’ 클라이언트 네비게이션의 성능 트레이스. 응답이 끝나기 전에 프레임 스트립에 새 화면이 나타난다

119ms와 912ms의 차이는 데이터 요청 시간으로 설명할 수 없다. 두 라우트 모두 같은 800ms 지연을 사용했다. 여기서 재는 값도 전체 로딩 시간이 아니다. 사용자가 클릭한 뒤 새 화면이 준비 중이라는 사실을 처음 볼 때까지의 시간이다.

Next.js 16.3 문서도 instant navigation을 비슷하게 정의한다. 클릭 순간 브라우저가 정적 콘텐츠, 캐시된 콘텐츠, fallback 중 적어도 하나를 그리기 시작하고 나머지는 뒤에서 스트리밍되는 상태다. 단, 공식 정의에는 중요한 전제가 붙는다. 캐시가 준비된 상태, 즉 warm cache를 가정한다.

그렇다면 다음 질문은 “어떻게 더 빨리 데이터를 가져올까?”가 아니다.

데이터가 오기 전, 브라우저는 다음 화면에 대해 무엇을 알고 있는가?

2. SPA가 빠르게 반응할 수 있는 이유

SPA가 언제나 빠른 것은 아니다. 라우트 청크도 없고 데이터도 없다면 SPA 역시 네트워크를 기다린다. 다만 SPA에는 다음 화면을 서버 응답 전에 바꿀 수 있는 조건이 있다.

다음 화면의 껍데기를 그릴 코드가 브라우저에 이미 있으면 된다. 초기 번들에 들어 있거나, 라우트 단위로 나눈 청크를 미리 받아둔 경우다. 데이터가 아직 없어도 컴포넌트는 스켈레톤이나 빈 상태를 그릴 수 있다.

SPA의 흐름. 브라우저가 라우트 코드를 갖고 있어 클릭 즉시 셸을 렌더하고, 서버는 마크업 없이 데이터만 돌려준다

여기서 “즉시”는 데이터가 모두 있다는 뜻이 아니다. 불완전해도 목적지에 속한 UI를 브라우저 스스로 만들 수 있다는 뜻에 가깝다.

코드 스플리팅은 이 장점을 없애지 않지만 비용의 시점을 바꾼다. 모든 페이지 코드를 초기 번들에 넣지 않는 대신, 클릭 전에 해당 청크를 prefetch하거나 클릭 뒤 청크 요청을 기다려야 한다. SPA의 즉시성도 공짜가 아니라 필요한 코드를 언제 내려받을지의 문제다.

3. MPA에서는 다음 문서를 서버가 만든다

전통적인 MPA에서 링크는 새 문서 요청을 시작한다. 브라우저는 목적지 HTML을 받기 전까지 그 문서의 내용을 알 수 없다.

MPA의 흐름. 브라우저에 다음 화면 코드가 없어 서버가 만든 HTML을 받은 뒤에야 파싱·렌더가 시작된다

MPA가 본질적으로 느리다는 뜻은 아니다. CDN에 캐시된 HTML이나 브라우저의 back-forward cache가 있으면 매우 빠를 수 있다. 서버 렌더링은 초기 콘텐츠와 검색 엔진 접근성에도 유리하다.

차이는 기본 계약에 있다. SPA는 다음 화면을 만들 책임을 브라우저 쪽에 둘 수 있고, MPA는 새 문서를 서버 응답으로 받는다. 네트워크가 느릴 때 이 책임의 위치가 첫 화면 변화 시점을 가른다.

SPA와 MPA의 첫 화면 변화 시점 비교. SPA는 클릭 직후, MPA는 서버 응답이 도착한 뒤에 바뀐다

4. Pages Router는 두 모델을 섞었다

Pages Router는 첫 진입에서는 서버가 만든 HTML을 보내고, 이후 <Link> 이동은 SPA처럼 처리했다. 페이지 컴포넌트 코드는 클라이언트 번들에도 들어가고, 라우터는 페이지 코드와 정적 데이터를 미리 받을 수 있었다.

다만 데이터 방식에 따라 동작은 달랐다.

  • getStaticProps 페이지는 <Link>가 보일 때 대응하는 페이지와 데이터를 미리 받을 수 있었다.
  • getServerSideProps 페이지의 데이터는 링크를 클릭한 뒤 서버 요청으로 가져왔다.

즉 Pages Router는 “초기 진입은 HTML, 이후 이동은 클라이언트 라우터”라는 다리를 놓았지만, 모든 이동에 즉시 shell을 보장한 것은 아니다. getServerSideProps 결과를 기다리는 네비게이션은 여전히 막힐 수 있었고, 로딩 UI를 언제 보여줄지는 애플리케이션이 별도로 설계해야 했다.

Pages Router의 첫 진입과 이후 Link 이동 흐름. getStaticProps는 미리 받고, getServerSideProps는 클릭 뒤 서버 요청을 기다린다

그래도 중요한 전제 하나는 남아 있었다. 페이지를 렌더링할 React 컴포넌트 코드가 브라우저에도 있었다. 서버에서 받은 것은 주로 그 컴포넌트에 넣을 데이터였다.

App Router는 바로 이 전제를 바꿨다.

5. App Router에서는 새 UI의 일부가 서버에만 있다

App Router의 page.tsxlayout.tsx는 기본적으로 Server Component다. Server Component의 구현은 서버에서 실행되고 클라이언트 번들에는 포함되지 않는다. 브라우저는 실행 코드를 받는 대신, 서버가 렌더링한 결과인 RSC Payload를 받는다.

첫 진입에서는 세 가지가 함께 움직인다.

  1. HTML이 비대화형 첫 화면을 보여준다.
  2. RSC Payload가 Server Component 결과와 Client Component 경계를 전달한다.
  3. JavaScript가 Client Component를 hydrate한다.

이후 클라이언트 네비게이션에서는 새 문서 HTML 대신 RSC Payload가 라우터의 입력이 된다. 공유 레이아웃은 유지하고 바뀐 세그먼트만 합친다.

이 구조에는 분명한 이점이 있다. 데이터베이스 접근이나 비밀 값을 다루는 로직을 브라우저로 보내지 않아도 되고, 상호작용하지 않는 컴포넌트의 JavaScript도 클라이언트 번들에서 뺄 수 있다.

그 대신 새로운 제약도 생겼다.

flowchart LR
  subgraph SPA["SPA 또는 Pages Router"]
    A["브라우저의 페이지 컴포넌트"] --> B["데이터가 없어도 shell 렌더 가능"]
  end
  subgraph APP["App Router의 Server Component 경로"]
    C["서버에서 컴포넌트 실행"] --> D["RSC Payload"] --> E["클라이언트 트리에 결합"]
  end

<ProductPage>가 Server Component라면 브라우저에는 그 컴포넌트를 실행해 임의의 placeholder를 만들 코드가 없다. 물론 내부의 Client Component 코드는 브라우저에 있을 수 있다. 그러나 새 라우트의 Server Component 트리와 경계를 구성하려면 서버가 만든 RSC 결과가 필요하다.

그래서 App Router가 SPA처럼 반응하려면, 목적지 전체가 아니라도 RSC로 표현된 목적지의 shell을 클릭 전에 받아둬야 한다. 이것이 App Router에서 prefetch가 단순한 추가 최적화 이상이 된 이유다.

SPA가 코드 청크를 미리 내려받듯 App Router는 렌더된 UI 결과를 미리 내려받는다. 비용이 사라진 것이 아니라 코드 전송에서 RSC 프리페치로 옮겨갔다.

6. 16.3 전에도 loading과 prefetch는 있었다

여기서 한 번 멈춰야 한다. App Router가 16.3 전까지 로딩 shell을 전혀 만들지 못했던 것은 아니다.

동적 라우트에 loading.tsx가 있으면 Next.js는 페이지를 Suspense로 감싸고, 공유 레이아웃과 로딩 UI를 부분적으로 prefetch할 수 있었다. 프리페치가 끝난 상태라면 클릭 즉시 fallback을 보여줄 수 있었다.

로딩 상태가 들어간 부분 콘텐츠와, 뒤이어 채워지는 완성 콘텐츠

문제는 그 방식이 URL에 강하게 묶여 있었다는 데 있다.

/chat/[id] 링크가 20개 있으면 /chat/1, /chat/2, … /chat/20에 대한 캐시 항목과 프리페치 작업이 각각 생겼다. 라우트 패턴은 하나인데 비용은 보이는 링크 수에 비례했다.

내 실험에서 /chat/[id] 링크 20개를 모두 화면에 노출한 뒤 요청을 세면 baseline 설정에서는 _rsc 요청이 33건이었다. 그중 링크에 직접 대응하는 프리페치가 20건이었다.

분류 요청 수
/chat 링크 20개 프리페치 20
라우트 트리 요청 9
기타 4

baseline 설정의 네트워크 탭. 링크 20개마다 _rsc 프리페치 요청이 하나씩 나가 있다

여기서 비용의 상한이 달라진다.

기준 무엇이 수를 정하는가 상한
라우트 수 파일 시스템 구조 개발자가 통제할 수 있음
링크 수 사용자 데이터와 화면 상태 피드·검색·채팅 목록에서는 사실상 없음

HTTP/2 멀티플렉싱은 브라우저의 연결 제한을 완화하지만 서버 렌더 횟수와 전송 바이트를 없애지는 않는다. Next.js의 prefetch scheduler도 동시에 진행 중인 요청이 4개에 이르면 일반 작업을 더 시작하지 않는다. 다만 가장 최근에 hover한 링크는 우선순위를 높여, 전체 진행 요청이 12개 미만이면 새 작업을 시작할 수 있다.

상한을 두면 큐 뒤쪽에는 아직 끝나지 않은 프리페치가 남는다. 사용자가 그 링크를 먼저 누르면 문제가 드러난다.

7. “아무 일도 안 일어남”은 하나의 현상이 아니었다

App Router의 segment cache에서 navigate()는 동기 값 또는 Promise를 반환한다. v16.3.0 소스의 주석은 기준을 명확히 적는다.

매칭되는 prefetch가 있으면 라우터 상태를 바로 만들고, 없으면 Promise를 돌려준다.

typescript
function navigate(/* ... */): AppRouterState | Promise<AppRouterState>

그리고 라우터 루트의 useActionQueue()는 그 값이 Promise이면 React의 use()로 읽는다. 이때 suspend는 애플리케이션이 배치한 Suspense 경계보다 위에서 발생한다.

이 흐름을 따라가면 클릭 뒤 화면이 멈추는 경우를 두 갈래로 나눌 수 있다.

flowchart TD
  C["링크 클릭"] --> N["navigate()"]
  N --> Q1{"목적지 route tree를<br/>바로 만들 수 있나?"}
  Q1 -->|"아니오"| P["Promise 반환"]
  P --> U["라우터 루트에서 use()로 suspend"]
  U --> A["A. 새 라우터 상태 자체를 기다림"]
  Q1 -->|"예"| S["상태를 동기로 반환"]
  S --> Q2{"렌더에 필요한 segment가<br/>준비됐나?"}
  Q2 -->|"아니오"| R["렌더 도중 suspend"]
  R --> B["B. React가 어느 Suspense를 쓸지 결정"]
  Q2 -->|"예"| I["즉시 커밋"]

A. 라우터 상태를 만들 재료가 없다

목적지 route tree와 매칭되는 prefetch가 없으면 navigate()는 서버 응답을 기다리는 Promise를 반환한다. 라우터 루트가 suspend하므로 페이지 내부의 fallback은 아직 후보가 될 수 없다. 사용자는 이전 화면을 계속 본다.

이것이 prefetch miss가 전체 네비게이션 대기로 바뀌는 경로다.

B. 상태는 만들었지만 새 렌더가 suspend한다

route tree는 있어도 구체 segment 데이터가 없을 수 있다. 이 경우 라우터 상태는 동기로 만들어지고, React가 새 트리를 렌더하는 도중 suspend한다.

Next.js의 push()replace()는 라우터 업데이트를 startTransition 안에서 수행한다. React는 Transition 도중 이미 보이던 콘텐츠가 다시 suspend하면, 그것을 곧바로 fallback으로 덮지 않고 이전 화면을 유지한다. 반대로 목적지와 함께 새로 생기는 Suspense 경계라면 fallback을 바로 보여줄 수 있다.

이 동작은 React의 의도다. 전환 중 이미 완성된 화면 전체가 큰 스피너로 되돌아가는 일을 피하고, 새로 추가되는 하위 경계만 로딩 UI를 드러내려는 선택이다.

8. 0.9초의 정체는 B였다

/tabs/a에서 /tabs/b로 이동할 때 공유 레이아웃의 Suspense 경계는 이미 마운트되어 있었다. 목적지 페이지가 suspend해도 React는 그 경계가 보여주던 기존 콘텐츠를 숨기지 않았다. 약 800ms의 데이터 지연이 끝난 뒤에야 새 탭 내용이 한꺼번에 나타났다.

이때 /tabs/b의 route tree prefetch는 이미 끝난 상태였다. 따라서 라우터 상태 자체를 기다리는 A 경로는 아니었다. 클릭 직후에는 next-router-prefetch 헤더가 없는 실제 네비게이션 요청이 나갔고, 응답이 진행되는 동안에도 공유 경계 아래의 이전 탭 내용이 남았다.

반면 /chat/[id]의 Suspense 경계는 페이지 안에 있었다.

목적지 페이지 서브트리와 함께 새 경계가 생겼고, React는 그 fallback을 보여줄 수 있었다. 그래서 같은 서버 지연을 두고도 첫 화면 변화가 119ms에 관측됐다.

flowchart TD
  S["새 렌더가 suspend"] --> Q{"가장 가까운 Suspense가<br/>이번 이동에서 새로 생기나?"}
  Q -->|"예"| F["fallback 표시"]
  Q -->|"아니오"| O["이전 화면 유지"]
  F --> C["/chat 이동"]
  O --> T["/tabs 이동"]

Next.js 16.3 공식 가이드도 같은 차이를 설명한다. 직접 방문은 문서 루트부터 렌더하므로 루트 레이아웃의 Suspense가 동작할 수 있다. 그러나 /store/shoes에서 /store/hats로 이동하면 공유 /store 레이아웃 아래만 다시 렌더한다. 경계가 그 재렌더 범위 위에 있으면 클라이언트 네비게이션에서는 fallback을 제공하지 못한다.

이 때문에 “새로고침에서는 loading이 보이는데 링크 이동에서는 이전 화면이 남는” 현상이 생긴다.

‘/tabs’ 이동: 응답을 기다리는 동안 프레임이 이전 탭 내용을 유지한다

‘/chat’ 이동: 같은 조건에서 프레임 스트립이 훨씬 이른 시점에 바뀐다

중요한 점이 하나 있다. 이 문제는 shell을 더 많이 prefetch한다고 자동으로 해결되지 않는다. 화면에 적용할 수 있는 경계가 재렌더 범위 밖에 있으면 React는 가져온 fallback을 보여주지 않는다. 16.3 기능을 모두 켠 /tabs 실험이 여전히 약 0.8초 걸린 이유다.

9. 16.3은 Prefetch의 단위를 페이지에서 Shell로 바꿨다

Partial Prefetching을 이해하려면 먼저 기존 prefetch가 어떤 비용 구조를 가졌는지 봐야 한다.

기존 방식에서 <Link>의 prefetch는 사용자가 이동할 구체적인 페이지를 미리 준비하는 방식이었다.

예를 들어 사용자가 채팅 목록에서 여러 대화를 보고 있다고 하자.

text
/chat/1
/chat/2
/chat/3
...
/chat/100

각 링크는 서로 다른 URL이다. 따라서 기존 prefetch는 각각의 페이지를 준비해야 했다.

text
/chat/1

┌─────────────────┐
│ Layout          │
│ Chat Page       │
│ Message Data    │
└─────────────────┘

/chat/2

┌─────────────────┐
│ Layout          │
│ Chat Page       │
│ Message Data    │
└─────────────────┘

문제는 대부분의 UI 구조는 동일하다는 점이다.

/chat/1/chat/2는 다른 페이지처럼 보이지만 실제로는 같은 라우트 구조를 공유한다.

text
┌────────────────────┐
│ Header             │
│ Sidebar            │
│ Chat Layout        │
├────────────────────┤
│ Message #id        │ ← URL마다 변경
└────────────────────┘

기존 방식은 이 공통 영역까지 URL마다 반복해서 준비해야 했다.


App Shell이라는 새로운 재사용 단위

Partial Prefetching은 이 단위를 바꾼다.

페이지 전체가 아니라, 여러 URL이 공유할 수 있는 App Shell을 먼저 가져온다.

text
              /chat/[id]

        ┌────────────────┐
        │ Header         │
        │ Sidebar        │
        │ Chat Layout    │
        └────────────────┘


      ┌──────────┼──────────┐
      │          │          │

   /chat/1    /chat/2    /chat/20



각 URL의 데이터는 이후 스트리밍

/chat/1/chat/2는 서로 다른 페이지지만, /chat/[id]라는 하나의 App Shell을 공유할 수 있다.

여기서 중요한 것은 static shell과 App Shell은 다르다는 점이다.

Static Shell App Shell
기준 하나의 URL 여러 URL
목적 특정 페이지 prerender navigation 재사용
예시 /chat/1의 정적 결과 /chat/[id]의 공통 구조
URL 의존성 가능 최소화

Static shell은 “이 페이지를 미리 만든 결과”이고, App Shell은 “여러 페이지에서 재사용할 수 있도록 URL 의존성을 제거한 결과”다.


Prefetch 비용의 변화

이 차이는 prefetch 비용 구조를 바꾼다.

기존:

text
Link 개수 증가


각 URL의 구체적인 페이지 준비



비용 증가

Partial Prefetching:

text
Link 개수 증가



공유 가능한 Route Shell 확인



필요한 Shell만 재사용

즉 prefetch의 단위가 페이지 인스턴스(page instance) 에서 라우트 패턴(route pattern) 으로 이동했다.


세 단계로 나뉘는 Prefetch

v16.3의 prefetch 과정은 크게 세 단계로 생각할 수 있다.

text
<Link href="/chat/1">





┌──────────────────┐
│ Route Tree       │
│ 라우트 구조 확인  │
└──────────────────┘





┌──────────────────┐
│ App Shell        │
│ 공통 UI 준비      │
└──────────────────┘





┌──────────────────┐
│ Speculative Data │
│ 필요 시 상세 데이터
└──────────────────┘

기본 <Link>는 우선 App Shell까지만 가져온다.

구체적인 URL 데이터까지 미리 가져오고 싶다면 명시적인 prefetch가 필요하다.

이렇게 하면 모든 링크의 데이터를 미리 준비하는 대신, 사용자가 실제로 이동하는 시점에 필요한 데이터를 스트리밍할 수 있다.


구체적인 데이터보다 준비된 Shell을 먼저 사용한다

이 변화는 캐시 탐색 방식에도 영향을 준다.

예를 들어 사용자가 /chat/7로 이동한다고 하자.

캐시 상태가 다음과 같다면:

text
Cache

/chat/7

└── Pending
    (아직 준비되지 않음)

/chat/[id]

└── Fulfilled
    (Shell 준비 완료)

기존 방식이라면 /chat/7이 끝날 때까지 기다려야 했다.

하지만 Partial Prefetching에서는 더 넓은 범위의 완료된 Shell을 먼저 사용할 수 있다.

text
1. App Shell 표시

┌─────────────────┐
│ Header          │
│ Sidebar         │
│ Chat Layout     │
└─────────────────┘

2. 이후 스트리밍

┌─────────────────┐
│ Message #7      │
└─────────────────┘

즉 구체적인 페이지 데이터가 아직 준비되지 않아도, 재사용 가능한 Shell이 있다면 navigation을 진행할 수 있다.


중요한 것은 “항상 즉시 이동”이 아니다

다만 이 동작을 절대적인 보장으로 이해하면 안 된다.

Partial Prefetching이 보장하는 것은:

  • 구체적인 링크 데이터가 아직 준비되지 않아도
  • 공유 가능한 App Shell이 준비되어 있다면
  • Shell 기반 navigation을 진행할 수 있다는 것

이다.

반대로:

  • App Shell 자체가 아직 생성되지 않았거나
  • Suspense 경계가 해당 navigation을 덮지 못하거나
  • 필요한 데이터가 모두 동적인 경우

첫 이동은 기다릴 수 있다.

결국 Partial Prefetching의 핵심은 “모든 페이지를 더 빠르게 가져오는 기술”이 아니다.

미리 준비해야 하는 대상을 페이지 전체에서 재사용 가능한 Shell로 바꾸고, 동적인 부분은 필요한 순간에 스트리밍하는 방향으로 navigation의 비용 구조를 바꾼 것이다.


구현 기반은 하나의 변경으로 만들어진 것이 아니다. dynamicIO를 통한 prerender 판단 모델, Cache Components, App Shell 캐시 단계, Partial Prefetching scheduler 등 여러 렌더링·캐시 기반이 함께 발전하면서 이 구조가 가능해졌다.

dynamicIO 등 자세한 구현 기반은 다음 Deep Dive 편에서 다룰 생각이다.

10. 요청 수와 첫 DOM 변화는 어떻게 달라졌나

cacheComponents: truepartialPrefetching: true를 켠 뒤 같은 20개 채팅 링크 실험을 반복했다.

설정 전체 RSC 요청 20개 /chat 링크에 직접 대응한 작업
baseline 33 구체 링크 prefetch 20건 + route tree 4건
cacheComponents 18 segment 4건 + route tree 4건
cacheComponents + partialPrefetching 15 App Shell 1건 + route tree 4건

Partial Prefetching을 켠 뒤의 네트워크 탭. 링크 20개에 대해 Next-Router-Segment-Prefetch 요청 몇 건만 남는다

Partial Prefetching 상태의 네비게이션 트레이스. /chat 요청이 10.03ms에 끝난다

같은 라우트 패턴을 향하는 20개의 concrete prefetch가 App Shell 1건으로 줄었다.

첫 DOM 변화도 MutationObserver로 같은 기준에서 비교했다.

/chat/7 조건 baseline Partial Prefetching
로컬 네트워크 15ms 19ms
Slow 4G 459ms 18ms
Slow 4G, 구체 링크 prefetch 미완료 482ms 18ms

로컬에서는 차이가 거의 없었다. 네트워크 왕복이 이미 짧기 때문이다. 차이는 Slow 4G와 구체 링크 prefetch miss에서 나타났다. 같은 라우트의 App Shell을 공유하므로 구체 /chat/7 작업이 끝나지 않아도 첫 DOM 변경은 기다리지 않았다.

여기서 DOM 수치와 screencast 수치를 섞지 않으려고 한다. Partial Prefetching 상태에서 DOM은 18ms에 변했고 첫 screencast 프레임은 119ms에 관측됐다. 그 사이에는 브라우저의 frame scheduling, style/layout/paint/composite뿐 아니라 screencast 캡처와 인코딩·전달 지연도 들어갈 수 있다. trace 없이 그 차이를 렌더링 비용 하나로 귀속할 수는 없다.

따라서 이 실험에서 직접 비교 가능한 결과는 두 가지로 나뉜다.

  • 같은 DOM 기준에서는 Slow 4G 459ms → 18ms
  • Partial Prefetching 상태에서 사용자가 볼 프레임은 119ms에 처음 관측

baseline의 첫 screencast 프레임 수치까지 같은 방식으로 공개하기 전에는 “픽셀 기준 459ms → 119ms”라고 쓰지 않는 편이 정확하다.

그리고 /tabs는 달라지지 않았다. 별도의 반복에서 baseline과 Partial Prefetching 모두 약 0.8초 뒤에야 바뀌었다. shell을 가져오는 비용은 줄었지만, 공유 레이아웃 위의 이미 드러난 Suspense 경계가 이전 화면을 지키는 문제는 남았기 때문이다.

16.3이 바꾼 것과 바꾸지 않은 것을 한 줄씩 적으면 이렇다.

  • 바꾼 것: 링크마다 shell을 준비하던 비용을 라우트별 공용 shell로 옮겼다.
  • 바꾸지 않은 것: 그 shell의 fallback을 현재 React 전환에서 실제로 보여줄 수 있는지는 컴포넌트와 Suspense 구조가 결정한다.

11. 단순한 아이디어가 왜 이제 들어왔을까

App Shell만 놓고 보면 아이디어는 별로 복잡하지 않다.

여러 URL이 어차피 같은 화면 구조를 공유한다면, 그 공통 부분만 미리 받아두면 되지 않을까?

그런데 이 기능이 이제야 들어온 이유를 이해하려면 조금 더 뒤로 가야 한다.

Next.js는 2023년 말부터 Partial Prerendering(PPR)을 실험해왔다.

한 페이지 안에서 정적인 부분은 미리 만들고, 동적인 부분은 요청 시점에 스트리밍한다는 아이디어였다. 하지만 PPR은 이후에도 오랫동안 experimental 상태에 머물렀고, Next.js 16에 와서야 별도의 PPR 설정이 아니라 Cache Components라는 더 큰 렌더링·캐시 모델 안으로 들어갔다.

왜 이렇게 오래 걸렸을까?

공개된 PR만으로 PPR의 긴 실험 기간 전체를 하나의 이유로 설명할 수는 없다.

다만 그동안의 변화를 따라가 보면 계속 반복해서 등장하는 어려운 문제가 하나 있다.

한 React 트리 안에서 어디까지를 미리 만들고, 어디부터를 요청 시점으로 미뤄야 하는가?


Astro에서는 이 경계가 비교적 선명하다

이 문제를 이해하는 데 Astro와 비교해보면 재미있다.

Astro는 기본 출발점부터 정적이다. Astro 컴포넌트는 기본적으로 클라이언트 런타임 없이 HTML로 렌더링되고, 브라우저에서 동작해야 하는 컴포넌트에 개발자가 client:* directive를 붙인다.

서버에서 나중에 동적으로 렌더링하고 싶은 영역 역시 server:defer라는 명시적인 경계를 만들 수 있다.

flowchart TB
    subgraph ASTRO["Astro"]
        A["Static HTML by default"]
        B["Interactive component"]
        C["Dynamic server component"]

        B -->|"client:*"| BI["Client Island"]
        C -->|"server:defer"| SI["Server Island"]
    end

    D["Developer explicitly marks the boundary"]

    D -.-> BI
    D -.-> SI

그래서 블로그나 랜딩 페이지처럼 대부분이 정적인 사이트에서는 Astro의 모델이 특히 잘 맞는다.

페이지 대부분은 그대로 HTML로 남기고 정말 필요한 부분에만 JavaScript를 보내면 된다. Astro 문서가 설명하듯 UI 프레임워크 컴포넌트조차 기본 상태에서는 클라이언트 JavaScript를 보내지 않고, 개발자가 client:*를 붙였을 때만 해당 island를 hydrate한다.

물론 이것만으로 Astro가 언제나 Next.js보다 빠르다는 뜻은 아니다. App Router의 Server Component 역시 브라우저에 컴포넌트 JavaScript를 보내지 않을 수 있다.

여기서 보고 싶은 것은 성능 순위가 아니라 설계 철학의 차이다.

Astro에서는 개발자가 비교적 명시적으로 말한다.

여기는 정적이다.
여기는 브라우저에서 실행한다.
여기는 나중에 서버에서 렌더링한다.

Next.js가 풀려고 했던 문제는 조금 달랐다.


Next.js는 하나의 React 트리를 나누고 싶었다

App Router의 이상적인 개발 경험은 페이지를 처음부터 static과 dynamic이라는 두 종류로 쪼개서 작성하는 것이 아니다.

그냥 하나의 React 트리를 작성한다.

text
export default function Page() {
  return (
    <>
      <Header />
      <Product />
      <Recommendations />
      <UserMenu />
    </>
  )
}

그런데 실제 렌더링 특성은 모두 다를 수 있다.

flowchart TB
    PAGE["/product/[id]"]

    PAGE --> H["Header<br/>Static"]
    PAGE --> P["Product<br/>params dependent"]
    PAGE --> R["Recommendations<br/>Cached"]
    PAGE --> U["User Menu<br/>cookies dependent"]

    H --> SHELL["Prerenderable Shell"]
    R --> SHELL

    P --> DYNAMIC["Request-time"]
    U --> DYNAMIC

Header는 완전히 정적일 수 있다.

Recommendations는 비동기 데이터를 읽지만 한 시간 동안 재사용해도 괜찮을 수 있다.

Product는 현재 URL의 params가 있어야 렌더링할 수 있다.

UserMenucookies()를 읽어 현재 사용자에 따라 달라질 수 있다.

Next.js가 PPR을 제대로 구현하려면 이 트리를 통째로 static 또는 dynamic으로 분류해서는 안 된다.

같은 트리 안에서 재사용 가능한 출력과 요청 시점에 필요한 출력을 분리해야 한다.

바로 이 지점부터 문제가 어려워진다.


“Promise인가?”로는 구분할 수 없다

처음 생각하면 비동기 작업을 찾으면 될 것처럼 보인다.

javascript
const data = await fetch(...)

그렇다면 이 부분은 dynamic이라고 판단하면 되지 않을까?

그렇게 간단하지 않다.

비동기라는 사실 자체는 데이터가 동적이라는 뜻이 아니다.

빌드 중 읽을 수 있는 데이터도 Promise일 수 있고, 캐시된 DB 조회도 Promise일 수 있다. 반대로 타이머처럼 실제 네트워크 I/O가 아닌 비동기 작업도 현재 렌더링 task 안에서는 끝나지 않을 수 있다.

그래서 2024년의 dynamicIO 실험에서 등장한 판단 기준이 중요하다.

프레임워크가 알아내려는 것은

“이 Promise는 진짜 I/O인가?”

가 아니었다.

더 가까운 질문은 이것이었다.

“이 작업을 현재 prerender task 안에서 완료할 수 있는가?”

PR #67571은 현재 task의 microtask queue 안에서 resolve할 수 없는 async 작업을 prerender에서 제외하도록 static generation과 PPR의 판단 방식을 변경했다.

flowchart TD
    R["Prerender starts"]
    A["Async work encountered"]
    Q{"Can it resolve<br/>within the current task?"}

    STATIC["Include in prerender"]
    POSTPONE["Exclude / postpone<br/>behind Suspense"]

    R --> A --> Q
    Q -->|"Yes"| STATIC
    Q -->|"No"| POSTPONE

이 변화가 중요한 이유는 Next.js가 데이터의 의미를 추측할 필요가 없어졌기 때문이다.

DB인지 API인지 타이머인지가 본질이 아니다.

현재 prerender를 완료할 수 있는 작업인가가 경계가 된다.


그렇다면 캐시된 비동기 데이터는?

여기서 또 하나의 문제가 생긴다.

API 요청이지만 한 시간 동안 같은 결과를 써도 되는 데이터가 있다고 하자.

typescript
async function Recommendations() {
  const products = await db.query(...)
  return <List products={products} />
}

이 작업을 무조건 request-time으로 보내면 정적으로 만들 수 있는 영역이 지나치게 작아진다.

그래서 Cache Components에서는 개발자가 'use cache'를 통해 의도를 표현할 수 있다.

typescript
async function Recommendations() {
  'use cache'

  const products = await db.query(...)
  return <List products={products} />
}

'use cache'는 “이 Promise는 이미 완료되어 있다”는 트릭이 아니다.

이 계산 결과에는 재사용 가능한 수명이 있으며, 캐시 가능한 렌더링 결과로 취급해도 된다는 계약에 가깝다. Next.js 16은 이 명시적인 캐시 모델을 Cache Components의 중심에 놓았다.

결국 Next.js의 모델은 순수한 자동 판별도, 완전한 수동 분류도 아니다.

flowchart LR
    CODE["React Tree"]

    CODE --> STATIC["Immediately prerenderable"]
    CODE --> CACHE["Developer marks<br/>'use cache'"]
    CODE --> RUNTIME["Uncached / Request data"]

    STATIC --> SHELL["Static Shell"]
    CACHE --> SHELL

    RUNTIME --> SUSPENSE["Suspense"]
    SUSPENSE --> STREAM["Request-time Stream"]

프레임워크는 렌더링 과정 자체를 관찰하고, 개발자는 캐시와 Suspense를 통해 의도를 보완한다.

Astro와 비교했을 때 흥미로운 차이가 여기 있다.

Astro는 island라는 구조적인 경계를 개발자가 먼저 만든다.

Next.js는 가능한 한 평범한 React 트리를 유지하면서 그 안에서 렌더링 가능한 경계를 찾아내려고 했다.


App Shell은 이 기반 위에서 가능해졌다

이제 다시 App Shell로 돌아오자.

/chat/1, /chat/2, /chat/20이 하나의 Shell을 공유하려면 Next.js는 먼저 다음과 같이 판단할 수 있어야 한다.

flowchart TB
    ROUTE["/chat/[id]"]

    ROUTE --> L["Layout"]
    ROUTE --> N["Navigation"]
    ROUTE --> C["Chat Content<br/>depends on params"]

    L --> SH["Reusable App Shell"]
    N --> SH

    C --> S["Suspense"]
    S --> STREAM["/chat/1<br/>/chat/2<br/>/chat/20"]

단순히 loading.tsx 하나를 저장하는 문제가 아니다.

서버가 만든 RSC 출력 중에서

어디까지가 URL이 달라져도 재사용 가능한가

를 먼저 결정할 수 있어야 한다.

그 결과를 RSC stream 안에서 보존해야 하고, 캐시에 넣어야 하고, 클라이언트 router가 다음 navigation에서 다시 찾아 쓸 수 있어야 한다.

그래서 App Shell은 갑자기 등장한 독립 기능이라기보다 그동안 쌓인 렌더링 모델 위에서 가능해진 결과에 가깝다.

PPR이 오랫동안 experimental이었던 이유 전체를 이것 하나로 설명할 수는 없다. React의 prerender/resume 지원, Next.js의 캐시 모델, RSC streaming, client router 등 여러 부분이 함께 변했다.

다만 공개된 구현의 흐름을 따라가면 한 가지는 분명하다.

Next.js가 오래 풀어온 문제 중 하나는 “정적 페이지와 동적 페이지를 구분하는 것”이 아니라, 하나의 React 트리 안에서 어디까지를 재사용 가능한 정적 결과로 만들 수 있는지 결정하는 것이었다.

그리고 그 경계를 안정적으로 만들 수 있게 되면서, 비로소 여러 URL이 하나의 App Shell을 공유하는 Partial Prefetching도 현실적인 선택지가 됐다.

구현 흐름을 더 깊게 보고 싶다면 PR #67571의 dynamicIO, #93998의 URL-independent App Shell, #93999의 Shell cache/prefetch, #94510의 Partial Prefetching을 순서대로 보면 이 변화가 비교적 잘 드러난다.

12. React는 서버로 돌아간 것이 아니다

나는 PPR을 꽤 오래 기다렸다.

Next.js 14에서 처음 Preview로 공개된 것이 2023년이었다. 그 뒤로도 오랫동안 experimental이라는 꼬리표가 붙어 있었고, Cache Components라는 형태로 본격적인 모델이 정리된 것은 Next.js 16에 와서였다.

기다리는 입장에서는 답답했지만, 지금 와서 내부 구현을 따라가 보면 마냥 Next.js가 느렸다고 말하기도 어렵다.

Next.js는 결국 React 위에 있는 프레임워크다.

React 18에서야 Suspense를 사용하는 streaming SSR과 concurrent rendering이 본격적으로 제공됐고, Server Components 역시 당시에는 아직 개발 중인 기능이었다. React 19에서 RSC가 안정적인 기능으로 들어왔고, React 19.2에서는 Partial Pre-rendering을 위해 prerender를 멈춘 상태를 저장했다가 나중에 resume할 수 있는 API까지 등장했다.

PPR이 오래 걸린 이유를 React 하나로 설명할 수는 없다.

그래도 Next.js가 만들고 싶었던 렌더링 모델을 생각하면, 아래에 있는 React의 기반이 먼저 성숙해야 했다는 것은 자연스럽다.

이 과정을 따라가다 보니 예전에 서버 개발자 동료들과 자주 했던 대화가 생각났다.

“근데 웹은 원래 서버에서 렌더링하던 거 아니야? 결국 예전으로 돌아가는 거 아냐?”

표면적으로 보면 맞는 말처럼 보인다.

브라우저에서 렌더링하던 React가 다시 서버에서 컴포넌트를 실행한다. HTML도 서버에서 만들고 데이터도 서버에서 읽는다.

그런데 이 글을 쓰면서 내가 내린 결론은 반대다.

React는 서버 렌더링으로 돌아간 것이 아니다.

오히려 React가 처음부터 풀어오던 문제를 서버까지 확장하고 있다.


처음에는 브라우저 안에서 시작했다

React가 처음부터 SPA와 MPA의 대결을 위해 만들어졌다고 말하면 역사적으로는 과하다.

React의 출발점은 상태에 따라 계속 변하는 interactive UI를 선언적으로 다루는 것이었다. 컴포넌트가 현재 상태에 맞는 UI를 기술하면 React가 실제 화면과의 차이를 계산해 갱신한다는 모델이다. React의 공식 설명 역시 오랫동안 interactive UI를 효율적으로 업데이트하는 라이브러리라는 점을 강조해왔다.

그 모델은 자연스럽게 SPA와 잘 맞았다.

페이지 전체를 서버에 다시 요청해서 새 문서를 받는 대신, 브라우저에 애플리케이션을 유지한 채 필요한 부분만 바꿀 수 있었다.

사용자 경험은 좋아졌다.

그런데 애플리케이션이 커지면서 또 다른 문제가 나타났다.

React의 초기 reconciler는 컴포넌트 트리를 재귀적으로 따라가며 하나의 tick에서 업데이트된 트리의 렌더링을 처리했다. React 팀 역시 당시 설계 문서에서 이 방식은 작업량이 커지면 frame drop을 만들 수 있고, 사용자 입력처럼 중요한 작업과 백그라운드 렌더링의 우선순위를 다르게 다룰 필요가 있다고 설명했다.

문제는 얼마나 빨리 계산하느냐만이 아니었다.

한 번 계산을 시작했을 때 브라우저에게 제어권을 언제 돌려줄 수 있느냐가 중요했다.

flowchart LR
    A["Render starts"] --> B["Component tree"]
    B --> C["More work"]
    C --> D["More work"]
    D --> E["Render finishes"]
    E --> F["Browser gets control back"]

렌더링이 짧을 때는 문제가 없다.

하지만 트리가 커지고 계산량이 많아지면 그동안 브라우저의 main thread에서는 다른 일이 밀릴 수 있다.

사용자가 타이핑했는데 입력 반응이 늦어지거나, 애니메이션 frame이 빠지거나, 스크롤이 버벅이는 식이다.

여기서 내가 알고리즘 문제를 풀 때 경험했던 재귀 DFS가 떠올랐다.

물론 React의 문제가 stack overflow였다는 뜻은 아니다.

비슷한 직관은 있다.

일반적인 JavaScript call stack에 재귀 실행을 맡겨버리면 라이브러리가 임의의 지점에서

“여기까지만 하고 중요한 일이 들어왔으니 잠깐 멈추자.”

라고 실행을 제어하기 어렵다.


Fiber가 바꾼 것은 속도보다 제어권이었다

React 16에서 등장한 Fiber는 이 문제를 풀기 위한 새로운 reconciler였다.

React 팀이 당시 가장 기대한다고 설명했던 것도 단순한 benchmark상의 속도 향상이 아니라 렌더링 작업을 cooperative scheduling하고 주기적으로 브라우저에 실행권을 돌려주는 것이었다.

흔히 Fiber를 “stack 기반에서 object 기반으로 바뀌었다”고 설명한다.

틀린 말은 아니지만, 내가 보기에는 그보다 중요한 결과가 있다.

React가 렌더링 작업의 실행 상태를 직접 관리할 수 있게 됐다는 것이다.

call stack이 실행 순서를 전부 소유하는 대신, 렌더링 작업을 React가 관리하는 작은 단위로 표현한다.

그러면 작업을:

  • 잠시 멈추고
  • 우선순위가 높은 일을 먼저 처리하고
  • 다시 이어서 실행하거나
  • 더 이상 필요 없다면 버릴 수 있다.

React 18이 concurrent rendering을 설명하면서 가장 먼저 강조한 특성도 바로 interruptible rendering이었다. 낮은 우선순위의 렌더링을 진행하다 더 중요한 업데이트가 들어오면 중단하고, 필요하다면 나중에 다시 처리할 수 있다.

flowchart LR
    A["Render"] --> B["Unit of work"]
    B --> C{"More urgent work?"}

    C -->|"No"| D["Next unit"]
    D --> C

    C -->|"Yes"| E["Yield"]
    E --> F["Handle urgent update"]
    F --> G["Resume / Restart"]

    G --> D

여기서 React가 깨달았다고 내가 느끼는 지점이 있다.

가장 빨리 모든 것을 끝내는 것이 항상 가장 좋은 사용자 경험은 아니다.

사용자에게 지금 필요한 것을 먼저 끝내는 것이 더 중요하다.

화면 밖에 있는 컴포넌트보다 사용자가 지금 누른 버튼이 중요하다.

백그라운드에서 들어온 데이터보다 현재 타이핑하고 있는 입력이 중요하다.

React의 오래된 Design Principles에도 이미 비슷한 생각이 적혀 있다. 모든 계산을 바로 수행하는 대신 필요할 때까지 미룰 수 있고, 사용자 interaction에서 발생한 작업을 중요하지 않은 background work보다 우선할 수 있어야 한다는 것이다.

Fiber는 그 철학을 실행할 수 있는 기반이었다.


그런데 브라우저 안에서만 잘한다고 끝나지 않았다

Fiber와 Concurrent React가 클라이언트 렌더링의 문제를 줄여도 웹 애플리케이션 전체의 문제가 사라지는 것은 아니었다.

SPA가 커지면서 다른 비용이 눈에 띄기 시작했다.

브라우저까지 보내야 하는 JavaScript가 많아지고, 그 코드를 다운로드하고 실행하고 hydration해야 했다. 데이터를 클라이언트에서 가져오면 렌더링 이후 다시 network request가 이어지는 waterfall도 쉽게 생겼다.

모든 것을 브라우저에서 해결하는 것 역시 모든 상황에서 최적은 아니었다.

React Server Components가 흥미로운 이유가 여기 있다.

React는 컴포넌트 모델을 버리고 과거의 서버 템플릿으로 돌아간 것이 아니라, React 트리 자체를 Server와 Client라는 두 실행 환경에 걸쳐 놓았다.

Server Component는 클라이언트 번들에 포함되지 않은 채 build time이나 request time에 실행될 수 있고, Client Component가 필요한 위치와 데이터는 RSC 모델을 통해 연결된다. React는 Server Components를 전통적인 server rendering의 성능 이점과 client-side app의 풍부한 interaction을 결합하기 위한 모델로 설명해왔다.

그래서 RSC의 핵심을 단순히

“React가 서버에서 실행된다.”

라고 이해하면 중요한 부분을 놓친다.

더 중요한 변화는 하나의 React 애플리케이션에 Server와 Client 사이의 명시적인 실행 경계가 생겼다는 것이다.

flowchart LR
    S["Server Components"] --> R["RSC representation"]
    R --> C["Client React Tree"]

    S -->|"data / rendering"| R
    C -->|"interaction"| B["Browser"]

서버와 클라이언트는 이제 둘 중 하나를 선택하는 관계가 아니다.

같은 UI를 구성하는 서로 다른 실행 환경이 됐다.


그래도 서버가 느리면 다시 기다려야 했다

그런데 여기까지 와도 문제가 하나 남는다.

Server Component에서 데이터를 가져온다고 해도, 서버가 필요한 작업을 모두 끝낼 때까지 기다린 다음 결과를 보내야 한다면 또 다른 all-or-nothing이 생긴다.

Pages Router에서 익숙했던 서버 렌더링은 특히 이런 성격이 강했다.

동적인 페이지에 필요한 데이터가 준비되어야 페이지 결과를 만들 수 있었고, client navigation에서도 해당 결과를 기다려야 하는 경우가 있었다.

App Router와 Suspense, streaming은 이 경계를 다시 쪼갰다.

flowchart LR
    subgraph OLD["All or Nothing"]
        A["Request"] --> B["Fetch"]
        B --> C["Render"]
        C --> D["Response"]
    end

    subgraph NEW["Progressive"]
        E["Request"] --> F["Render ready parts"]
        F --> G["Send Shell"]
        G --> H["Wait for dynamic work"]
        H --> I["Stream remaining UI"]
    end

다시 같은 아이디어가 등장한다.

모든 것이 준비될 때까지 기다리지 않는다.

준비된 것은 먼저 보낸다.

아직 준비되지 않은 것은 Suspense 뒤에 남겨둔다.

나중에 준비되면 이어서 보낸다.


그리고 PPR에서는 렌더링 자체를 이어서 사용한다

이 글에서 살펴본 PPR과 Cache Components까지 오면 이 흐름이 더 노골적으로 보인다.

React 19.2의 prerender는 React 트리를 미리 렌더링하다가 작업이 완료되지 않았을 경우 정적인 prelude와 함께 opaque한 postponed state를 반환할 수 있다. 이 상태는 이후 resume에 넘겨 렌더링을 이어갈 수 있다. React 문서도 이 API를 partial pre-rendering 지원과 직접 연결해서 설명한다.

Next.js는 이 기반 위에서 한 route를:

text
미리 만들 수 있는 부분
        +
요청 시점에 필요한 부분

으로 나눈다.

그리고 이제 Partial Prefetching에서는 그중 여러 URL이 공유할 수 있는 App Shell만 먼저 가져와 navigation에 재사용한다.

여기서 재미있는 공통점이 보인다.

Fiber와 PPR은 전혀 다른 계층의 기술이다.

하나는 브라우저에서 reconciliation 작업을 scheduling하는 아키텍처이고, 다른 하나는 서버에서 prerender와 request-time rendering을 결합하는 모델이다.

그러니

“PPR은 서버 버전 Fiber다.”

라고 말하면 틀린 설명이다.

하지만 두 기술이 풀고 있는 문제에는 내가 좋아하는 공통된 사고방식이 있다.

flowchart TB
    A["Old assumption<br/>Start → Finish"]

    A --> B["Fiber"]
    A --> C["Suspense + Streaming"]
    A --> D["RSC"]
    A --> E["PPR"]

    B --> B1["Pause / Prioritize / Resume rendering"]
    C --> C1["Reveal ready UI first"]
    D --> D1["Split execution across Server / Client"]
    E --> E1["Prerender / Postpone / Resume"]

한 번 시작한 일을 전부 끝내야 다음 단계로 갈 수 있다는 제약을 계속 없애고 있다.

클라이언트에서는 렌더링 작업을 잘게 나눴다.

서버에서는 화면을 Suspense boundary로 나눴다.

RSC에서는 실행 환경을 서버와 클라이언트로 나눴다.

PPR에서는 하나의 route 안에서 build time에 끝낼 수 있는 작업과 request time까지 미뤄야 하는 작업을 나눴다.

그리고 Next.js 16.3의 App Shell은 그 결과 중 URL이 달라져도 재사용 가능한 부분을 한 번 더 분리한다.


그래서 나는 이것을 회귀라고 생각하지 않는다

웹의 역사를 아주 멀리서 보면 이상하게 보일 수도 있다.

서버에서 HTML을 만들었다.

브라우저에서 모두 렌더링하기 시작했다.

다시 서버에서 렌더링하기 시작했다.

그래서 결국 원래 자리로 돌아온 것처럼 보인다.

하지만 어디에서 렌더링하느냐만 놓고 보면 그렇다.

내가 React의 발전을 보면서 더 중요하다고 느끼는 것은 다른 축이다.

text
Server → Client → Server

가 아니라,

text
All or Nothing

Split

Schedule

Stream

Resume

Reuse

에 가깝다.

React가 처음부터 중요하게 생각했던 것은 단순히 DOM을 빨리 그리는 일이 아니었던 것 같다.

사용자가 지금 필요로 하는 UI를 언제 보여줄 것인가를 React가 통제할 수 있어야 한다는 것.

그 생각은 Fiber에서는 scheduling으로 나타났고, Suspense에서는 boundary로, RSC에서는 server/client composition으로, 지금의 PPR에서는 prerender와 runtime의 경계로 나타나고 있다. React의 공식 설계 문서가 오래전부터 UI에서 어떤 계산이 “지금 중요한가”를 프레임워크가 판단할 수 있어야 한다고 이야기해왔다는 점도 이 흐름과 잘 맞는다.

그래서 나는 React의 현재 방향이 마음에 든다.

몇 년 동안 React와 Next.js의 단점으로 지적되던 것들 중 상당수는 결국 React가 처음 선택했던 컴포넌트 모델을 버리지 않은 채 해결하려는 문제가 됐다.

클라이언트 JavaScript가 너무 많으면 서버에서 실행한다.

서버 응답을 기다리는 것이 느리면 스트리밍한다.

동적인 데이터 때문에 정적 렌더링을 포기해야 한다면 둘을 한 route 안에서 나눈다.

모든 링크를 prefetch하기 비싸다면 URL이 공유하는 Shell만 재사용한다.

하나의 패러다임을 고집하기보다, 필요한 실행을 필요한 장소와 필요한 시점으로 계속 옮기고 있다.

그래서 적어도 지금의 나에게 React는 오래돼서 살아남은 기술이라기보다, 꽤 오랫동안 같은 문제를 더 정교하게 풀어오고 있는 기술처럼 보인다.

그리고 이 방향이 계속된다면, 나는 React가 프론트엔드의 중심에서 생각보다 훨씬 오래 살아남을 것 같다.

13. 남은 질문

처음 본 0.9초는 하나의 문제가 아니었다.

첫 번째는 목적지 route tree나 shell이 없어 라우터 상태 자체를 서버 응답 뒤에야 만들 수 있는 경우였다. 16.3은 링크별 concrete prefetch보다 먼저 라우트별 App Shell을 준비하고, pending concrete entry보다 fulfilled shell을 우선하도록 바꿨다.

두 번째는 상태와 shell이 있어도 React Transition이 이미 보인 UI를 지키는 경우였다. 이때는 Suspense 경계가 현재 네비게이션에서 새로 생기는 위치에 있어야 fallback을 보여줄 수 있다.

이제 “App Router가 느리다”라는 한 문장은 적어도 네 질문으로 나뉜다.

  1. 목적지 route tree가 클릭 전에 준비됐는가?
  2. 여러 링크가 재사용할 App Shell이 준비됐는가?
  3. 현재 전환에서 사용할 수 있는 Suspense 경계가 있는가?
  4. 그 shell 안에는 사용자가 알아볼 만한 UI가 얼마나 들어 있는가?

16.3은 앞의 두 질문을 위한 비용 모델과 도구를 제공한다. 하지만 마지막 두 질문의 답은 애플리케이션 구조에 남는다.

그래서 다음 편에서는 “App Shell이 있다”는 설명을 한 단계 더 의심해 보려 한다.

그 shell 안에는 정확히 무엇이 들어가며, params, searchParams, 쿠키, 'use cache'는 경계를 어떻게 바꾸는가?

Navigation Inspector와 instant() 테스트 도우미로 네비게이션을 shell 상태에 멈춘 뒤 직접 확인해 본다.

참고로 이 글의 내용을 이해하면 다른 next.js 16 기능은 모두 쉽게 이해할 수 있었다. (ISR 등 기반 지식이 동일하다)

최종 아키텍처

React 초기부터 Next.js 16.3까지의 렌더링 모델 진화와, 서버·네트워크·클라이언트에 걸친 Instant Navigation 전체 아키텍처


출처