컴포넌트 안에서 직접 데이터베이스를 쿼리하면서도, 그 코드가 브라우저에는 전혀 전송되지 않는다면 — 이것이 가능할까요?

React Server Components(RSC)는 React의 가장 근본적인 변화 중 하나입니다. 서버에서만 실행되는 컴포넌트와 클라이언트에서 인터랙티브한 컴포넌트를 하나의 트리에서 자연스럽게 조합합니다. SSR과는 근본적으로 다른 개념이며, 이 둘의 차이를 이해하는 것이 핵심입니다.

RSC vs SSR — 핵심 차이

SSR (Server-Side Rendering)

PLAINTEXT
서버: 컴포넌트 → HTML 문자열 생성 → 클라이언트에 전송
클라이언트: HTML 표시 → JavaScript 로드 → Hydration → 인터랙티브
  • HTML을 미리 만들어 보냅니다
  • 모든 컴포넌트의 JavaScript가 클라이언트에 전송됩니다
  • Hydration으로 인터랙티브하게 만듭니다

RSC (React Server Components)

PLAINTEXT
서버: 서버 컴포넌트 실행 → 직렬화된 트리(wire format) 생성 → 전송
클라이언트: wire format 해석 → 클라이언트 컴포넌트만 hydrate
  • 직렬화된 React 트리(JSON과 유사)를 보냅니다
  • ** 서버 컴포넌트의 JavaScript는 클라이언트에 전송되지 않습니다**
  • 클라이언트 컴포넌트만 hydrate합니다

비유

  • SSR: 완성된 그림(HTML)을 보내고, 클라이언트가 위에 인터랙션 레이어를 덧씌우는 것
  • RSC: 그림의 설계도(wire format)를 보내고, 클라이언트가 인터랙티브 부분만 직접 그리는 것

서버 컴포넌트

기본적으로 모든 컴포넌트는 서버 컴포넌트입니다 (Next.js App Router 기준).

JSX
// app/page.js — 서버 컴포넌트 (기본)
import { db } from '@/lib/database';
import { marked } from 'marked';  // 이 라이브러리는 클라이언트에 전송되지 않음

async function BlogPost({ postId }) {
  // 서버에서 직접 데이터베이스 쿼리
  const post = await db.posts.findUnique({ where: { id: postId } });

  // 서버에서 마크다운 변환
  const html = marked(post.content);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
      <p>작성일: {post.createdAt.toLocaleDateString()}</p>
    </article>
  );
}

서버 컴포넌트에서 할 수 있는 것

  • 데이터베이스 직접 접근
  • 파일 시스템 접근
  • API 키 같은 비밀 값 사용
  • 무거운 라이브러리 사용 (클라이언트 번들에 포함 안 됨)
  • async/await 직접 사용

서버 컴포넌트에서 할 수 없는 것

  • useState, useEffect 등 Hook 사용
  • 이벤트 핸들러 (onClick, onChange 등)
  • 브라우저 API (window, document, localStorage)
  • createContext (Consumer는 가능)

클라이언트 컴포넌트

인터랙션이 필요한 컴포넌트에 'use client' 지시어를 추가합니다.

JSX
'use client';

import { useState } from 'react';

function LikeButton({ postId, initialCount }) {
  const [count, setCount] = useState(initialCount);
  const [liked, setLiked] = useState(false);

  const handleLike = async () => {
    setLiked(!liked);
    setCount(liked ? count - 1 : count + 1);
    await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
  };

  return (
    <button onClick={handleLike}>
      {liked ? '❤️' : '🤍'} {count}
    </button>
  );
}

'use client'의 정확한 의미

'use client'는 ** 경계(boundary)**를 형성합니다.

PLAINTEXT
서버 영역
├── ServerComponent (서버 컴포넌트)
│   ├── AnotherServer (서버 컴포넌트)
│   └── 'use client' ← 여기가 경계
│       ├── ClientComponent (클라이언트 컴포넌트)
│       └── ImportedByClient (클라이언트 컴포넌트 — 'use client' 없어도)
  • 'use client'가 있는 파일부터 그 파일이 import하는 모든 모듈이 클라이언트 번들에 포함됩니다
  • 모든 파일에 'use client'를 붙일 필요는 없습니다 — 경계 지점에만 붙이면 됩니다

경계 규칙 (Boundary Rules)

서버 → 클라이언트: props는 직렬화 가능해야 함

JSX
// 서버 컴포넌트
async function PostPage({ postId }) {
  const post = await db.posts.findUnique({ where: { id: postId } });

  return (
    <div>
      <h1>{post.title}</h1>
      {/* 직렬화 가능한 데이터만 전달 */}
      <LikeButton
        postId={post.id}           // ✓ 숫자
        initialCount={post.likes}  // ✓ 숫자
        title={post.title}         // ✓ 문자열
      />
      {/* 함수는 전달 불가 */}
      {/* <ClientComp onClick={() => {}} /> ✗ 에러 */}
    </div>
  );
}

직렬화 가능한 타입

  • 원시 값: string, number, boolean, null, undefined
  • 배열, 일반 객체
  • Date (문자열로 변환)
  • React 엘리먼트 (JSX)
  • Server Actions (함수이지만 참조로 전달)

직렬화 불가능한 타입

  • 함수 (일반 함수, 이벤트 핸들러)
  • 클래스 인스턴스
  • Symbol
  • 순환 참조가 있는 객체

합성 패턴: 서버 컴포넌트를 children으로

클라이언트 컴포넌트 안에 서버 컴포넌트를 배치하는 패턴입니다.

JSX
'use client';

