← 목록으로
AI-assisted content
Next.js Pages Router vs App Router 렌더링 모델 비교

Next.js의 App Router는 Pages Router와 단순히 "최신 버전"의 관계가 아닙니다. 렌더링 모델 자체가 다르고, 같은 용어("Pre-rendering")가 서로 다른 범위를 가리킵니다. 이 글은 두 라우터의 차이를 용어 정의부터 RSC Payload, PPR(Cache Components)까지 단계적으로 정리합니다.

1. 용어 정의: "Pre-rendering"은 각 라우터에서 다른 의미를 가진다

이 글을 이해하려면 먼저 렌더링 용어를 정리해야 합니다. "Pre-rendering"이라는 동일한 단어가 Pages Router와 App Router에서 서로 다른 범위로 사용되기 때문입니다.

Pages Router의 용어 체계

Pages Router에서 Pre-rendering은 서버에서 HTML을 미리 생성하는 모든 행위를 의미합니다. SSG(빌드 시점)든 SSR(요청 시점)이든, 서버에서 HTML을 만들면 모두 Pre-rendering입니다.

"Next.js는 두 가지 형태의 Pre-rendering을 가진다: Static Generation과 Server-side Rendering. 차이는 페이지의 HTML을 언제 생성하느냐이다." — Next.js 공식 문서, Two Forms of Pre-rendering
Pages Router의 Pre-rendering (서버에서 HTML을 생성하는 모든 행위)
├── SSG (빌드 시점에 HTML 생성)
└── SSR (매 요청마다 HTML 생성)

App Router의 용어 체계

App Router에서는 용어가 더 정밀하게 재정의되었습니다. 포괄적인 상위 개념은 Server Rendering(서버 렌더링)이며, 이것이 렌더링 시점에 따라 두 가지로 나뉩니다.

"서버 렌더링에는 두 가지 유형이 있으며, 이는 언제 발생하느냐에 따라 구분된다: Prerendering은 빌드 시점 또는 revalidation 중에 발생하며 그 결과가 캐시된다. Dynamic Rendering은 요청 시점에 클라이언트 요청에 대한 응답으로 발생한다." — Next.js 공식 문서, Linking and Navigating
App Router의 Server Rendering (서버에서 렌더링하는 모든 행위)
├── Prerendering (빌드/revalidation 시점, 결과 캐시됨)
└── Dynamic Rendering (요청 시점, 매번 새로 렌더링)

핵심 차이는 Pages Router에서 SSR(요청 시점 렌더링)이 "Pre-rendering"에 포함되었지만, App Router에서는 요청 시점 렌더링을 "Dynamic Rendering"이라는 별도 용어로 분리했다는 것입니다. "Pre"는 "요청 전에 미리"라는 의미이므로, 요청이 있어야 실행되는 렌더링에는 더 이상 이 용어를 사용하지 않습니다.

App Router에서 Prerendering과 Dynamic Rendering의 구분 기준

구분 기준은 "콘텐츠가 정적인가"가 아니라 "요청 도착 전에 결과를 확정할 수 있는가"입니다.

"이것은 요청이 도착하기 전에 미리 발생하기 때문에, 이를 Prerendering이라고 한다." — Next.js 공식 문서, Cache Components
Prerendering (요청 전에 결과 확정 가능)Dynamic Rendering (요청이 있어야 실행 가능)
순수 계산, 모듈 importcookies(), headers() 사용
use cache로 캐시된 데이터 페칭캐시되지 않은 데이터 페칭
generateStaticParams로 미리 생성된 동적 라우트사용자별 개인화된 콘텐츠
결과가 캐시되어 재사용됨매 요청마다 새로 렌더링됨

용어 대응 관계 요약

Pages Router 용어App Router 용어
Pre-rendering (SSG)Prerendering
Pre-rendering (SSR)Dynamic Rendering
(상위 개념 없음)Server Rendering (Prerendering + Dynamic Rendering)

2. 근본적인 공통점: 서버에서 렌더링

