React Server Components: 서버와 클라이언트의 경계를 넘어

들어가며

안녕하세요! 지난 시간에는 스트리밍 SSR에 대해 알아봤는데요, 오늘은 React의 가장 혁신적인 패러다임 전환인 React Server Components (RSC)에 대해 깊이 파헤쳐보겠습니다.

“서버 컴포넌트가 뭐가 다른데? 그냥 SSR 아니야?”라고 생각하실 수 있지만, RSC는 우리가 알던 SSR과는 완전히 다른 접근 방식입니다.

기존 SSR의 근본적인 한계

🔄 HTML → Virtual DOM의 비효율적인 재구성

전통적인 SSR과 React Server Components의 처리 단계를 좌우로 비교한 그림. 왼쪽 SSR은 HTML 전송 후 전체 JS 다운로드·Virtual DOM 재구성·전체 하이드레이션을 거치고, 오른쪽 RSC는 HTML과 RSC Payload를 함께 보내 클라이언트 컴포넌트만 다운로드하고 부분적 하이드레이션으로 끝난다

전통적인 SSR의 동작 방식을 다시 한 번 살펴볼까요?

javascript
// 서버에서 렌더링
function ServerSideApp() {
  return (
    <div className="app">
      <Header user={currentUser} />
      <MainContent>
        <Article id={123} comments={comments} />
      </MainContent>
    </div>
  );
}

// 서버가 생성하는 HTML
const html = ReactDOMServer.renderToString(<ServerSideApp />);

서버가 보내는 것:

html
<div class="app">
  <header>...</header>
  <main>
    <article>...</article>
  </main>
</div>

그런데 브라우저에서는 어떤 일이 일어날까요?

javascript
// 브라우저에서의 하이드레이션
ReactDOM.hydrateRoot(container, <ClientSideApp />);

// 브라우저가 해야 하는 일:
// 1. 전체 컴포넌트 트리 재실행
// 2. Virtual DOM 재구성
// 3. 서버 HTML과 비교
// 4. 이벤트 핸들러 연결

😱 하이드레이션 불일치의 악몽

javascript
// 서버와 클라이언트의 미묘한 차이
function TimeDisplay() {
  // 서버: 2024-01-01 10:00:00
  // 클라이언트: 2024-01-01 10:00:01
  return <div>{new Date().toISOString()}</div>;
}

// 결과: Warning: Text content did not match!

왜 이런 문제가 발생할까요? 서버는 HTML만 보내고, 클라이언트는 처음부터 다시 Virtual DOM을 만들기 때문입니다.

📏 Element Tree vs HTML: 크기의 문제

같은 버튼 하나를 HTML과 Element Tree로 표현했을 때의 크기 비교. 왼쪽 HTML은 button 태그 한 줄로 약 2KB인 반면, 오른쪽 Element Tree는 type·props·onClick 함수·_owner·_store까지 담아 약 5KB로 더 크다

실제 예제로 비교해보겠습니다:

javascript
// React 컴포넌트
<Button
  onClick={handleClick}
  disabled={isLoading}
  className="primary"
>
  Submit
</Button>

HTML로 변환:

html
<button class="primary" disabled>Submit</button>

하지만 Element Tree는:

javascript
{
  type: Button,
  props: {
    onClick: function handleClick() { /* ... */ },
    disabled: true,
    className: "primary",
    children: "Submit"
  },
  // 더 많은 내부 메타데이터...
}

Element Tree가 HTML보다 훨씬 크지만, 더 많은 정보를 담고 있습니다. 그래서 지금까지는 HTML만 보냈던 것이죠.

RSC의 혁신: Element Tree를 직접 전송하기

🚀 RSC Payload의 등장

RSC는 과감한 선택을 했습니다: Element Tree를 직렬화해서 보내자!

