
React Server Components (RSC) 이해하기
React Server Components (RSC) 이해하기
React Server Components(RSC)는 React 18에서 도입되고 React 19에서 정식 안정화된 새로운 아키텍처입니다. 기존의 클라이언트 사이드 렌더링(CSR)이나 서버 사이드 렌더링(SSR)과는 완전히 다른 패러다임으로, React 생태계의 가장 큰 변화 중 하나입니다.
왜 Server Components가 필요한가?
기존 React 앱의 문제점을 먼저 살펴봅시다.
기존 CSR의 한계
사용자 요청 → 빈 HTML 다운로드 → JS 번들 다운로드 → JS 실행 → API 호출 → 화면 렌더링
- 번들 크기 증가: 모든 컴포넌트 코드가 클라이언트로 전송됨
- 워터폴 현상: HTML → JS → 데이터 순차적 로딩
- 초기 로딩 지연: 사용자가 빈 화면을 오래 봐야 함
기존 SSR의 한계
SSR은 서버에서 HTML을 생성하지만, Hydration 과정에서 여전히 모든 JS를 클라이언트에 보내야 합니다.
// SSR이어도 이 코드는 클라이언트 번들에 포함됨 import { formatDate } from 'date-fns'; // 72KB import { marked } from 'marked'; // 50KB function BlogPost({ post }) { return ( <article> <time>{formatDate(post.date, 'yyyy-MM-dd')}</time> <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} /> </article> ); }
위 컴포넌트는 서버에서 렌더링되지만, date-fns와 marked 라이브러리는 여전히 클라이언트 번들에 포함됩니다.
Server Components란?
Server Components는 서버에서만 실행되고, 클라이언트로 JavaScript 코드가 전송되지 않는 컴포넌트입니다.
// BlogPost.jsx - Server Component (기본값) // 이 코드는 클라이언트 번들에 포함되지 않음! import { formatDate } from 'date-fns'; import { marked } from 'marked'; import { db } from '@/lib/database'; async function BlogPost({ slug }) { // 서버에서 직접 DB 접근 가능 const post = await db.posts.findUnique({ where: { slug } }); return ( <article> <h1>{post.title}</h1> <time>{formatDate(post.date, 'yyyy-MM-dd')}</time> <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} /> </article> ); }
핵심 특징
| 특징 | Server Component | Client Component |
|---|---|---|
| 실행 위치 | 서버 | 클라이언트 (+ SSR시 서버) |
| JS 번들 포함 | ❌ | ✅ |
| useState/useEffect | ❌ | ✅ |
| 이벤트 핸들러 | ❌ | ✅ |
| async/await | ✅ | ❌ |
| DB/파일시스템 접근 | ✅ | ❌ |
동작 원리: RSC Payload와 React Flight
RSC의 핵심은 RSC Payload(또는 React Flight)라는 특수한 직렬화 포맷입니다.
렌더링 흐름
1. 서버: Server Component 실행 2. 서버: 결과를 RSC Payload로 직렬화 3. 네트워크: RSC Payload 스트리밍 전송 4. 클라이언트: RSC Payload를 React 트리로 재구성 5. 클라이언트: Client Component 자리에 실제 컴포넌트 삽입
RSC Payload 구조
RSC Payload는 줄 단위의 텍스트 스트림입니다:
0:I{"id":"./src/components/LikeButton.js","chunks":["client0"],"name":"LikeButton"} 1:["quot;,"div",null,{"children":[["quot;,"h1",null,{"children":"Hello World"}],["quot;,"$L0",null,{"postId":123}]]}]
I: 클라이언트 모듈 참조 (Client Component 위치 정보)$L0: 0번 모듈(LikeButton)을 여기에 렌더링하라는 플레이스홀더- 나머지: 일반 DOM 요소 정보
시각적 흐름
┌─────────────────────────────────────────────────────────────┐ │ SERVER │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ <Page> │ │ │ │ <Header /> ← Server Component │ │ │ │ <BlogPost /> ← Server Component (async) │ │ │ │ <LikeButton /> ← Client Component 참조만 │ │ │ │ </Page> │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ RSC Payload (React Flight) │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ 0:I{"id":"LikeButton","chunks":["client0"]} │ │ │ │ 1:["quot;,"div",null,{"children":[...rendered HTML]}] │ │ │ └─────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ │ Streaming ▼ ┌─────────────────────────────────────────────────────────────┐ │ CLIENT │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ RSC Payload 파싱 │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ Virtual DOM 구성 │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ $L0 → LikeButton.js 로드 및 렌더링 │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ 최종 UI 완성 │ │ │ └─────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
Server Component vs Client Component
선언 방법
// Server Component (기본값) - 별도 선언 불필요 async function ServerComponent() { const data = await fetchData(); return <div>{data}</div>; }
// Client Component - 파일 최상단에 'use client' 선언 'use client'; import { useState } from 'react'; function ClientComponent() { const [count, setCount] = useState(0); return <button onClick={() => setCount(c => c + 1)}>{count}</button>; }
주의: 'use server'는 Server Component가 아님!
흔한 오해 중 하나입니다:
// ❌ 이것은 Server Component 선언이 아님! 'use server'; // 'use server'는 Server Action을 위한 지시문 export async function submitForm(formData) { // 폼 처리 로직 }
Server Component는 별도 선언이 필요 없습니다. React 19부터 'use client'가 없으면 기본적으로 Server Component입니다.
실전 예제: 블로그 페이지
컴포넌트 구조 설계
BlogPage (Server) ├── Header (Server) ├── BlogContent (Server) │ ├── TableOfContents (Server) │ └── MarkdownRenderer (Server) ├── LikeButton (Client) ← 인터랙션 필요 ├── CommentSection (Client) ← 실시간 업데이트 └── Footer (Server)
Server Component: 데이터 fetching과 렌더링
// app/blog/[slug]/page.jsx - Server Component import { db } from '@/lib/database'; import { marked } from 'marked'; import LikeButton from '@/components/LikeButton'; import CommentSection from '@/components/CommentSection'; export default async function BlogPage({ params }) { // 서버에서 직접 DB 접근 const post = await db.posts.findUnique({ where: { slug: params.slug }, include: { author: true } }); // 마크다운 변환 (이 라이브러리는 클라이언트로 안 감) const htmlContent = marked(post.content); return ( <article className="prose"> <h1>{post.title}</h1> <p>By {post.author.name}</p> {/* Server Component: HTML만 전송 */} <div dangerouslySetInnerHTML={{ __html: htmlContent }} /> {/* Client Component: JS 번들 필요 */} <LikeButton postId={post.id} initialLikes={post.likes} /> <CommentSection postId={post.id} /> </article> ); }
Client Component: 인터랙션 처리
// components/LikeButton.jsx 'use client'; import { useState, useTransition } from 'react'; import { likePost } from '@/actions/posts'; export default function LikeButton({ postId, initialLikes }) { const [likes, setLikes] = useState(initialLikes); const [isPending, startTransition] = useTransition(); const handleLike = () => { startTransition(async () => { const newLikes = await likePost(postId); setLikes(newLikes); }); }; return ( <button onClick={handleLike} disabled={isPending} className="like-button" > ❤️ {likes} {isPending && '...'} </button> ); }
Server Action: 서버 로직
// actions/posts.js 'use server'; import { db } from '@/lib/database'; import { revalidatePath } from 'next/cache'; export async function likePost(postId) { const post = await db.posts.update({ where: { id: postId }, data: { likes: { increment: 1 } } }); revalidatePath(`/blog/${post.slug}`); return post.likes; }
Suspense와 스트리밍
Server Components는 Suspense와 함께 사용하면 진정한 힘을 발휘합니다.
// app/dashboard/page.jsx import { Suspense } from 'react'; import UserProfile from '@/components/UserProfile'; import RecentPosts from '@/components/RecentPosts'; import Analytics from '@/components/Analytics'; export default function Dashboard() { return ( <div className="dashboard"> {/* 빠르게 로드되는 컴포넌트 */} <Suspense fallback={<ProfileSkeleton />}> <UserProfile /> </Suspense> {/* 약간 느린 컴포넌트 */} <Suspense fallback={<PostsSkeleton />}> <RecentPosts /> </Suspense> {/* 느린 외부 API를 사용하는 컴포넌트 */} <Suspense fallback={<AnalyticsSkeleton />}> <Analytics /> </Suspense> </div> ); }
스트리밍 동작 방식
Time ─────────────────────────────────────────────────────────► 서버: [UserProfile 완료] ─────────────────────────────────────► │ └──► 클라이언트: UserProfile 렌더링 서버: ──────────── [RecentPosts 완료] ─────────────────────────► │ └──► 클라이언트: RecentPosts 렌더링 서버: ──────────────────────────── [Analytics 완료] ───────────► │ └──► 클라이언트: Analytics 렌더링
각 컴포넌트가 준비되는 대로 점진적으로 화면에 표시됩니다.
언제 무엇을 사용해야 하는가?
Server Component를 사용해야 하는 경우
- ✅ 데이터베이스/API에서 데이터 fetching
- ✅ 민감한 정보 접근 (API 키, 환경변수)
- ✅ 무거운 라이브러리 사용 (마크다운 파서, 날짜 포맷터 등)
- ✅ 정적인 콘텐츠 렌더링
- ✅ SEO가 중요한 콘텐츠
Client Component를 사용해야 하는 경우
- ✅ 사용자 인터랙션 (클릭, 입력, 호버 등)
- ✅ 상태 관리 (useState, useReducer)
- ✅ 생명주기/부수효과 (useEffect)
- ✅ 브라우저 API 사용 (localStorage, geolocation 등)
- ✅ 커스텀 훅 사용
- ✅ 클라이언트 전용 라이브러리 (차트, 지도, 애니메이션)
결정 플로우차트
인터랙션이 필요한가? │ ├─ Yes ──► 'use client' 사용 │ └─ No │ 브라우저 API가 필요한가? │ ├─ Yes ──► 'use client' 사용 │ └─ No │ useState/useEffect가 필요한가? │ ├─ Yes ──► 'use client' 사용 │ └─ No ──► Server Component (기본값)
주의사항과 제한
1. Server Component에서 Client Component로 props 전달 제한
// ❌ 함수는 직렬화 불가능 <ClientComponent onClick={() => console.log('clicked')} /> // ❌ 클래스 인스턴스 불가 <ClientComponent data={new Date()} /> // ✅ 직렬화 가능한 데이터만 가능 <ClientComponent name="John" count={42} items={['a', 'b', 'c']} createdAt="2024-01-01T00:00:00Z" // 문자열로 전달 />
2. Client Component에서 Server Component import 불가
'use client'; // ❌ Client Component 내에서 Server Component를 직접 import 불가 import ServerComponent from './ServerComponent'; function ClientComponent() { return <ServerComponent />; // 에러! }
대신 children으로 전달:
// page.jsx (Server Component) import ClientWrapper from './ClientWrapper'; import ServerChild from './ServerChild'; export default function Page() { return ( <ClientWrapper> <ServerChild /> {/* children으로 전달 */} </ClientWrapper> ); }
// ClientWrapper.jsx 'use client'; export default function ClientWrapper({ children }) { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}>Toggle</button> {isOpen && children} {/* Server Component가 여기 들어감 */} </div> ); }
성능 이점 정리
| 항목 | 기존 CSR/SSR | Server Components |
|---|---|---|
| 번들 크기 | 모든 코드 포함 | 인터랙티브 코드만 |
| 데이터 fetching | 클라이언트 워터폴 | 서버에서 병렬 처리 |
| 민감 정보 | 별도 API 필요 | 직접 접근 가능 |
| 초기 렌더링 | JS 실행 후 | 즉시 표시 |
| 캐싱 | 복잡한 설정 필요 | 자동 최적화 |
마무리
React Server Components는 단순한 기능 추가가 아니라 React의 패러다임 변화입니다:
- 기본은 Server: 클라이언트가 꼭 필요한 경우만
'use client'사용 - 제로 번들: Server Component 코드는 클라이언트로 전송되지 않음
- 직접 데이터 접근: 서버에서 DB, 파일시스템에 직접 접근
- 스트리밍: Suspense와 함께 점진적 렌더링
Next.js 13+ App Router를 사용한다면 이미 RSC를 사용하고 있는 것입니다. 아직 Pages Router를 사용 중이라면, 이제 App Router로의 마이그레이션을 고려해볼 때입니다.