Pages Router와 App Router 모두 서버에서 컴포넌트를 먼저 렌더링한다는 점은 동일합니다. 두 방식 모두 서버 환경(Node.js)에서 컴포넌트가 실행되며, 이 환경에는 window, document, localStorage 같은 브라우저 API가 존재하지 않습니다. 이것이 window is not defined 에러의 근본 원인입니다.

차이는 서버에서 렌더링한 결과를 클라이언트에 어떻게 전달하는가에 있습니다.

3. Pages Router의 렌더링 모델

구조

Pages Router에서는 모든 컴포넌트가 기본적으로 Client Component입니다. Pre-rendering 방식은 두 가지가 있습니다.

방식실행 시점사용 함수
SSG (Static Site Generation)빌드 시점getStaticProps
SSR (Server-Side Rendering)매 요청마다getServerSideProps

getServerSidePropsgetInitialProps가 없으면 Next.js는 자동으로 정적 HTML로 Pre-render합니다. (공식 문서: Automatic Static Optimization)

핵심 특성: 정적 페이지라도 JS 번들이 전송된다

Pages Router에서 아무리 정적인 페이지라도 컴포넌트의 JavaScript 번들은 클라이언트에 전송됩니다.

"정적으로 생성된 페이지도 여전히 reactive하다. Next.js는 클라이언트 측에서 애플리케이션을 hydrate하여 완전한 인터랙티비티를 부여한다." — Next.js 공식 문서, Automatic Static Optimization

이유는 Hydration 때문입니다. React가 서버에서 만든 정적 HTML에 이벤트 핸들러를 붙이고 React 트리를 장악하려면, 컴포넌트의 JS 코드가 브라우저에 존재해야 합니다. 클라이언트 번들에서 제거되는 것은 getStaticProps, getServerSideProps 같은 데이터 페칭 함수뿐입니다.

Pages Router의 전달 흐름

[서버]
  컴포넌트 실행 → HTML 생성
       ↓
[클라이언트로 전송]
  HTML + 컴포넌트 JS 번들
       ↓
[클라이언트]
  HTML 즉시 표시 → JS 번들로 전체 컴포넌트 Hydration

서버는 컴포넌트를 실행하여 HTML을 만들고, 그 HTML과 함께 동일한 컴포넌트의 JS 번들을 클라이언트에 전송합니다. 클라이언트는 이 JS를 사용하여 HTML 위에 React 트리를 재구성하고 이벤트 핸들러를 붙입니다(Hydration). 이 구조에서는 서버가 만든 산출물이 HTML 하나뿐이며, 컴포넌트의 JS는 Hydration을 위해 항상 함께 전송됩니다.

window is not defined 발생 원인

Pages Router에서 모든 컴포넌트는 서버에서 Pre-render(SSG 또는 SSR)됩니다. 이 과정에서 렌더 함수 본문에서 window에 직접 접근하면 에러가 발생합니다.

// pages/my-page.js
export default function MyPage() {
  const width = window.innerWidth; // 서버에서 HTML 생성 시 에러
  return <div>{width}</div>;
}

해결 방법은 세 가지입니다.

방법 1: useEffect 안에서 접근useEffect는 브라우저에서만 실행됩니다.

import { useState, useEffect } from 'react';

export default function MyPage() {
  const [width, setWidth] = useState(0);
  useEffect(() => {
    setWidth(window.innerWidth);
  }, []);
  return <div>{width}</div>;
}

방법 2: next/dynamic으로 SSR 비활성화

import dynamic from 'next/dynamic';

const BrowserOnly = dynamic(
  () => import('../components/BrowserOnly'),
  { ssr: false }
);

방법 3: typeof window !== 'undefined' 가드 — 단, Hydration Mismatch를 유발할 수 있으므로 주의가 필요합니다.

4. App Router의 렌더링 모델

구조: Server Component가 기본값

