React Server Components 심화 — 서버와 클라이언트의 경계
컴포넌트 안에서 직접 데이터베이스를 쿼리하면서도, 그 코드가 브라우저에는 전혀 전송되지 않는다면 — 이것이 가능할까요?
React Server Components(RSC)는 React의 가장 근본적인 변화 중 하나입니다. 서버에서만 실행되는 컴포넌트와 클라이언트에서 인터랙티브한 컴포넌트를 하나의 트리에서 자연스럽게 조합합니다. SSR과는 근본적으로 다른 개념이며, 이 둘의 차이를 이해하는 것이 핵심입니다.
RSC vs SSR — 핵심 차이
SSR (Server-Side Rendering)
서버: 컴포넌트 → HTML 문자열 생성 → 클라이언트에 전송
클라이언트: HTML 표시 → JavaScript 로드 → Hydration → 인터랙티브
- HTML을 미리 만들어 보냅니다
- 모든 컴포넌트의 JavaScript가 클라이언트에 전송됩니다
- Hydration으로 인터랙티브하게 만듭니다
RSC (React Server Components)
서버: 서버 컴포넌트 실행 → 직렬화된 트리(wire format) 생성 → 전송
클라이언트: wire format 해석 → 클라이언트 컴포넌트만 hydrate
- 직렬화된 React 트리(JSON과 유사)를 보냅니다
- ** 서버 컴포넌트의 JavaScript는 클라이언트에 전송되지 않습니다**
- 클라이언트 컴포넌트만 hydrate합니다
비유
- SSR: 완성된 그림(HTML)을 보내고, 클라이언트가 위에 인터랙션 레이어를 덧씌우는 것
- RSC: 그림의 설계도(wire format)를 보내고, 클라이언트가 인터랙티브 부분만 직접 그리는 것
서버 컴포넌트
기본적으로 모든 컴포넌트는 서버 컴포넌트입니다 (Next.js App Router 기준).
// 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' 지시어를 추가합니다.
'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)**를 형성합니다.
서버 영역
├── ServerComponent (서버 컴포넌트)
│ ├── AnotherServer (서버 컴포넌트)
│ └── 'use client' ← 여기가 경계
│ ├── ClientComponent (클라이언트 컴포넌트)
│ └── ImportedByClient (클라이언트 컴포넌트 — 'use client' 없어도)
'use client'가 있는 파일부터 그 파일이 import하는 모든 모듈이 클라이언트 번들에 포함됩니다- 모든 파일에
'use client'를 붙일 필요는 없습니다 — 경계 지점에만 붙이면 됩니다
경계 규칙 (Boundary Rules)
서버 → 클라이언트: props는 직렬화 가능해야 함
// 서버 컴포넌트
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으로
클라이언트 컴포넌트 안에 서버 컴포넌트를 배치하는 패턴입니다.
'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>
);
}
// 서버 컴포넌트
async function Page() {
const data = await fetchData();
return (
<ClientLayout>
{/* 서버 컴포넌트를 children으로 전달 */}
<ServerContent data={data} />
</ClientLayout>
);
}
이것이 가능한 이유: ServerContent는 서버에서 이미 렌더링되어 직렬화된 결과물 이 됩니다. 이 결과물은 직렬화 가능하므로 children prop으로 전달할 수 있습니다.
RSC Wire Format
서버에서 클라이언트로 전송되는 RSC의 실제 형태입니다.
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을 해석하여:
- 서버 컴포넌트 부분은 정적 DOM으로 렌더링
- 클라이언트 컴포넌트(
$L1)는 해당 JavaScript 모듈을 로드하여 hydrate
RSC + Suspense + Streaming
세 가지가 결합되면 최적의 로딩 경험을 만듭니다.
// 서버 컴포넌트
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} />;
}
동작 흐름:
UserGreeting,h1, 스켈레톤이 즉시 스트리밍됩니다DateRangePicker의 클라이언트 JS가 로드되어 인터랙티브해집니다AnalyticsChart의 데이터가 준비되면 스켈레톤을 교체합니다RecentOrders의 데이터가 준비되면 스켈레톤을 교체합니다
Server Actions
서버 컴포넌트에서 클라이언트로 함수를 전달하는 유일한 방법입니다.
// 서버 액션 정의
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>
);
}
'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 설계의 출발점입니다.