dev.syw

RSC 직렬화와 스트리밍 SSR이 화면을 그리는 원리를 해부한다.

App Router 렌더링 내부 구조와 RSC 페이로드

입문편에서 서버 컴포넌트와 클라이언트 컴포넌트의 차이, 그리고 SSR·ISR·SSG 렌더링 전략을 선택하는 방법을 익혔습니다. 그런데 실제로 브라우저에 HTML이 도착하기까지 서버에서 어떤 일이 벌어지는지, 네트워크를 통해 무엇이 오고가는지, hydration이 실패하는 근본 원인이 무엇인지는 설명하지 않았습니다. 이 강좌는 바로 그 "내부"를 다룹니다.

React 18의 RSC(React Server Components) 페이로드 포맷, 스트리밍 SSR, Suspense 경계와 HTML 플러시 순서, hydration 과정과 mismatch 원인, Router Cache와 소프트 내비게이션에서 페이로드가 어떻게 재사용되는지까지 단계별로 해부합니다. 이 원리를 이해하면 성능 병목을 직관적으로 찾고, 예기치 않은 버그를 근거를 가지고 수정할 수 있습니다.

학습 목표

  • **RSC 페이로드(wire format)**가 어떤 구조로 직렬화되는지 이해하고 실제 네트워크 탭에서 읽을 수 있다.
  • 서버/클라이언트 경계가 번들 크기와 네트워크 왕복에 미치는 영향을 설명하고, 'use client' 경계를 최소화하는 패턴을 적용할 수 있다.
  • 스트리밍 SSR과 Suspense 경계가 만드는 점진적 HTML 플러시 순서를 그림으로 추론할 수 있다.
  • hydration mismatch의 근본 원인을 파악하고 직렬화 불가능한 prop 전달 실수를 방지할 수 있다.
  • Router Cache와 소프트 내비게이션 시 RSC 페이로드 재사용 및 프리페치 동작을 설명할 수 있다.

RSC 페이로드 포맷 — 와이어 프로토콜 읽기

Next.js App Router는 서버 컴포넌트를 HTML이 아닌 RSC 페이로드라는 별도 포맷으로 직렬화합니다. 이 페이로드는 React Flight 프로토콜(이하 "Flight")을 기반으로 하며, .json도 HTML도 아닌 줄 단위 청크 스트림입니다.

브라우저에서 __NEXT_DATA__/_next/static/chunks/ 경로가 아니라, 소프트 내비게이션(클라이언트 라우팅) 시 요청되는 ?_rsc=... URL의 응답을 네트워크 탭에서 열어보면 다음과 같은 형태를 볼 수 있습니다.

0:"$L1"
1:["$","main",null,{"children":["$","h1",null,{"children":"안녕하세요"}]}]
2:I{"id":"./src/components/Counter.tsx","chunks":["app/page"],"name":"Counter","async":false}
3:["$","div",null,{"children":[["$","$L2",null,{}]]}]

각 줄의 구조는 <행번호>:<타입접두사><직렬화된 값> 입니다.

접두사의미
"$L..."다른 행을 참조하는 lazy reference
I{...}클라이언트 컴포넌트 메타데이터 (Client Reference)
["$","태그",key,props]React element 표현
S"..."문자열 상수

💡 TIP 크롬 DevTools → Network 탭 → Fetch/XHR 필터 → 페이지 이동 → _rsc= 쿼리가 붙은 요청 선택 → Response 탭. 이것이 RSC 페이로드의 실제 모습입니다.

서버 컴포넌트 트리 전체가 이 포맷으로 직렬화되고, 클라이언트 컴포넌트를 만나는 지점에서 I{...} 참조 레코드로 대체됩니다. 클라이언트는 이 레코드를 보고 해당 JS 청크를 로드한 뒤 컴포넌트를 마운트합니다.

// app/page.tsx — 서버 컴포넌트 (기본값)
import { ClientCounter } from "@/components/ClientCounter";