App Router에서는 Server Component가 기본 컴포넌트 타입입니다. Server Component는 서버에서 렌더링되며, 직접 데이터를 가져올 수 있고, 클라이언트 JavaScript 번들에 추가되지 않습니다. state나 브라우저 API를 사용할 수 없습니다. (공식 문서: App Router Glossary)

Client Component는 파일 상단에 'use client' 지시어를 선언하여 opt-in합니다.

핵심 특성: Server Component의 JS는 클라이언트에 전송되지 않는다

이것이 Pages Router와의 결정적 차이입니다. 이 차이를 이해하려면 App Router가 서버에서 만들어내는 두 가지 별개의 산출물을 구분해야 합니다.

RSC Payload: HTML이 아닌 React 트리의 직렬화

Server Component를 실행하면 그 결과가 곧바로 HTML이 되는 것이 아닙니다. 먼저 RSC Payload(React Server Component Payload)라는 특수한 직렬화 형식으로 변환됩니다. RSC Payload는 HTML이 아니라, Server Component 함수를 실행한 결과인 React 엘리먼트 트리를 직렬화한 것입니다.

"RSC Payload는 렌더링된 React Server Component 트리의 압축된 바이너리 표현이다." — Next.js 공식 문서, Server and Client Components
"SSR은 React 컴포넌트를 HTML로 Pre-render하는 것이고, RSC는 React 컴포넌트를 RSC Payload로 직렬화하는 것이다." — The Gnar Company, React Server Components Example with Next.js

이 RSC Payload에는 다음이 포함됩니다.

  • Server Component의 렌더링 결과 (React 엘리먼트 트리를 직렬화한 것이며, HTML이 아님)
  • Client Component가 렌더링될 위치에 대한 플레이스홀더와 해당 JavaScript 파일에 대한 참조
  • Server Component에서 Client Component로 전달되는 props

HTML: RSC Payload로부터 생성되는 프리뷰

Next.js는 이 RSC Payload와 Client Component JavaScript 정보를 조합하여 HTML을 생성합니다. 이 HTML은 사용자가 빠르게 화면을 볼 수 있도록 하기 위한 비인터랙티브 프리뷰입니다.

"React는 Server Component를 RSC Payload라는 특수한 데이터 형식으로 렌더링한다. Next.js는 RSC Payload와 Client Component JavaScript 지시사항을 사용하여 서버에서 HTML을 렌더링한다." — Next.js 공식 문서, Server Components (v14)

App Router의 전달 흐름

이 두 산출물을 기반으로 한 전체 흐름은 다음과 같습니다. 이 흐름은 Prerendering(빌드 시점)이든 Dynamic Rendering(요청 시점)이든 동일하게 적용되며, 차이는 이 과정이 언제 실행되느냐뿐입니다.

[서버] — Prerendering이면 빌드 시점에, Dynamic Rendering이면 요청 시점에 실행
  ① Server Component 실행 → RSC Payload 생성 (React 트리의 직렬화)
  ② RSC Payload + Client Component 정보 → HTML 생성
       ↓
[클라이언트로 전송]
  HTML + RSC Payload + Client Component JS 번들
       ↓
[클라이언트]
  ③ HTML을 받아 즉시 화면에 표시 (비인터랙티브 프리뷰)
  ④ RSC Payload를 읽어 React 트리 구조를 파악
     (Server Component의 위치, Client Component의 플레이스홀더 위치)
  ⑤ Client Component JS로 플레이스홀더 위치에 Hydration 수행
산출물형식역할
HTML순수 마크업초기 로드 시 빠른 프리뷰 표시 (사용자가 빨리 볼 수 있게)
RSC Payload직렬화된 React 트리React가 컴포넌트 트리를 재구성하고 DOM을 조정 (React가 트리를 이해할 수 있게)
Client JSJavaScript 번들Client Component만 Hydrate하여 인터랙티브하게 만듦

