React 서버 컴포넌트(RSC)는 2023년 Next.js 13 App Router와 함께 정식 출시되어, 2025년 현재 프로덕션 환경에서 널리 사용되고 있습니다. 전통적인 CSR/SSR의 한계를 극복하고, 컴포넌트 단위로 렌더링 전략을 선택할 수 있는 혁신적인 패러다임입니다.
RSC가 웹 개발 패러다임을 바꾸는 이유
기존 접근법의 한계
전통적인 CSR (Client-Side Rendering):
번들 크기가 증가할수록 초기 로딩 느림
모든 라이브러리가 클라이언트로 전송
SEO 초기 대응 어려움
전통적인 SSR (Server-Side Rendering):
전체 페이지 단위 렌더링
Hydration 비용 (클라이언트에서 재실행)
인터랙티브 컴포넌트와 정적 컴포넌트 구분 불가
RSC의 혁신
React 서버 컴포넌트는 컴포넌트 레벨에서 렌더링 전략을 선택할 수 있습니다.
특성
서버 컴포넌트
클라이언트 컴포넌트
실행 위치
서버
클라이언트
번들 포함
포함 안됨
포함됨
데이터 페칭
직접 가능 (async/await)
useEffect/SWR 필요
인터랙션
불가능
가능 (useState, event handlers)
패키지 사용
서버 전용 (fs, database 등)
브라우저 전용 (DOM API)
재렌더링
페이지 네비게이션 시
상태 변경 시
핵심 이점:
번들 크기 혁신: 서버 컴포넌트는 번들에 포함되지 않음 → 100KB+ 라이브러리도 부담 없음
워터폴 제거: 컴포넌트 내부에서 직접 데이터 페칭 → 병렬 처리 가능
자동 코드 스플리팅: 클라이언트 컴포넌트만 분리 전송
Streaming: 준비된 컴포넌트부터 점진적 렌더링
Server and Client components work together across the serialization boundary
Server/Client 경계 설계 원칙
가장 중요한 의사결정은 "어떤 컴포넌트를 서버/클라이언트로 만들 것인가"입니다.
기본 원칙
서버 컴포넌트 기본 (Default):
Next.js App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트
'use client' 지시어가 없으면 서버 컴포넌트
클라이언트 컴포넌트로 만들어야 하는 경우:
상태 관리 (useState, useReducer)
이벤트 핸들러 (onClick, onChange 등)
브라우저 API (window, localStorage)
React Hooks (useEffect, useContext 등)
클래스 컴포넌트
직렬화 경계 (Serialization Boundary)
서버 컴포넌트에서 클라이언트 컴포넌트로 전달되는 props는 직렬화 가능해야 합니다.
전달 가능:
JSON 직렬화 가능한 데이터 (string, number, boolean, array, plain object)
React 노드 (JSX)
Promise (특별 처리)
전달 불가능:
함수 (이벤트 핸들러 제외)
클래스 인스턴스
Date, Map, Set (JSON.stringify 시 손실)
tsx
// 잘못된 예
// app/page.tsx (Server Component)
import ClientComponent from './ClientComponent';
export default async function Page() {
const data = await fetchData();
return (
<ClientComponent
onUpdate={() => console.log('update')} // ❌ 함수 전달 불가
date={new Date()} // ❌ Date 객체 직렬화 문제
/>
);
}
// 올바른 예
export default async function Page() {
const data = await fetchData();
return (
<ClientComponent
data={data} // ✅ plain object
timestamp={new Date().toISOString()} // ✅ string
/>
);
}
컴포지션 패턴 (Composition Pattern)
서버 컴포넌트를 클라이언트 컴포넌트의 children으로 전달하는 패턴입니다.
tsx
// ClientWrapper.tsx
'use client';
import { useState } from 'react';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Hide' : 'Show'}
</button>
{isOpen && <div>{children}</div>}
</div>
);
}
// app/page.tsx (Server Component)
import { ClientWrapper } from './ClientWrapper';
export default async function Page() {
const data = await fetchData();
return (
<ClientWrapper>
{/* 이 부분은 여전히 서버 컴포넌트! */}
<ServerDataDisplay data={data} />
</ClientWrapper>
);
}
이 패턴의 장점:
ServerDataDisplay는 서버에서 렌더링되어 HTML로 전달
ClientWrapper의 번들에 포함되지 않음
서버 전용 라이브러리 사용 가능
Data Fetching 패턴
Async 서버 컴포넌트
서버 컴포넌트는 async/await를 사용할 수 있습니다.
tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 } // 1시간 캐시
});
if (!res.ok) return null;
return res.json();
}
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}