export default async function Page() {
  const data = await fetch("https://api.example.com/items").then(r => r.json());

  return (
    <main>
      <h1>아이템 목록</h1>
      {/* 서버에서 렌더링된 리스트 — RSC 페이로드에 직접 포함 */}
      <ul>
        {data.map((item: { id: number; name: string }) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
      {/* 클라이언트 컴포넌트 — I{} 참조 레코드로 직렬화됨 */}
      <ClientCounter initialCount={0} />
    </main>
  );
}
// components/ClientCounter.tsx
"use client";

import { useState } from "react";

interface Props {
  initialCount: number; // ✅ 직렬화 가능한 number
}

export function ClientCounter({ initialCount }: Props) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount(c => c + 1)}>카운트: {count}</button>;
}

서버/클라이언트 경계와 번들 영향 — 'use client' 최소화

'use client'는 그 파일과 그 파일이 import하는 모든 모듈을 클라이언트 번들에 포함시킵니다. 이 경계를 잘못 그으면 서버에서만 쓰이면 충분한 코드가 번들로 유출됩니다.

// ❌ 나쁜 예 — 큰 컴포넌트 전체를 클라이언트 컴포넌트로 만들기
"use client";
import HeavyChart from "heavy-chart-lib"; // 200 kB

export default function Dashboard({ data }: { data: ChartData[] }) {
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <div>
      {/* data 가공 로직도 번들에 포함됨 */}
      <HeavyChart data={data} onSelect={setSelected} />
      <p>선택됨: {selected}</p>
    </div>
  );
}
// ✅ 좋은 예 — 상태를 필요로 하는 최소 부분만 클라이언트 컴포넌트로 분리
// components/ChartSelector.tsx
"use client";

export function ChartSelector({ onSelect }: { onSelect: (id: string) => void }) {
  return <button onClick={() => onSelect("A")}>A 선택</button>;
}
// app/dashboard/page.tsx — 서버 컴포넌트
import HeavyChart from "heavy-chart-lib"; // 서버에서만 실행, 번들에 포함 안 됨
import { ChartSelector } from "@/components/ChartSelector";

export default async function Dashboard() {
  const data = await fetchChartData();

  return (
    <div>
      <HeavyChart data={data} /> {/* 서버에서 HTML로 렌더링 */}
      <ChartSelector onSelect={/* 서버 액션 또는 URL 기반 */ updateSelection} />
    </div>
  );
}

⚠️ 주의 'use client' 경계 아래에서는 서버 전용 모듈(server-only 패키지, DB 연결 등)을 import해도 번들에 포함되려 시도하므로 빌드 에러나 시크릿 노출이 발생합니다. server-only 패키지로 실수를 컴파일 타임에 잡으세요.

직렬화 불가능한 prop 전달 — 함수와 클래스

RSC 페이로드는 React Flight가 직렬화할 수 있는 값(원시값, 배열/객체, Date, BigInt, Map, Set, TypedArray, Promise, 서버 액션 등)을 서버 → 클라이언트로 전달할 수 있습니다. 따라서 Map·Set도 prop으로 전달할 수 있습니다. 반면 일반 함수(서버 액션 제외), 클래스 인스턴스, Symbol 등은 직렬화 불가능합니다.

// ❌ 서버 컴포넌트에서 함수 prop 전달 시도
export default async function Page() {
  const handleClick = () => console.log("클릭");
  // Error: Functions cannot be passed directly to Client Components
  return <ClientButton onClick={handleClick} />;
}

함수를 전달해야 한다면 서버 액션으로 감싸거나, 이벤트 처리 로직 전체를 클라이언트 컴포넌트 내부로 옮깁니다.

// ✅ 서버 액션을 prop으로 전달
"use server";
async function logClick() {
  console.log("서버에서 처리");
}

// app/page.tsx
export default function Page() {
  return <ClientButton action={logClick} />;
}
// components/ClientButton.tsx
"use client";
export function ClientButton({ action }: { action: () => Promise<void> }) {
  return <button formAction={action}>클릭</button>;
}