RSC Payload가 함께 전달되기 때문에 React는 "이 HTML의 어느 부분이 Server Component의 결과이고, 어느 부분에 Client Component가 들어가야 하는지"를 정확히 알 수 있고, 그래서 Client Component만 선택적으로 Hydration할 수 있습니다. Server Component는 이미 RSC Payload로 렌더링 결과가 확정되어 있으므로 Hydration이 필요 없고, 따라서 해당 JS 코드가 클라이언트에 전송되지 않습니다.

스트리밍 렌더링

RSC Payload는 스트리밍 여부와 관계없이 항상 클라이언트에 전송됩니다. 스트리밍은 RSC의 필수 조건이 아니라 전달 방식의 최적화입니다.

  • 스트리밍 없이: RSC Payload가 한 번에 전송되고 클라이언트가 즉시 렌더링
  • 스트리밍 활성화 시: <Suspense> 경계를 기준으로 준비된 부분부터 점진적으로 전달

Pages Router에서는 페이지 전체가 렌더링 완료될 때까지 기다린 후 한 번에 응답하는 구조였지만, App Router는 Prerendering된 정적 셸을 먼저 보내고, Dynamic Rendering이 필요한 부분은 준비되는 대로 스트리밍합니다.

'use client'의 의미

'use client'는 "이 컴포넌트는 클라이언트에서만 실행하라"는 의미가 아닙니다. 서버와 클라이언트 모듈 그래프 간의 경계를 선언하는 것입니다. 해당 파일에 'use client'가 선언되면, 그 파일의 모든 import와 자식 컴포넌트가 클라이언트 번들의 일부로 간주됩니다.

Client Component도 서버에서 렌더링됩니다(Prerendering 또는 Dynamic Rendering 시점에 HTML 생성에 포함됨). 따라서 Client Component의 렌더 함수에서 window에 직접 접근하면 동일하게 에러가 발생합니다.

window is not defined 발생 시나리오

시나리오 1: Server Component에서 접근 (구조적으로 불가능)

// app/page.tsx — 기본적으로 Server Component
export default function Page() {
  console.log(window.innerWidth);
  // 이 코드는 브라우저에서 절대 실행되지 않음
  return <div>Hello</div>;
}

이것은 타이밍 문제가 아니라, 해당 코드가 브라우저에서 실행될 기회 자체가 없는 것입니다.

시나리오 2: Client Component 렌더 함수에서 접근 (서버 렌더링 시 에러)

'use client';

export default function MyComponent() {
  const width = window.innerWidth; // 서버에서 HTML 생성 시 에러
  return <div>{width}</div>;
}

해결 방법은 Pages Router와 동일하게 useEffect 내부에서 접근하는 것입니다.

5. App Router의 두 가지 독립적인 축

App Router에는 서로 다른 문제를 해결하는 두 가지 독립적인 축이 존재합니다. 이 두 축을 혼동하면 구조를 정확히 이해할 수 없습니다.

축 1: Server Component vs Client Component — "JS를 클라이언트에 보낼 것인가"

이것은 번들 크기와 코드 실행 환경의 문제입니다. Server Component의 JS는 클라이언트에 전송되지 않고 RSC Payload로 결과만 전달됩니다. Client Component의 JS는 전송되어 Hydration됩니다. 'use client' 지시어로 경계를 결정합니다.

축 2: Prerendering vs Dynamic Rendering — "서버 렌더링을 언제 실행할 것인가"

이것은 렌더링 시점의 문제입니다. 빌드 시점에 미리 만들 수 있는가(Prerendering), 아니면 요청이 와야 만들 수 있는가(Dynamic Rendering). cookies(), headers() 같은 요청 시점 API 사용 여부로 결정됩니다.

두 축은 독립적으로 조합된다

Server Component라고 해서 반드시 Prerendering(정적)인 것이 아니고, Client Component라고 해서 반드시 Dynamic Rendering인 것이 아닙니다. 예를 들어, cookies()를 사용하는 Server Component는 서버에서만 실행되고 JS가 전송되지 않지만(Server Component), 요청 시점에 렌더링됩니다(Dynamic Rendering).

