React Server Components의 동작 원리, 직렬화 프로토콜, 번들 전략, 합성 규칙을 심층적으로 분석합니다. 서버와 클라이언트의 경계를 이해합니다.
1장에서 React 19의 전체 그림을 살펴보았습니다. 이번 장에서는 React 19의 가장 근본적인 변화인 React Server Components(RSC)의 아키텍처를 심층적으로 분석합니다. RSC가 왜 필요한지, 어떻게 동작하는지, 그리고 서버와 클라이언트 컴포넌트의 합성 규칙은 무엇인지를 명확하게 이해하는 것이 이 시리즈 전체를 관통하는 핵심 기반입니다.
React Server Components는 서버 환경에서만 실행되는 컴포넌트입니다. 빌드 타임의 CI 서버에서 실행될 수도 있고, 요청 시점의 웹 서버에서 실행될 수도 있습니다. 핵심은 이 컴포넌트의 JavaScript 코드가 절대 클라이언트 번들에 포함되지 않는다는 점입니다.
// app/article/[id]/page.tsx
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
async function ArticlePage({ params }: { params: { id: string } }) {
const article = await db.articles.findUnique({
where: { id: params.id },
});
// marked(~35KB)와 sanitize-html(~40KB)은
// 클라이언트 번들에 포함되지 않음
const html = sanitizeHtml(marked(article.content));
return (
<article>
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
<CommentSection articleId={article.id} />
</article>
);
}이 코드에서 marked와 sanitize-html 라이브러리는 서버에서만 사용되므로, gzip 기준 약 75KB 이상의 JavaScript가 클라이언트 번들에서 제거됩니다.
이 세 가지 개념은 자주 혼동됩니다. 명확하게 구분해봅시다.
기존의 SSR은 같은 컴포넌트 코드를 서버와 클라이언트 양쪽에서 실행합니다.
SSR의 핵심 한계는 모든 컴포넌트의 JavaScript가 여전히 클라이언트로 전송된다는 점입니다.
Server Components는 서버에서만 실행됩니다.
Server Components의 JavaScript는 클라이언트에 전송되지 않으므로, 번들 크기가 줄어들고 하이드레이션 비용이 감소합니다.
'use client' 디렉티브로 표시된 컴포넌트입니다. 기존 React 컴포넌트와 동일하게 동작하며, useState, useEffect 등의 클라이언트 훅을 사용할 수 있습니다.
'use client';
import { useState } from 'react';
function LikeButton({ articleId }: { articleId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '좋아요 취소' : '좋아요'}
</button>
);
}'use client'는 "이 컴포넌트가 클라이언트에서만 실행된다"는 뜻이 아닙니다. SSR을 사용하는 경우 Client Component도 서버에서 HTML을 생성합니다. 'use client'는 서버-클라이언트 경계를 정의하는 진입점이며, 이 파일과 이 파일이 임포트하는 모듈들이 클라이언트 번들에 포함된다는 의미입니다.
| 특성 | SSR | Server Component | Client Component |
|---|---|---|---|
| 실행 위치 | 서버 + 클라이언트 | 서버만 | 클라이언트 (+ SSR) |
| JS 번들 포함 | 포함 | 미포함 | 포함 |
| useState/useEffect | 사용 가능 | 사용 불가 | 사용 가능 |
| async/await | 사용 불가 | 사용 가능 | 사용 불가 |
| DB/파일 직접 접근 | 불가 | 가능 | 불가 |
| 이벤트 핸들러 | 가능 | 불가 | 가능 |
| 하이드레이션 | 필요 | 불필요 | 필요 |
Server Components가 실제로 어떤 과정을 거쳐 화면에 표시되는지 단계별로 살펴봅시다.
서버는 컴포넌트 트리를 순회하며 Server Components를 실행합니다.
// Server Component
async function Page() {
const posts = await fetchPosts();
return (
<main>
<h1>블로그</h1>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
<Newsletter /> {/* Client Component */}
</main>
);
}
// Server Component
function PostCard({ post }: { post: Post }) {
return (
<article>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<LikeButton postId={post.id} /> {/* Client Component */}
</article>
);
}서버는 Page와 PostCard를 실행하여 렌더링 결과를 생성합니다. Newsletter와 LikeButton은 Client Component이므로, 서버는 이들의 참조(reference)만 남기고 넘어갑니다.
렌더링 결과는 RSC Payload라는 특별한 직렬화 형식으로 변환됩니다. 이것은 HTML이 아닌, React 트리를 표현하는 스트리밍 가능한 데이터 형식입니다.
// RSC Payload (간략화)
0: ["$", "main", null, {
"children": [
["$", "h1", null, { "children": "블로그" }],
["$", "article", null, {
"children": [
["$", "h2", null, { "children": "React 19 입문" }],
["$", "p", null, { "children": "React 19의 새로운..." }],
["$", "@1", null, { "postId": "1" }] // Client Component 참조
]
}],
["$", "@2", null, {}] // Client Component 참조
]
}]
여기서 @1과 @2는 Client Component 번들의 참조입니다. 실제 컴포넌트 코드가 아닌, 번들러가 생성한 모듈 참조를 포함합니다.
브라우저는 RSC Payload를 받아서 다음을 수행합니다.
이 과정에서 Server Components의 코드는 한 번도 클라이언트에 전송되지 않습니다.
Server Components와 Client Components를 조합할 때 반드시 지켜야 할 규칙들이 있습니다. 이 규칙들을 정확히 이해하는 것이 RSC 아키텍처의 핵심입니다.
가장 기본적이고 자연스러운 패턴입니다.
// app/page.tsx (Server Component)
import { SearchBar } from '@/components/SearchBar'; // Client Component
async function Page() {
const data = await fetchData();
return (
<div>
<SearchBar /> {/* Client Component 렌더링 */}
<DataTable data={data} /> {/* Server Component */}
</div>
);
}이것이 가장 흔한 실수입니다. Client Component 파일에서 Server Component를 import하면, 해당 Server Component도 클라이언트 번들에 포함되려고 시도하며 오류가 발생합니다.
'use client';
// 이 임포트는 오류를 발생시킵니다
import { ServerDataFetcher } from './ServerDataFetcher';
function Dashboard() {
return (
<div>
{/* Server Component를 직접 임포트하여 사용할 수 없음 */}
<ServerDataFetcher />
</div>
);
}Client Component가 Server Component를 직접 임포트할 수는 없지만, props로 전달받는 것은 가능합니다. 이것이 RSC 합성의 핵심 패턴입니다.
// components/ClientWrapper.tsx
'use client';
function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>토글</button>
{isOpen && children} {/* Server Component의 렌더링 결과 */}
</div>
);
}
// app/page.tsx (Server Component)
import { ClientWrapper } from '@/components/ClientWrapper';
async function Page() {
const data = await fetchData();
return (
<ClientWrapper>
{/* ServerDataTable은 Server Component이지만,
Page(Server Component)가 렌더링한 결과를
ClientWrapper에 children으로 전달 */}
<ServerDataTable data={data} />
</ClientWrapper>
);
}이 패턴이 동작하는 이유는, ServerDataTable이 Server Component(Page)에 의해 이미 렌더링된 결과가 children으로 전달되기 때문입니다. Client Component는 Server Component의 렌더링 결과(React 엘리먼트)를 받을 뿐, Server Component의 코드 자체를 실행하지 않습니다.
Server Component에서 Client Component로 전달하는 props는 직렬화 가능(Serializable)해야 합니다.
// 허용: 원시 값, 배열, 객체, Date, Map, Set, JSX
<ClientComponent
name="Kreath"
count={42}
tags={['react', 'rsc']}
createdAt={new Date()}
>
<ServerChild /> {/* JSX도 직렬화 가능 */}
</ClientComponent>
// 금지: 함수, 클래스 인스턴스, Symbol
<ClientComponent
onClick={() => console.log('click')} // 함수는 직렬화 불가
validator={new Validator()} // 클래스 인스턴스 불가
/>Server Actions('use server'로 표시된 함수)는 예외적으로 직렬화되어 경계를 넘을 수 있습니다. 이에 대해서는 3장에서 자세히 다룹니다.
'use client' 경계의 의미'use client' 디렉티브를 이해하는 핵심은 이것이 모듈 단위의 경계라는 점입니다.
// components/Dashboard.tsx
'use client'; // 이 파일부터 클라이언트 경계 시작
import { Chart } from './Chart'; // Chart도 Client Component가 됨
import { Tooltip } from './Tooltip'; // Tooltip도 Client Component가 됨
function Dashboard() {
return (
<div>
<Chart />
<Tooltip />
</div>
);
}Dashboard.tsx에 'use client'를 선언하면, Dashboard뿐만 아니라 이 파일이 임포트하는 모든 모듈도 클라이언트 번들에 포함됩니다. Chart와 Tooltip에 'use client'가 없더라도 마찬가지입니다.
이것이 의미하는 바는 중요합니다. 'use client' 경계는 가능한 트리의 깊은 곳(leaf)에 배치해야 클라이언트 번들 크기를 최소화할 수 있습니다.
// 나쁜 예: 최상위에 'use client'
// app/dashboard/page.tsx
'use client'; // 전체 페이지가 Client Component
function DashboardPage() {
// 이 페이지의 모든 하위 컴포넌트가 클라이언트 번들에 포함
return (
<div>
<StaticHeader /> {/* 서버 컴포넌트가 될 수 있었음 */}
<DataSummary /> {/* 서버 컴포넌트가 될 수 있었음 */}
<InteractiveChart /> {/* 실제로 클라이언트가 필요한 부분 */}
</div>
);
}// 좋은 예: 인터랙션이 필요한 곳에만 'use client'
// app/dashboard/page.tsx (Server Component)
async function DashboardPage() {
const summary = await fetchSummary();
return (
<div>
<StaticHeader />
<DataSummary data={summary} />
<InteractiveChart /> {/* 이것만 Client Component */}
</div>
);
}
// components/InteractiveChart.tsx
'use client';
function InteractiveChart() {
const [range, setRange] = useState('7d');
// ... 인터랙티브 차트 로직
}Server Components의 가장 강력한 특성 중 하나는 async 함수로 정의할 수 있다는 점입니다.
async function UserProfile({ userId }: { userId: string }) {
// 데이터베이스에서 직접 조회
const user = await db.users.findUnique({
where: { id: userId },
include: { posts: { take: 5, orderBy: { createdAt: 'desc' } } },
});
if (!user) return <NotFound />;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
<section>
<h2>최근 글</h2>
{user.posts.map(post => (
<PostPreview key={post.id} post={post} />
))}
</section>
</div>
);
}이 패턴이 가능한 이유는 Server Component가 서버에서만 실행되기 때문입니다. 서버 환경에서는 await가 자연스럽고, 렌더링이 완료될 때까지 기다린 후 결과를 클라이언트에 전송합니다.
Client Component는 async로 정의할 수 없습니다. 클라이언트에서 비동기 데이터를 다루려면 use() API를 사용해야 합니다. 이에 대해서는 4장에서 다룹니다.
Server Components는 Suspense와 자연스럽게 통합됩니다. 데이터의 우선순위에 따라 일부는 즉시 렌더링하고, 나머지는 스트리밍할 수 있습니다.
async function ArticlePage({ params }: { params: { id: string } }) {
// 핵심 데이터: 즉시 로드 (서버 렌더링을 블로킹)
const article = await db.articles.findUnique({
where: { id: params.id },
});
// 부가 데이터: Promise를 생성하되 await하지 않음
const commentsPromise = db.comments.findMany({
where: { articleId: params.id },
});
const relatedPromise = db.articles.findRelated(params.id);
return (
<article>
<h1>{article.title}</h1>
<div>{article.content}</div>
{/* 댓글은 준비되면 스트리밍 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
{/* 관련 글도 별도로 스트리밍 */}
<Suspense fallback={<RelatedSkeleton />}>
<RelatedArticles relatedPromise={relatedPromise} />
</Suspense>
</article>
);
}이 패턴에서 article 데이터는 즉시 렌더링되어 사용자에게 표시되고, 댓글과 관련 글은 각각의 Suspense 경계 안에서 독립적으로 스트리밍됩니다. 사용자는 본문을 읽는 동안 나머지 데이터가 점진적으로 나타나는 것을 볼 수 있습니다.
Server Components의 제약을 명확히 이해하는 것도 중요합니다.
// 1. useState, useEffect 등 클라이언트 훅
function ServerComponent() {
const [count, setCount] = useState(0); // 오류
useEffect(() => { /* ... */ }, []); // 오류
}
// 2. 이벤트 핸들러
function ServerComponent() {
return <button onClick={() => alert('click')}>클릭</button>; // 오류
}
// 3. 브라우저 전용 API
function ServerComponent() {
const width = window.innerWidth; // 오류: window 미정의
localStorage.setItem('key', 'value'); // 오류
}
// 4. Context 생성 (소비는 가능)
function ServerComponent() {
return (
<ThemeContext.Provider value="dark"> // 오류
<Children />
</ThemeContext.Provider>
);
}// 1. 데이터베이스 접근
const user = await db.users.findUnique({ where: { id: userId } });
// 2. 파일 시스템 접근
const content = await fs.readFile('./data/config.json', 'utf-8');
// 3. 환경 변수 (서버 전용)
const apiKey = process.env.SECRET_API_KEY;
// 4. 서버 전용 라이브러리
import { marked } from 'marked';
import { createHash } from 'crypto';
// 5. async/await
async function fetchAndRender() { /* ... */ }
// 6. Context 소비 (use API 사용)
import { use } from 'react';
const theme = use(ThemeContext);// lib/data/articles.ts (서버 전용)
import { cache } from 'react';
export const getArticle = cache(async (id: string) => {
const article = await db.articles.findUnique({
where: { id },
include: { author: true, tags: true },
});
return article;
});
export const getArticleComments = cache(async (articleId: string) => {
return db.comments.findMany({
where: { articleId },
include: { author: true },
orderBy: { createdAt: 'desc' },
});
});React의 cache 함수는 동일한 요청 내에서 같은 인자로 호출된 함수의 결과를 메모이제이션합니다. 여러 Server Component가 같은 데이터를 필요로 할 때 중복 쿼리를 방지합니다.
전통적인 Container/Presentational 패턴이 Server/Client Component 경계와 자연스럽게 매핑됩니다.
// app/articles/page.tsx (Server Component = Container)
async function ArticlesPage() {
const articles = await getArticles();
const categories = await getCategories();
return (
<div>
<CategoryFilter categories={categories} /> {/* Client */}
<ArticleGrid articles={articles} /> {/* Client */}
</div>
);
}// components/ArticleGrid.tsx (Client Component = Presentational)
'use client';
import { useState } from 'react';
function ArticleGrid({ articles }: { articles: Article[] }) {
const [sortBy, setSortBy] = useState<'date' | 'title'>('date');
const sorted = [...articles].sort((a, b) =>
sortBy === 'date'
? b.date.getTime() - a.date.getTime()
: a.title.localeCompare(b.title)
);
return (
<div>
<select onChange={(e) => setSortBy(e.target.value as 'date' | 'title')}>
<option value="date">최신순</option>
<option value="title">제목순</option>
</select>
<div className="grid grid-cols-3 gap-4">
{sorted.map(article => (
<ArticleCard key={article.id} article={article} />
))}
</div>
</div>
);
}'use client' 경계는 모듈 단위로 전파되며, 가능한 트리의 깊은 곳에 배치해야 합니다.children 패턴으로 합성할 수 있습니다.async/await를 직접 사용할 수 있고, Suspense와 통합하여 스트리밍 렌더링을 구현합니다.다음 장에서는 클라이언트에서 서버 로직을 안전하게 호출하는 Server Actions의 동작 원리와 실전 활용법을 살펴봅니다.
이 글이 도움이 되셨나요?
Server Actions의 동작 원리, 폼 처리 패턴, 데이터 뮤테이션, 에러 핸들링, 보안 고려사항을 실전 코드와 함께 다룹니다.
React 19가 가져온 근본적인 변화를 살펴봅니다. Actions, Server Components, 새로운 훅, React Compiler까지 React의 새로운 패러다임을 이해합니다.
React 19의 use() API로 Promise와 Context를 조건부로 소비하는 방법, 서버-클라이언트 스트리밍 패턴, 기존 훅과의 차이를 다룹니다.