본문으로 건너뛰기
yutils

React Server Components 는 어떻게 동작할까?

server vs client component boundary, 'use client' directive, RSC payload (HTML 아닌 JSON 아닌 별도 format), streaming render, props 는 serialize 되는데 function 은 안 되는 이유, RSC 가 실제 줄이는 bundle.

약 10분 읽기

React Server Components — 2021 RFC, 2023 Next.js App Router 의 default 가 되며 mainstream 진입. 이름이 헷갈리는 점 — "server 에서 render" 는 SSR 와 다른 의미. RSC 는 "client 에 JS 전송하지 않는 component". 이 가이드는 server vs client boundary, RSC payload format, props serialization 함정을 정리한다.

SSR vs RSC — 다른 것

SSR (Server-Side Rendering, ~2014):
  1. server 가 HTML render
  2. browser 가 HTML 받음 (TTFB)
  3. JS bundle download (component code 다)
  4. hydration — server HTML 위에 React 부착
  → 모든 component 코드가 client 에 전송

RSC (React Server Component, 2021+):
  1. server 가 React tree render → RSC payload (JSON-like)
  2. client 가 payload 로 DOM update
  → Server Component 의 코드는 client 에 전송 X
  → JS bundle 작아짐 (server-only 코드 제외)

차이:
  SSR = "server 에서 HTML 만들기" (모든 component JS 는 여전히 client)
  RSC = "server 에서만 도는 component" (그 코드는 client 안 감)

Server vs Client Boundary

// app/page.tsx — server component (default in Next.js App Router)
import {db} from "@/lib/db";

export default async function Page() {
  const posts = await db.posts.findAll();  // server-only — DB 호출
  return <PostList posts={posts} />;
}

// components/PostList.tsx — server component (server context 상속)
export function PostList({posts}: {posts: Post[]}) {
  return (
    <ul>
      {posts.map(p => <PostCard key={p.id} post={p} />)}
    </ul>
  );
}

// components/Counter.tsx — client component (명시)
"use client";
import {useState} from "react";

export function Counter() {
  const [n, setN] = useState(0);  // useState 사용 → client 필수
  return <button onClick={() => setN(n+1)}>{n}</button>;
}

규칙:
- "use client" directive 박힌 파일 = client component
- 그 파일이 import 하는 module 도 client bundle 에 박힘
- server component 는 client component 를 import 할 수 있음 (그 반대 X 직접)
- 단 prop 으로 server → client 전달은 가능

"use client" 의 정확한 의미

잘못된 멘탈 모델:
  "use client" = "이 component 는 client 에서 render"
                 → 사실 서버에서도 render (SSR HTML 만들기 위해)

정확:
  "use client" = "이 module 부터 client boundary 시작"
                 → 이 file 의 코드는 client bundle 에 박힘
                 → 자식 component 의 default 가 client 로 바뀜
                 → useState / useEffect / event handler 같은 client-only API 가능

→ "use client" 는 boundary 표시. 위치 (component tree 의 어디) 가 중요.

권장 패턴:
  Page (server) → ServerComponentA (server) → LeafInteractive (client)

  client boundary 를 leaf 가까이 → bundle 최소화.

RSC Payload Format

server 가 client 에 보내는 것은 HTML 아닌 RSC payload (text format).

예 (간략):
  1:I["@/components/PostList","default"]
  2:I["@/components/Counter","default"]
  M0:["$","ul",null,{"children":[
    ["$","li",null,{"children":["Post 1"]}],
    ["$","li",null,{"children":["Post 2"]}]
  ]}]
  M1:["$","$L2",null,{}]
  J0:[M0, M1]

특징:
- JSON-like 이지만 React-aware
- I (import) line: client component 의 reference
- M (model) line: React tree
- 직렬화 가능한 props 만 (function 안 됨, Date / Map 도 정확히 안 됨)
- streaming — payload 가 chunk 로 전달, browser 가 받자마자 render

vs HTML:
- HTML: 초기 paint 만 표현, 이후 React 가 "다시 render" 필요
- RSC payload: incremental update 가능, "이 트리의 일부만 갱신"

Props Serialization 함정

server → client component 에 prop 전달 시:

✓ OK:
  - primitive (string, number, boolean, null, undefined)
  - plain object / array
  - Date (Next 15+에서 가능)
  - Promise (server component 가 await 안 한 promise 도 stream)

✗ 불가:
  - function (closure 직렬화 불가)
  - class instance (prototype 잃음)
  - Map / Set (또는 일부 framework 에서만)
  - DOM node, React element

흔한 함정:
  // ❌ 안 됨
  <Button onClick={() => fetchData()} />   ← function prop
  // → "Functions cannot be passed directly to Client Components"

  // ✓ 해결 — Server Action
  async function handleClick() {
    "use server";
    await fetchData();
  }
  <Button action={handleClick} />

Server Action — server function 을 prop 으로

// app/actions.ts
"use server";

export async function createPost(formData: FormData) {
  await db.posts.create({title: formData.get("title")});
  revalidatePath("/posts");
}

// app/page.tsx
import {createPost} from "./actions";

export default function Page() {
  return (
    <form action={createPost}>   ← server action 을 form action 으로
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

→ "use server" function 은 자동으로 endpoint 생성.
   client 가 form submit 시 그 endpoint 에 POST → server 에서 실행.
   API 직접 안 짜고 server logic 호출 가능.

주의: "use server" 도 module-level directive. 같은 file 의 export 모두 server action.

RSC 의 실제 효과

측정SSR 만RSC
Initial JS bundle3 MB (모든 component)1 MB (client 만)
library cost (markdown parser 등)client 에도 downloadserver 만 → 0 client cost
DB queryAPI endpoint 거쳐component 안에서 직접
SEOOK (SSR HTML)OK + 더 적은 JS

흔한 함정

  • "use client" 너무 위에 — Page 에 박으면 모든 tree 가 client → RSC 효과 0. leaf 가까이.
  • server component 에서 hook 사용 — useState / useEffect 호출 시 build error. 그 코드는 client 로 이전.
  • function prop 전달 — error. Server Action 또는 client component 안에서 직접 정의.
  • environment variable leak — server component 의 process.env.DB_PASSWORD 가 무심코 client prop 으로 가면 RSC payload 에 박혀 leak.
  • 같은 component 가 server/client 양쪽 사용 — server 와 client 가 다른 export 같은 layout 어색. 명확한 boundary.

마무리

RSC 의 본질 — "client 에 안 보내는 component". server 에서 render + 그 코드는 client bundle 에 박지 않음. SSR 이 "HTML 미리" 였다면 RSC 는 "component 의 일부를 server 만의 것으로".

실용 — default server, leaf 만 "use client". DB / 외부 API 호출은 server component 에서 직접 (API endpoint 생략). Server Action 으로 form / mutation. 큰 markdown parser / DB driver 같은 library 는 server 만에 → bundle 큰 폭 감소.

가이드 목록으로