Prerendering (빌드 시점)Dynamic Rendering (요청 시점)
Server Component빌드 시 RSC Payload 생성, JS 미전송요청 시 RSC Payload 생성, JS 미전송
Client Component빌드 시 HTML 생성, JS 전송 후 Hydration요청 시 HTML 생성, JS 전송 후 Hydration

Server/Client 구분은 "이 컴포넌트의 JS 코드가 브라우저에 가는가"를 결정하고, Prerendering/Dynamic 구분은 "이 컴포넌트의 렌더링 결과가 언제 만들어지는가"를 결정합니다.

6. Partial Prerendering(PPR)과 Cache Components

PPR 이전의 문제: 라우트 전체가 Dynamic Rendering으로 전환

PPR이 도입되기 전(Next.js 13~15 기본 설정), cookies()headers() 같은 Dynamic 함수가 라우트 어딘가에서 호출되면 해당 라우트 전체가 Dynamic Rendering으로 전환되었습니다. 이것은 중첩 깊이와 무관했습니다.

"렌더링 중에 Dynamic 함수나 캐시되지 않은 데이터 요청이 발견되면, Next.js는 라우트 전체를 Dynamic Rendering으로 전환한다." — Next.js 공식 문서, Server Components (v14)
PPR 이전: cookies() 하나가 라우트 전체를 Dynamic으로 전환
┌─────────────────────────────────────────────────┐
│ Header        (정적이지만 Dynamic Rendering됨)   │
│ ProductInfo   (정적이지만 Dynamic Rendering됨)   │
│ UserSection                                     │
│ └── UserGreeting                                │
│     └── cookies() 호출 ← 이것 때문에 전체 Dynamic │
└─────────────────────────────────────────────────┘

PPR의 등장: 정적 셸과 Dynamic 구멍의 공존

Partial Prerendering(PPR)은 이 문제를 해결하기 위해 도입되었습니다. 하나의 라우트 안에서 Prerendering(정적 셸)과 Dynamic Rendering(동적 구멍)이 공존할 수 있게 만들었습니다. <Suspense> 경계가 "여기까지는 Prerendering, 여기부터는 Dynamic Rendering"을 구분하는 역할을 합니다.

"PPR 이전에는 Next.js가 각 URL을 정적으로 렌더링할지 동적으로 렌더링할지 선택해야 했으며, 중간 지대가 없었다. PPR은 이 이분법을 없애고, 개발자가 정적 페이지의 일부분을 Suspense를 통해 동적 렌더링으로 전환할 수 있게 했다." — Next.js 16 릴리스 노트
"<User /> 컴포넌트는 cookies API를 사용하기 때문에 동적이다. <User /> 컴포넌트는 스트리밍되고, <Page /> 내의 다른 모든 콘텐츠는 Prerendering되어 정적 셸의 일부가 된다." — Next.js 공식 문서, Partial Prerendering (v15)
PPR 이후: Suspense 경계로 Dynamic 부분만 격리
┌─ 정적 셸 (Prerendering, 빌드 시점에 완성)
│  ├─ Header                  → 정적 셸에 포함
│  ├─ ProductInfo             → 정적 셸에 포함
│  └─ <Suspense fallback>     → fallback이 정적 셸에 포함
│
└─ Dynamic 구멍 (Dynamic Rendering, 요청 시점에 실행)
   └─ UserGreeting (cookies()) → 스트리밍으로 전달

Next.js 16: PPR에서 Cache Components로의 통합

Next.js 16에서 실험적 PPR 플래그(experimental.ppr, experimental_ppr)는 제거되었습니다. PPR이라는 개념 자체는 그대로 존재하지만, Cache Components라는 더 포괄적인 모델 안에 통합되었습니다.

"Next.js 16은 실험적 Partial Prerendering(PPR) 플래그와 설정 옵션을 제거했다. Next.js 16부터는 cacheComponents 설정을 사용하여 PPR에 opt-in할 수 있다." — Next.js 공식 문서, Upgrading to Version 16