스트리밍 SSR과 Suspense 경계 — 점진적 HTML 플러시

Next.js App Router는 React 18의 renderToPipeableStream을 사용해 HTML을 청크 단위로 스트리밍합니다. Suspense 경계가 없으면 모든 async 작업이 끝날 때까지 첫 바이트가 전송되지 않습니다.

[요청][서버]1. Shell(레이아웃+Suspense fallback) → 즉시 플러시
        ↓
  2. SlowComponent 데이터 완료 → <template> 청크 추가 플러시
        ↓
  3. 브라우저 JS가 template을 Suspense placeholder와 교체
// app/page.tsx
import { Suspense } from "react";
import { ProductList } from "@/components/ProductList";
import { RecommendationPanel } from "@/components/RecommendationPanel";

export default function Page() {
  return (
    <main>
      <h1>쇼핑몰</h1>

      {/* 빠른 컴포넌트 — shell에 포함되어 즉시 전송 */}
      <Suspense fallback={<p>상품 목록 로딩 중...</p>}>
        <ProductList /> {/* DB 쿼리 ~100ms */}
      </Suspense>

      {/* 느린 컴포넌트 — 나중에 스트리밍 */}
      <Suspense fallback={<p>추천 로딩 중...</p>}>
        <RecommendationPanel /> {/* ML 추론 ~800ms */}
      </Suspense>
    </main>
  );
}
// components/ProductList.tsx — 서버 컴포넌트
export async function ProductList() {
  const products = await db.query("SELECT * FROM products LIMIT 20");
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

💡 TIP Suspense 경계를 중첩할수록 더 세밀하게 스트리밍 우선순위를 제어할 수 있습니다. 단, 경계가 너무 많으면 레이아웃 시프트(CLS)가 발생하므로 placeholder 크기를 실제 컨텐츠와 비슷하게 맞추는 것이 중요합니다.

스트리밍 HTML 청크의 실제 구조

브라우저로 전송되는 초기 HTML 청크를 단순화하면 다음과 같습니다.

<!-- 1차 플러시: shell -->
<!DOCTYPE html>
<html>
<body>
  <main>
    <h1>쇼핑몰</h1>
    <!--$?-->
    <template id="B:0"></template>
    <p>상품 목록 로딩 중...</p>
    <!--/$-->
    <!--$?-->
    <template id="B:1"></template>
    <p>추천 로딩 중...</p>
    <!--/$-->
  </main>
HTML
<!-- 2차 플러시: ProductList 완료 후 추가 전송 -->
<div hidden id="S:0">
  <ul><li>상품 A</li><li>상품 B</li></ul>
</div>
<script>
  // React가 template B:0을 S:0 내용으로 교체
  $RC("B:0", "S:0")
</script>
HTML

React Reconciliation·Hydration과 Mismatch의 근본 원인

서버가 HTML을 보내면 브라우저의 React는 hydration 단계에서 그 DOM과 자신의 가상 DOM을 비교(reconcile)합니다. 서버 HTML과 클라이언트 렌더링 결과가 다르면 Hydration failed 에러가 발생합니다.

흔한 mismatch 원인

원인예시
Date.now() / Math.random()서버 시간 ≠ 클라이언트 시간
typeof window 분기서버에서 undefined, 클라이언트에서 object
브라우저 전용 APIlocalStorage, navigator 서버에서 접근 시도
잘못된 HTML 중첩<p> 안에 <div> → 브라우저 자동 수정
로케일 의존 포맷팅toLocaleString()이 서버/클라이언트 환경에서 다른 결과
// ❌ hydration mismatch 유발
"use client";
export function Timestamp() {
  // 서버 렌더 시점과 클라이언트 hydration 시점의 값이 다름
  return <span>{new Date().toISOString()}</span>;
}
// ✅ useEffect로 클라이언트 전용 렌더링
"use client";
import { useState, useEffect } from "react";

export function Timestamp() {
  const [time, setTime] = useState<string | null>(null);

  useEffect(() => {
    setTime(new Date().toISOString());
  }, []);

  if (!time) return <span>--</span>; // 서버와 첫 클라이언트 렌더 일치
  return <span>{time}</span>;
}
// ✅ suppressHydrationWarning — DOM이 의도적으로 다를 때만 사용
export function ClientOnlyTime() {
  return (
    <time suppressHydrationWarning>
      {typeof window !== "undefined" ? new Date().toLocaleTimeString() : ""}
    </time>
  );
}

⚠️ 주의 suppressHydrationWarning은 해당 엘리먼트 하나에만 적용됩니다. 트리 전체를 억제하는 방법은 없으며, 잦은 사용은 실제 버그를 숨길 수 있습니다.

Hydration 과정 단계별 이해

1. 서버 → HTML 스트리밍 → 브라우저 DOM 구성
2. JS 번들 로드 (React runtime + 클라이언트 컴포넌트)
3. React.hydrateRoot() 호출
4. 가상 DOM 구성 (서버와 동일한 props로 렌더링)
5. 실제 DOM과 가상 DOM 비교 (reconciliation)
6. 이벤트 핸들러 부착 → 인터랙티브 상태

서버 컴포넌트는 이 과정에서 재실행되지 않습니다. hydration은 클라이언트 컴포넌트만 대상으로 하며, 서버 컴포넌트가 생성한 DOM은 그대로 재사용됩니다.

Router Cache와 소프트 내비게이션 — RSC 페이로드 재사용

Next.js App Router는 두 가지 클라이언트 측 캐시를 운영합니다.

캐시저장 위치대상만료
Router Cache메모리 (탭 생명주기)RSC 페이로드동적(기본): 0s, 정적: 5min
Full Route CacheCDN/서버 파일시스템렌더링된 HTML+RSCrevalidate 설정에 따름

⚠️ 주의 Next.js 15부터 프리페치된 동적 페이지의 기본 staleTime이 0초로 변경되어, 동적 페이지는 기본적으로 30초 동안 Router Cache에 머무르지 않습니다(이 프로젝트는 Next.js 16.2.7 기준). 과거 13~14의 30초 동작이 필요하면 next.configexperimental.staleTimes.dynamic 값을 조정해 캐시 유지 시간을 설정할 수 있습니다.

소프트 내비게이션(클라이언트 사이드 라우팅) 시 Next.js는 다음 순서로 동작합니다.

1. Router Cache에 해당 경로의 RSC 페이로드가 있으면 → 즉시 사용 (서버 요청 없음)
2. 없으면 → 서버에 RSC 페이로드 요청 (`RSC: 1` 헤더와 `?_rsc=<hash>` 쿼리)
3. 응답을 Router Cache에 저장
4. React가 페이로드를 적용해 클라이언트 트리 업데이트

프리페치 동작

<Link> 컴포넌트는 뷰포트에 들어오는 순간 해당 경로의 RSC 페이로드를 미리 요청합니다.

// app/layout.tsx
import Link from "next/link";

export default function Nav() {
  return (
    <nav>
      {/* prefetch={true} — 기본값, 정적 경로는 전체 페이로드 프리페치 */}
      <Link href="/about">소개</Link>

      {/* prefetch={false} — 프리페치 비활성화 (인증 필요 페이지 등) */}
      <Link href="/dashboard" prefetch={false}>대시보드</Link>
    </nav>
  );
}
// 프로그래매틱 프리페치
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

export function PrefetchOnHover({ href }: { href: string }) {
  const router = useRouter();
  return (
    <button
      onMouseEnter={() => router.prefetch(href)}
      onClick={() => router.push(href)}
    >
      이동
    </button>
  );
}

💡 TIP router.refresh()를 호출하면 Router Cache의 현재 경로 항목이 무효화되고 서버에서 새 RSC 페이로드를 가져옵니다. 서버 액션 완료 후 데이터를 갱신할 때 자주 사용하는 패턴입니다.

Router Cache 수동 무효화

// 서버 액션에서 캐시 무효화
"use server";
import { revalidatePath, revalidateTag } from "next/cache";

export async function updateProduct(id: string, data: FormData) {
  await db.update("products", id, Object.fromEntries(data));

  // 특정 경로의 Full Route Cache(서버)를 무효화하고,
  // 다음 내비게이션 시 클라이언트 Router Cache도 함께 비웁니다.
  revalidatePath(`/products/${id}`);

  // 태그 기반 무효화
  revalidateTag("products");
}

요약

  • RSC 페이로드는 JSON이 아닌 React Flight 줄 스트림 형태로 직렬화되며, 클라이언트 컴포넌트는 I{} 참조 레코드로 표현됩니다.
  • 'use client' 경계는 해당 파일과 모든 하위 import를 클라이언트 번들에 포함시키므로, 상태·이벤트를 필요로 하는 최소 단위에만 선언해야 합니다.
  • 스트리밍 SSR은 Suspense 경계 단위로 HTML을 점진적으로 플러시하며, 느린 데이터 소스를 병렬로 처리해 TTFB를 단축합니다.
  • Hydration mismatch는 서버와 클라이언트 렌더링 결과가 다를 때 발생하며, 가장 흔한 원인은 Date.now(), Math.random(), typeof window 분기입니다.
  • RSC 페이로드는 Date·BigInt·Map·Set·TypedArray·Promise·서버 액션 등 React Flight가 지원하는 값을 전달할 수 있으나, 일반 함수·클래스 인스턴스·Symbol은 직렬화할 수 없으므로 함수 전달이 필요하면 서버 액션으로 감싸야 합니다.
  • Router Cache는 메모리 내 RSC 페이로드 캐시로, 소프트 내비게이션 속도를 높이고 router.refresh() 또는 revalidatePath()로 무효화할 수 있습니다.

연습문제

  1. 다음 코드에서 hydration mismatch가 발생하는 이유를 설명하고, 올바르게 수정하세요.
"use client";
export function RandomId() {
  return <span>ID: {Math.random().toString(36).slice(2)}</span>;
}

힌트 useEffectuseState를 조합해 클라이언트 마운트 이후에만 값을 설정하세요.

  1. 아래 서버 컴포넌트는 handler 함수를 클라이언트 컴포넌트에 직접 prop으로 넘기고 있습니다. 이 코드가 왜 에러를 발생시키는지 설명하고, 서버 액션을 활용해 수정하세요.
// app/page.tsx
import { ActionButton } from "@/components/ActionButton";

export default async function Page() {
  const handler = async () => {
    await db.insertLog("clicked");
  };
  return <ActionButton onClick={handler} />;
}

힌트 서버 액션은 파일 최상단에 'use server'를 선언하거나, 함수 내부에 'use server' 지시문을 추가해 정의할 수 있습니다.

  1. 다음 페이지에서 SlowComponent가 완료되기 전에도 FastComponent가 브라우저에 표시되도록 스트리밍 SSR을 적용하세요.
// app/page.tsx
export default async function Page() {
  return (
    <div>
      <FastComponent />
      <SlowComponent />
    </div>
  );
}

힌트 Suspense 경계를 어떤 컴포넌트에 감싸야 효과적인지 생각해보세요.

  1. 제품 상세 페이지(/products/[id])에서 서버 액션으로 재고를 업데이트한 뒤 현재 페이지의 RSC 페이로드를 갱신하는 코드를 작성하세요. revalidatePathrouter.refresh() 중 어느 것을 사용해야 하는지, 그 차이도 설명하세요.

힌트 revalidatePath는 서버 캐시(Full Route Cache)를 무효화하면서 다음 내비게이션 시 클라이언트 Router Cache도 함께 비우고, router.refresh()는 현재 탭의 클라이언트 Router Cache만 즉시 무효화합니다.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Next.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.