function ClientLayout({ children }) {
  const [sidebarOpen, setSidebarOpen] = useState(true);

  return (
    <div className={sidebarOpen ? 'with-sidebar' : 'no-sidebar'}>
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>토글</button>
      <div className="content">
        {children}  {/* 서버 컴포넌트가 여기에 들어옴 */}
      </div>
    </div>
  );
}
JSX
// 서버 컴포넌트
async function Page() {
  const data = await fetchData();

  return (
    <ClientLayout>
      {/* 서버 컴포넌트를 children으로 전달 */}
      <ServerContent data={data} />
    </ClientLayout>
  );
}

이것이 가능한 이유: ServerContent는 서버에서 이미 렌더링되어 직렬화된 결과물 이 됩니다. 이 결과물은 직렬화 가능하므로 children prop으로 전달할 수 있습니다.

RSC Wire Format

서버에서 클라이언트로 전송되는 RSC의 실제 형태입니다.

PLAINTEXT
M1:{"id":"./src/LikeButton.js","chunks":["chunk-abc"],"name":"LikeButton"}
J0:["$","div",null,{"children":[
  ["$","h1",null,{"children":"블로그 제목"}],
  ["$","p",null,{"children":"블로그 내용..."}],
  ["$","$L1",null,{"postId":1,"initialCount":42}]
]}]
  • M1: 클라이언트 모듈 참조 (LikeButton.js)
  • J0: 직렬화된 React 트리
  • $L1: 클라이언트 컴포넌트 참조 (M1에서 정의한 모듈)

클라이언트는 이 wire format을 해석하여:

  1. 서버 컴포넌트 부분은 정적 DOM으로 렌더링
  2. 클라이언트 컴포넌트($L1)는 해당 JavaScript 모듈을 로드하여 hydrate

RSC + Suspense + Streaming

세 가지가 결합되면 최적의 로딩 경험을 만듭니다.

JSX
// 서버 컴포넌트
async function DashboardPage() {
  return (
    <main>
      <h1>대시보드</h1>

      {/* 빠른 데이터 — 즉시 렌더링 */}
      <UserGreeting />

      {/* 느린 데이터 — Suspense로 스트리밍 */}
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />   {/* 서버 컴포넌트 — async */}
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />     {/* 서버 컴포넌트 — async */}
      </Suspense>

      {/* 인터랙티브 — 클라이언트 컴포넌트 */}
      <DateRangePicker />
    </main>
  );
}

async function AnalyticsChart() {
  const data = await fetchAnalytics(); // 2초 소요
  return <Chart data={data} />;
}

동작 흐름:

  1. UserGreeting, h1, 스켈레톤이 즉시 스트리밍됩니다
  2. DateRangePicker의 클라이언트 JS가 로드되어 인터랙티브해집니다
  3. AnalyticsChart의 데이터가 준비되면 스켈레톤을 교체합니다
  4. RecentOrders의 데이터가 준비되면 스켈레톤을 교체합니다

Server Actions

서버 컴포넌트에서 클라이언트로 함수를 전달하는 유일한 방법입니다.

JSX
// 서버 액션 정의
async function addToCart(productId) {
  'use server';
  const user = await getUser();
  await db.cart.add({ userId: user.id, productId });
  revalidatePath('/cart');
}

// 서버 컴포넌트에서 클라이언트 컴포넌트로 전달
function ProductCard({ product }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <AddToCartButton action={addToCart.bind(null, product.id)} />
    </div>
  );
}
JSX
'use client';

function AddToCartButton({ action }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => startTransition(() => action())}
      disabled={isPending}
    >
      {isPending ? '추가 중...' : '장바구니 담기'}
    </button>
  );
}

정리

React Server Components는 서버와 클라이언트의 경계를 React 컴포넌트 수준에서 관리합니다.

  • RSC vs SSR: SSR은 HTML을 보내고 전체를 hydrate하지만, RSC는 직렬화된 트리를 보내고 클라이언트 컴포넌트만 hydrate합니다
  • ** 번들 크기 감소 **: 서버 컴포넌트의 코드와 의존 라이브러리는 클라이언트에 전송되지 않습니다
  • **'use client'는 경계 **: 서버와 클라이언트의 경계를 형성하며, 이 지점부터 클라이언트 번들이 시작됩니다
  • **props 직렬화 **: 서버 → 클라이언트 경계를 넘는 props는 직렬화 가능해야 합니다
  • ** 합성 패턴 **: 서버 컴포넌트를 클라이언트 컴포넌트의 children으로 전달할 수 있습니다
  • RSC + Suspense + Streaming: 세 가지가 결합되어 최적의 점진적 로딩을 만듭니다

주의할 점

'use client'를 너무 높은 위치에 배치

'use client'를 루트에 가까운 컴포넌트에 배치하면, 그 하위 트리 전체가 클라이언트 번들에 포함됩니다. 인터랙션이 필요한 ** 가장 작은 단위 **에만 'use client'를 배치해야 합니다.

서버 → 클라이언트 경계에서 직렬화 불가능한 props

함수, Date 객체, Map/Set 등은 서버에서 클라이언트로 직렬화할 수 없습니다. 서버 컴포넌트에서 클라이언트 컴포넌트로 전달하는 props는 JSON 직렬화 가능한 값이어야 합니다.

RSC를 이해하는 핵심 질문은 "이 코드가 브라우저에 도착해야 하는가?"입니다. 아니라면 서버 컴포넌트로, 인터랙션이 필요하면 클라이언트 컴포넌트로 -- 이 판단이 RSC 설계의 출발점입니다.

댓글 로딩 중...