Cache Components의 핵심 변화는 캐싱이 전적으로 opt-in이 되었다는 것입니다. 이전 App Router에서는 fetch가 기본적으로 캐시되는 등 암묵적 캐싱이 있어 혼란을 줬지만, Cache Components에서는 모든 동적 코드가 기본적으로 요청 시점에 실행되고, 개발자가 'use cache' 지시어로 명시적으로 캐싱을 선택합니다.

"이전 버전 App Router의 암묵적 캐싱과 달리, Cache Components의 캐싱은 전적으로 opt-in이다. 모든 동적 코드는 기본적으로 요청 시점에 실행된다." — Next.js 16 릴리스 노트

활성화 방법은 다음과 같습니다.

// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
};

Cache Components의 동작 원리: 'use cache'와 \<Suspense\>

Cache Components에서는 빌드 시점에 Next.js가 라우트의 컴포넌트 트리를 렌더링하며, 각 컴포넌트가 어떻게 처리되는지는 사용하는 API에 따라 달라집니다.

"빌드 시점에 Next.js가 라우트의 컴포넌트 트리를 렌더링한다. use cache의 결과는 캐시되어 정적 셸에 포함된다. <Suspense>는 fallback UI가 정적 셸에 포함되고 실제 콘텐츠는 요청 시점에 스트리밍된다. 순수 계산이나 모듈 import 같은 결정적 연산은 자동으로 정적 셸에 포함된다." — Next.js 공식 문서, Cache Components

컴포넌트를 'use cache'로 캐싱할 수 있다는 것은 요청 전에 결과를 확정할 수 있다는 것이고, 이는 곧 Prerendering(정적 셸)에 포함된다는 것을 의미합니다. 반대로, cookies() 같은 요청 시점 API를 사용하는 컴포넌트는 <Suspense>로 감싸서 Dynamic 구멍으로 격리합니다. 이 조합이 PPR이며, Cache Components의 기본 동작입니다.

"캐싱은 컴포넌트 또는 함수 레벨에서 적용할 수 있고, fallback UI는 어떤 하위 트리 주위에든 정의할 수 있다. 이는 하나의 라우트 안에서 정적, 캐시된, 동적 콘텐츠를 조합할 수 있다는 것을 의미한다. 이 렌더링 접근 방식을 Partial Prerendering이라고 하며, Cache Components의 기본 동작이다." — Next.js 공식 문서, Cache Components

'use client'가 "이 컴포넌트의 JS를 클라이언트 번들에 포함해라"라는 명시적 선언이듯, 'use cache'는 "이 부분의 결과를 캐시하여 Prerendering에 포함해라"라는 명시적 선언입니다. 두 지시어 모두 기본값은 아무것도 하지 않는 것이고, 개발자가 의식적으로 opt-in하는 구조입니다.

PPR에서 캐시 무효화 시 동작

캐시가 무효화(revalidation)되더라도 PPR의 정적 셸 구조는 깨지지 않습니다. revalidateTag는 stale-while-revalidate(SWR) 방식으로 동작합니다.

"revalidateTag는 stale-while-revalidate 방식으로 태그별 캐시 항목을 무효화한다. 오래된(stale) 콘텐츠가 즉시 제공되면서, 백그라운드에서 새로운 콘텐츠가 로드된다." — Next.js 공식 문서, Revalidating

캐시가 무효화되면 기존 캐시된(오래된) 콘텐츠가 즉시 제공되어 정적 셸이 유지되고, 백그라운드에서 새로운 콘텐츠가 생성되어 캐시가 갱신됩니다. 정적 셸에서 Dynamic Rendering으로의 전환은 일어나지 않습니다.

캐시 수명이 매우 짧은 경우(seconds 프로필, revalidate: 0, expire 5분 미만)에는 해당 부분이 Prerendering에서 제외되어 Dynamic 구멍이 됩니다. 하지만 이 경우에도 라우트 전체가 Dynamic Rendering으로 전환되지는 않습니다. 해당 캐시 부분만 정적 셸에서 빠져나와 Dynamic 구멍이 되고 나머지 정적 셸은 유지됩니다.