javascript
// app/page.js (서버 컴포넌트)
async function HomePage() {
  const posts = await db.posts.findMany();  // 서버에서만 실행

  return (
    <div>
      <h1>블로그</h1>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

서버가 보내는 RSC Payload:

javascript
// 실제로는 더 최적화된 형식이지만, 이해를 위해 단순화
{
  "type": "div",
  "props": {
    "children": [
      {
        "type": "h1",
        "props": { "children": "블로그" }
      },
      {
        "type": "PostCard",
        "props": {
          "post": {
            "id": 1,
            "title": "RSC 이해하기",
            "content": "..."
          }
        }
      }
    ]
  }
}

RSC Payload 구조를 보여주는 코드 스크린샷. 최상위 div의 children 배열에 h1 요소, name이 InteractiveButton인 $module-reference(클라이언트 컴포넌트), fallback과 $lazy children을 가진 $Suspense 경계가 차례로 직렬화돼 있다

🤔 왜 이렇게 하는 걸까?

1. Suspense 경계 보존

javascript
// 기존 SSR에서는 불가능했던 패턴
function ArticlePage() {
  return (
    <article>
      <h1>제목</h1>
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />  {/* 비동기 서버 컴포넌트 */}
      </Suspense>
    </article>
  );
}

HTML만으로는 “이 부분이 Suspense 경계다”라는 정보를 전달할 수 없습니다. 하지만 RSC Payload는 가능합니다:

javascript
{
  "type": "article",
  "props": {
    "children": [
      { "type": "h1", "props": { "children": "제목" } },
      {
        "type": "$Suspense",  // Suspense 경계 명시
        "props": {
          "fallback": { "type": "CommentsSkeleton" },
          "children": {
            "type": "$Promise",  // 비동기 컴포넌트
            "value": "pending..."
          }
        }
      }
    ]
  }
}

2. 서버 전용 로직 완전 분리

javascript
// 이 코드는 클라이언트 번들에 포함되지 않음!
import { db } from './database';  // 서버 전용 import
import { SECRET_KEY } from './env';  // 민감한 정보

async function SecureDataFetcher() {
  const data = await db.query({
    auth: SECRET_KEY  // 클라이언트에 노출되지 않음
  });

  return <DataDisplay data={data} />;
}

서버 컴포넌트와 클라이언트 컴포넌트의 조화

🎭 혼재된 컴포넌트 트리

서버 컴포넌트와 클라이언트 컴포넌트가 섞인 트리 그림. 최상위 ProductPage(서버) 아래에 ProductInfo(서버)·AddToCart(클라이언트)·Reviews(서버)가 놓이고, AddToCart 밑에 QuantitySelector가 달린다. 파란색은 데이터 페칭과 무거운 연산을, 빨간색은 상태 관리와 이벤트 핸들링을 맡는다

실제 앱에서는 서버 컴포넌트와 클라이언트 컴포넌트가 섞여 있습니다:

javascript
// app/page.js (서버 컴포넌트)
import { ClientButton } from './ClientButton';
import { db } from './database';

export default async function ProductPage() {
  const product = await db.products.findOne();

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <ClientButton productId={product.id} />  {/* 클라이언트 컴포넌트 */}
    </div>
  );
}

// ClientButton.js
'use client';  // 클라이언트 컴포넌트 선언

import { useState } from 'react';

export function ClientButton({ productId }) {
  const [quantity, setQuantity] = useState(1);

  return (
    <button onClick={() => addToCart(productId, quantity)}>
      장바구니에 추가
    </button>
  );
}

📦 RSC Payload에서의 표현

서버 컴포넌트와 클라이언트 컴포넌트가 섞인 트리는 이렇게 직렬화됩니다:

javascript
{
  "type": "div",
  "props": {
    "children": [
      { "type": "h1", "props": { "children": "맥북 프로" } },
      { "type": "p", "props": { "children": "최신 M3 칩셋..." } },
      {
        "type": "$module-reference",  // 클라이언트 컴포넌트!
        "name": "ClientButton",
        "props": { "productId": 123 }
      }
    ]
  }
}

🔍 왜 모듈 참조로 표현할까?

javascript
// 만약 클라이언트 컴포넌트를 인라인으로 포함한다면?
{
  "type": function ClientButton() { /* 전체 코드 */ },  // ❌ 불가능!
  // 함수는 직렬화할 수 없음
}

// 대신 모듈 참조 사용
{
  "type": "$module-reference",
  "name": "ClientButton",
  "module": "./ClientButton.js"  // 번들러가 처리
}

이유:

  1. 코드 분할: 클라이언트 컴포넌트는 별도 번들로 관리
  2. 최적화: 중복 전송 방지
  3. 보안: 서버 코드와 완전 분리

이중 전송: HTML + RSC Payload

🎪 두 가지 형식이 모두 필요한 이유

실제로 Next.js App Router는 HTML과 RSC Payload를 모두 전송합니다:

서버가 즉시 표시용 HTML과 React 트리용 RSC Payload를 함께 브라우저로 보내는 흐름도. 아래에는 브라우저 처리 단계가 HTML로 초기 렌더링, RSC Payload 파싱, React 트리 재구성, 필요한 부분만 선택적 하이드레이션 순서로 나열돼 있다

html
<!DOCTYPE html>
<html>
<body>
  <!-- 1. 즉시 보여줄 HTML -->
  <div class="app">
    <h1>맥북 프로</h1>
    <p>최신 M3 칩셋...</p>
    <button>장바구니에 추가</button>
  </div>

  <!-- 2. RSC Payload (숨겨진 스크립트 태그) -->
  <script id="__RSC_PAYLOAD__" type="application/rsc">
    {"type":"div","props":{"children":[...]}}
  </script>
</body>
</html>

🤹 브라우저에서의 처리 과정

javascript
// 1단계: HTML로 초기 렌더링 (빠른 표시)
document.body.innerHTML = receivedHTML;

// 2단계: RSC Payload 파싱
const payload = JSON.parse(document.getElementById('__RSC_PAYLOAD__').textContent);

// 3단계: React 트리 재구성
const reactTree = parseRSCPayload(payload);

// 4단계: 선택적 하이드레이션
hydrateRoot(container, reactTree);

RSC의 실제 동작 예제

🛠️ 복잡한 앱 구조

javascript
// app/dashboard/page.js (서버 컴포넌트)
export default async function Dashboard() {
  const user = await getUser();

  return (
    <DashboardLayout user={user}>
      <Suspense fallback={<StatsSkeleton />}>
        <ServerStats userId={user.id} />  {/* 서버 컴포넌트 */}
      </Suspense>

      <InteractiveChart />  {/* 클라이언트 컴포넌트 */}

      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed userId={user.id} />  {/* 서버 컴포넌트 */}
      </Suspense>
    </DashboardLayout>
  );
}

// ServerStats.js (서버 컴포넌트)
async function ServerStats({ userId }) {
  // 무거운 집계 쿼리 - 서버에서만 실행
  const stats = await db.stats.aggregate({
    where: { userId },
    _sum: { revenue: true },
    _count: { orders: true }
  });

  return <StatsDisplay stats={stats} />;
}

// InteractiveChart.js (클라이언트 컴포넌트)
'use client';

function InteractiveChart() {
  const [timeRange, setTimeRange] = useState('week');

  return (
    <div>
      <ChartControls onRangeChange={setTimeRange} />
      <Chart range={timeRange} />
    </div>
  );
}

📡 네트워크에서 전송되는 내용

javascript
// 초기 HTML (빠른 표시용)
<div class="dashboard">
  <div>로딩 중...</div>  <!-- StatsSkeleton -->
  <div class="chart">...</div>  <!-- InteractiveChart 정적 HTML -->
  <div>로딩 중...</div>  <!-- FeedSkeleton -->
</div>

// RSC Payload (React 트리 재구성용)
{
  "type": "DashboardLayout",
  "props": {
    "user": { "id": 1, "name": "김철수" },
    "children": [
      {
        "type": "$Suspense",
        "props": {
          "fallback": { "type": "StatsSkeleton" },
          "children": { "type": "$lazy", "id": "stats-1" }
        }
      },
      {
        "type": "$module-reference",
        "name": "InteractiveChart"
      },
      {
        "type": "$Suspense",
        "props": {
          "fallback": { "type": "FeedSkeleton" },
          "children": { "type": "$lazy", "id": "feed-1" }
        }
      }
    ]
  }
}

// 스트리밍으로 추가 전송 (ServerStats 완료 시)
{
  "id": "stats-1",
  "type": "StatsDisplay",
  "props": {
    "stats": {
      "revenue": 1234567,
      "orders": 89
    }
  }
}

RSC의 장점과 트레이드오프

✅ 장점

  1. 번들 크기 감소
javascript
// 이전: 클라이언트 번들에 포함
import { format } from 'date-fns';  // 70KB
import { markdown } from 'markdown-parser';  // 120KB

// RSC: 서버에서만 실행, 번들에서 제외
async function Article() {
  const content = await fetchContent();
  const html = markdown(content);  // 서버에서만 실행

  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
  1. 데이터 페칭 최적화
javascript
// 데이터베이스와 가까운 곳에서 실행
async function ProductList() {
  // N+1 문제? 서버에서는 효율적인 쿼리로 해결
  const products = await db.products.findMany({
    include: {
      category: true,
      reviews: { take: 5 }
    }
  });

  return <ProductGrid products={products} />;
}
  1. 보안 향상
javascript
// API 키와 민감한 로직이 클라이언트에 노출되지 않음
async function SecureComponent() {
  const data = await fetch('https://api.example.com', {
    headers: {
      'Authorization': `Bearer ${process.env.SECRET_API_KEY}`
    }
  });

  return <DataView data={data} />;
}

⚠️ 주의사항

  1. 서버 컴포넌트의 제약
javascript
// ❌ 서버 컴포넌트에서 불가능
function ServerComponent() {
  useState();  // ❌ Hooks 사용 불가
  useEffect();  // ❌
  onClick={() => {});  // ❌ 이벤트 핸들러 불가
}

// ✅ 클라이언트 컴포넌트로 분리
'use client';
function InteractivePart() {
  const [state, setState] = useState();
  return <button onClick={() => setState(!state)}>클릭</button>;
}
  1. props 직렬화 제한
javascript
// ❌ 함수는 전달 불가
<ClientComponent
  callback={() => console.log('hello')}  // ❌
/>

// ✅ 서버 액션 사용
async function serverAction() {
  'use server';
  console.log('서버에서 실행');
}

<ClientComponent action={serverAction} />  // ✅

실무 패턴과 베스트 프랙티스

🏗️ 컴포넌트 경계 설계

javascript
// app/product/[id]/page.js
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);

  return (
    <div>
      {/* 정적 정보는 서버 컴포넌트 */}
      <ProductInfo product={product} />

      {/* 상호작용이 필요한 부분만 클라이언트 컴포넌트 */}
      <AddToCart productId={product.id} />

      {/* 무거운 연산은 서버에서 */}
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts
          category={product.category}
          currentId={product.id}
        />
      </Suspense>
    </div>
  );
}

마무리

React Server Components는 단순한 서버 사이드 렌더링의 진화가 아닙니다. 서버와 클라이언트의 경계를 재정의하는 패러다임 전환입니다.

핵심 혁신:

  • Element Tree 직접 전송: HTML의 한계 극복
  • 선택적 하이드레이션: 필요한 부분만 상호작용 가능하게
  • 번들 크기 최적화: 서버 전용 코드는 클라이언트에 전송하지 않음
  • 보안 강화: 민감한 로직과 데이터를 서버에 격리

RSC는 복잡해 보일 수 있지만, 결국 “각 코드를 가장 적합한 곳에서 실행한다”는 간단한 원칙에 기반합니다.

여러분의 Next.js 앱에서도 RSC의 힘을 활용해보세요. 더 빠르고, 더 안전하고, 더 효율적인 웹 애플리케이션을 만들 수 있을 것입니다! 🚀

생각해볼 거리

  • Islands Archietecture와 RSC의 유사한 점과 차이?
    • 철학이 유사하다.