"Short-lived 캐시는 자동으로 Prerender에서 제외되어 Dynamic 구멍(dynamic holes)이 된다." — Next.js 공식 문서, Revalidating
캐시 무효화 시:
┌─ 정적 셸 (유지됨)
│  ├─ 순수 정적 콘텐츠           → 그대로 유지
│  ├─ 'use cache' (무효화됨)     → stale 콘텐츠 즉시 제공, 백그라운드 재생성
│  └─ <Suspense> fallback        → 그대로 유지
└─ Dynamic 구멍 (변화 없음)
   └─ cookies() 등                → 원래부터 요청 시점에 스트리밍

Short-lived 캐시 (5분 미만) 시:
┌─ 정적 셸 (유지됨)
│  ├─ 순수 정적 콘텐츠           → 그대로 유지
│  └─ <Suspense> fallback        → 그대로 유지
└─ Dynamic 구멍 (확대됨)
   ├─ 'use cache' (short-lived)  → Prerender에서 제외, Dynamic 구멍으로 전환
   └─ cookies() 등                → 원래부터 Dynamic

이전 모델과의 대응 관계

Next.js 15 이전Next.js 16 (Cache Components)
experimental: { ppr: 'incremental' }cacheComponents: true
export const experimental_ppr = true제거됨 (전체 적용)
fetch의 암묵적 캐싱기본 동적, 'use cache'로 명시적 캐싱
unstable_cache()'use cache' 지시어로 대체
export const dynamic = 'force-dynamic'deprecated (cacheComponents와 호환되지 않아 제거 필요)

7. 핵심 차이 비교표

구분Pages RouterApp Router
기본 컴포넌트 타입Client ComponentServer Component
서버 렌더링 용어Pre-rendering (SSG + SSR 모두 포함)Server Rendering (Prerendering + Dynamic Rendering)
요청 시점 렌더링의 명칭Pre-rendering (SSR)Dynamic Rendering
렌더링 단위페이지 단위 (SSG 또는 SSR 택 1)컴포넌트 단위 (PPR로 혼합 가능)
서버 산출물HTML 1개HTML + RSC Payload 2개
정적 페이지의 JS 번들전송됨 (Hydration 필요)Server Component는 전송 안 됨, Client Component만
Hydration 범위모든 컴포넌트Client Component만 (RSC Payload로 위치 파악)
클라이언트 전달 형식HTML + JS 번들HTML + RSC Payload + Client Component JS
window 에러 맥락서버 렌더링 시 일시적으로 접근 불가Server Component에서는 구조적으로 접근 불가
스트리밍 렌더링미지원 (전체 완료 후 응답)<Suspense> 기반 점진적 스트리밍 지원
Dynamic 함수 사용 시 영향해당 없음 (SSR은 getServerSideProps로 명시적 선택)<Suspense> 내부만 Dynamic (PPR)
캐싱 모델암묵적 (SSG 기본 캐시)명시적 ('use cache' opt-in)

8. App Router의 설계 목적

App Router는 두 가지 핵심 문제를 해결하기 위해 설계되었습니다.

첫째, 클라이언트에 전송되는 JavaScript를 최소화하는 것입니다. Pages Router에서는 서버가 HTML이라는 단일 산출물만 만들고, Hydration을 위해 모든 컴포넌트의 JS를 전송해야 했습니다. App Router는 RSC Payload라는 두 번째 산출물을 도입하여, Server Component의 JS를 클라이언트에 보내지 않고도 React 트리를 유지할 수 있게 만들었습니다. 'use client'는 "이 부분의 JS를 클라이언트에 보내라"는 명시적 opt-in입니다.

둘째, 서버 렌더링에서 정적 부분과 동적 부분을 컴포넌트 단위로 혼합하는 것입니다. 이전에는 cookies() 하나 때문에 라우트 전체가 Dynamic Rendering이 되어야 했습니다. PPR(현재 Cache Components로 통합)은 정적 셸을 최대한 유지하면서 Dynamic Rendering 부분을 최소화합니다. 'use cache'는 "이 부분의 결과를 캐시하여 정적 셸에 포함해라"는 명시적 opt-in이고, 캐시가 무효화될 때에도 SWR 방식으로 정적 셸 구조를 유지하며, 캐시 수명이 짧은 경우에도 해당 부분만 Dynamic 구멍이 될 뿐 라우트 전체가 전환되지는 않습니다.

이 두 축('use client''use cache')은 각각 독립적으로 동작하며, 조합하여 번들 크기와 렌더링 시점 모두를 세밀하게 제어할 수 있습니다.

9. 실무 적용: Server Component의 이점을 유지하는 전략

이상과 현실

이상적으로는 정적인 부분을 Server Component로 유지하고 인터랙티브한 부분만 Client Component로 분리하는 것이 좋습니다. 하지만 현실의 웹 애플리케이션은 대시보드, 폼, 필터, 모달 등 인터랙티브 요소가 많아, 대부분이 Client Component가 될 수 있습니다.

State 끌어올리기의 문제

자식 컴포넌트들이 상태를 공유할 때 공통 부모로 state를 올리면, 그 부모에 useState가 필요해지고 'use client'를 선언해야 합니다. 이때 해당 파일의 모든 import와 자식 컴포넌트가 클라이언트 번들에 포함되므로, Server Component의 이점이 사라집니다.

해결 전략: 외부 캐시를 통한 상태 공유

React Query(TanStack Query) 같은 서버 상태 관리 라이브러리를 활용하면, 각 Client Component가 독립적으로 같은 query key를 통해 데이터에 접근할 수 있습니다. 데이터 공유를 위해 부모로 state를 끌어올릴 필요가 없습니다.

React Query를 활용한 구조:
Layout         (Server Component)   ← JS 전송 안 됨
  ├── Sidebar  (Server Component)   ← JS 전송 안 됨
  ├── Header   (Server Component)   ← JS 전송 안 됨
  ├── StatsChart   (Client)   ← useQuery('dashboard-stats')
  ├── FilterPanel  (Client)   ← useQuery('dashboard-stats')
  └── DataTable    (Client)   ← useQuery('dashboard-stats')

각 Client Component가 leaf 노드로 독립 존재하면서 React Query 캐시를 통해 데이터를 공유합니다. Layout, Sidebar, Header는 Server Component로 유지되어 JS가 전송되지 않습니다.

State 끌어올리기를 사용한 구조:
Layout         (Client Component로 변환)   ← 전체 JS 전송
  ├── Sidebar          ← 클라이언트 번들에 포함
  ├── Header           ← 클라이언트 번들에 포함
  ├── StatsChart
  ├── FilterPanel
  └── DataTable

이 경우 App Router를 쓰는 의미가 크게 줄어듭니다.

마치며

App Router는 'use client'로 JS 번들 전송 범위를 제어하고, 'use cache'로 Prerendering 범위를 제어하는 두 가지 독립적인 opt-in 축을 가집니다. Server Rendering을 Prerendering과 Dynamic Rendering으로 세분화하고, RSC Payload라는 중간 표현을 도입하여 Server Component의 JS를 클라이언트에 보내지 않고도 React 트리를 유지하며, PPR(Cache Components)을 통해 정적 셸을 최대한 유지하면서 Dynamic Rendering 부분을 최소화하는 것이 구조적 핵심입니다.

Pages Router와 App Router는 단순한 API 차이가 아니라 렌더링 모델 자체가 다릅니다. 같은 용어("Pre-rendering")가 서로 다른 범위를 가리키는 것도 그 때문입니다. "이 컴포넌트의 JS는 어디로 가는가"와 "이 컴포넌트의 렌더링은 언제 일어나는가"를 분리해서 보는 시각이 App Router를 정확히 이해하는 출발점입니다.

참고