React Server Components: 두 대의 컴퓨터를 위한 React
앞 장들에서 직렬화와 Streaming SSR을 다뤘습니다. 이제 이 모든 것이 어떻게 RSC로 귀결되는지 살펴봅니다.
문제 제기: Streaming SSR의 한계
Streaming SSR은 많은 문제를 해결했습니다:
- TTFB 개선
- 점진적 렌더링
- 서버 부하 분산
하지만 근본적인 한계가 남아있습니다:
// 이 컴포넌트는 서버에서 실행되지만...
async function BlogPost() {
const post = await db.posts.get(id);
return (
<article>
<MarkdownRenderer content={post.content} />
</article>
);
}
JavaScript
복사
MarkdownRenderer가 marked와 highlight.js를 사용한다면? 서버에서 렌더링되지만, 그 코드는 여전히 클라이언트 번들에 포함됩니다.
클라이언트 번들:
- react.js (100KB)
- marked.js (50KB) ← 서버에서만 쓰는데?
- highlight.js (200KB) ← 서버에서만 쓰는데?
- app.js (...)
Plain Text
복사
Streaming SSR은 “언제 보내느냐”를 최적화했지만, “무엇을 보내느냐”는 최적화하지 못했습니다.
핵심 아이디어: 두 종류의 컴포넌트
RSC의 핵심 통찰은 단순합니다:
모든 컴포넌트가 클라이언트에서 실행될 필요는 없다.
컴포넌트를 두 종류로 나눕니다:
Server Component | Client Component | |
실행 위치 | 서버에서만 | 서버(SSR) + 클라이언트 |
async/await | ||
DB, 파일 접근 | ||
useState, useEffect | ||
onClick 등 이벤트 | ||
클라이언트 번들 |
// Server Component (기본)
async function BlogPost({ id }) {
const post = await db.posts.get(id); // DB 직접 접근
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={id} /> {/* Client Component */}
</article>
);
}
// Client Component
'use client';
function LikeButton({ postId }) {
const [liked, setLiked] = useState(false); // 상태 사용
return (
<button onClick={() => setLiked(!liked)}> {/* 이벤트 핸들러 */}
{liked ? '❤️' : '🤍'}
</button>
);
}
JavaScript
복사
’use client’의 실제 의미
'use client'는 가장 많이 오해받는 지시어입니다.
이 섹션을 읽기 전에 하나만 기억하세요:
’use client’는 “클라이언트에서만 실행”이 아닙니다.
정확한 의미: “이 컴포넌트는 클라이언트 번들에 포함됩니다”
(서버에서도 SSR을 위해 실행됩니다!)
흔한 오해
왜 이렇게 오해하기 쉬운가?
- 이름이 “use client”라서
- 다른 프레임워크에서는 실제로 클라이언트 전용인 경우가 많아서
- “Server Component”와 대비되는 이름이라서
실제 의미
'use client'가 하는 것:
1. 클라이언트 번들에 코드 포함
2. 서버에서 이 컴포넌트를 참조(reference)로 처리
3. useState, useEffect 등 Hooks 사용 가능
4. onClick 등 이벤트 핸들러 사용 가능
'use client'가 하지 않는 것:
1.
SSR 비활성화 (여전히 서버에서 HTML 생성)
2.
서버 실행 완전 제외 (SSR 시 실행됨)
실행 흐름
서버 (SSR):
BlogPost 실행 → DB 조회 → HTML 생성
LikeButton도 실행됨! (HTML 생성용)
클라이언트:
LikeButton Hydration → 이벤트 핸들러 연결
Plain Text
복사
Client Component는 두 곳에서 실행됩니다:
- 서버: SSR을 위한 HTML 생성
- 클라이언트: Hydration과 이후 인터랙션
Import 규칙: 참조 시스템
RSC의 핵심 메커니즘은 import가 어떻게 동작하는가입니다.
네 가지 시나리오
From → To | 가능? | 동작 |
Server → Server | 일반 import | |
Client → Client | 일반 import | |
Server → Client | 참조로 변환 | |
Client → Server | 금지 |
Server → Client: 참조로 변환
// BlogPost.jsx (Server Component)
import { LikeButton } from './LikeButton'; // 'use client' 파일
async function BlogPost({ id }) {
return<LikeButton postId={id} />;
}
JavaScript
복사
번들러가 이 코드를 변환합니다:
// 서버 번들에서 LikeButton은 참조 객체가 됨
const LikeButton = {
$$typeof: Symbol.for('react.client.reference'),
$$id: './LikeButton.js',
$$name: 'LikeButton'
};
JavaScript
복사
서버는 LikeButton 함수를 실행하지 않습니다. “여기에 LikeButton이 들어간다”는 주소만 전달합니다.
Client → Server: 금지
// ❌ 불가능
'use client';
import { ServerComponent } from './ServerComponent';
JavaScript
복사
왜 금지될까요? 구체적인 보안 시나리오로 이해해봅시다.
// ServerComponent.jsx (Server Component)
async function ServerComponent() {
// 이런 코드가 있다고 상상해보세요
const db = await Database.connect('postgres://admin:secret123@db.company.com');
const apiKey = process.env.STRIPE_SECRET_KEY; // sk_live_xxx...
const data = await db.query('SELECT * FROM users');
return<UserList users={data} />;
}
JavaScript
복사
만약 Client Component에서 이걸 import할 수 있다면?
1. Client Component → 클라이언트 번들에 포함
2. import된 ServerComponent 코드도 → 클라이언트 번들에 포함
3. 브라우저 DevTools → Sources 탭 → 번들 파일 열기
4. 🚨 DB 연결 문자열, API 키 전부 노출!
Plain Text
복사
공격자가 할 수 있는 것:
- DB에 직접 접속해서 데이터 탈취
- 결제 API로 무단 거래
- 내부 시스템 접근
이것이 React가 컴파일 타임에 Client → Server import를 차단하는 이유입니다.
children 패턴: Client 안에 Server 넣기
Client → Server import가 금지되면, Client Component 안에 Server Component를 어떻게 넣을까요?
답: import하지 말고 props로 전달하라.
// ❌ 불가능
'use client';
import { ServerContent } from './ServerContent';
function ClientWrapper() {
return (
<div>
<ServerContent /> {/* 에러: Client에서 Server import */}
</div>
);
}
JavaScript
복사
// ✅ 가능: children 패턴
// Page.jsx (Server Component)
import { ClientWrapper } from './ClientWrapper';
import { ServerContent } from './ServerContent';
function Page() {
return (
<ClientWrapper>
<ServerContent /> {/* 여기서 이미 렌더링됨 */}
</ClientWrapper>
);
}
// ClientWrapper.jsx
'use client';
function ClientWrapper({ children }) {
const [show, setShow] = useState(true);
return show ? children : null; // children은 이미 렌더링된 JSX
}
JavaScript
복사
핵심 통찰:
- <ServerContent />는 Server Component(Page)에서 렌더링됨
- 그 결과가 children으로 전달됨
- ClientWrapper는 Server Component를 import하지 않음
RSC Payload: 무엇이 전송되는가
Server Component가 렌더링되면 RSC Payload가 생성됩니다. 이것은 5-1장에서 다룬 직렬화의 확장입니다.
// Server Component
async function Page() {
const user = await getUser();
return (
<div>
<h1>Hello, {user.name}</h1>
<LikeButton />
</div>
);
}
JavaScript
복사
RSC Payload:
0:["$","div",null,{"children":[
["$","h1",null,{"children":"Hello, Dan"}],
["$","$L1",null,{}]
]}]
1:I["./LikeButton.js","LikeButton"]
Plain Text
복사
•
["$","div",...]: div 요소
•
"Hello, Dan": 서버에서 이미 해결된 데이터
•
["$","$L1",...]: Client Component 참조 ($L1)
•
1:I["./LikeButton.js","LikeButton"]: 참조 정보
RSC Payload vs HTML
HTML (SSR) | RSC Payload | |
형식 | 문자열 | 구조화된 데이터 |
갱신 | 전체 페이지 | 부분 갱신 가능 |
상태 | 잃어버림 | 보존 가능 |
Hydration | 필요 | 선택적 |
RSC Payload의 장점:
- 클라이언트 상태를 유지하면서 서버 데이터 갱신 가능
- 필요한 부분만 Hydration
Streaming + RSC: Progressive JSON
RSC Payload도 스트리밍됩니다. 이것이 Progressive JSON입니다.
async function Page() {
const user = await getUser(); // 빠름 (100ms)
return (
<div>
<Header user={user} />
<Suspense fallback={<Loading />}>
<Posts /> {/* 느림 (500ms) */}
</Suspense>
</div>
);
}
JavaScript
복사
RSC Payload 스트리밍:
# 첫 청크 (즉시)
0:{"user":{"name":"Dan"}}
1:["$","div",null,{"children":[
["$","Header",...],
["$","$Sreact.suspense",{"fallback":"Loading...","children":"$2"}]
]}]
2:$Sreact.suspense
---
# 두 번째 청크 (500ms 후)
2:["posts":[...]]
Plain Text
복사
5-2장의 Fizz(HTML 스트리밍)와 결합:
1. Fizz: HTML을 청크 단위로 전송
2. Flight: RSC Payload를 청크 단위로 전송
서버:
[Fizz] → HTML 스트림 → 브라우저 (초기 렌더링)
[Flight] → RSC Payload 스트림 → React (갱신)
Plain Text
복사
불가능했던 것들
RSC가 해결하는 “불가능”했던 패턴들:
1. 서버 데이터 + 클라이언트 인터랙션
// ❌ 전통적 방식: API 분리 필요
// GET /api/post/123
// 클라이언트에서 fetch + 상태 관리
// ✅ RSC: 하나의 컴포넌트 트리
async function BlogPost({ id }) {
const post = await db.posts.get(id); // 서버에서 데이터
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={id} liked={post.liked} /> {/* 클라이언트 인터랙션 */}
</article>
);
}
JavaScript
복사
2. Zero-Bundle 컴포넌트
// 이 모든 라이브러리가 클라이언트 번들에 포함되지 않음
import { marked } from 'marked'; // 50KB
import hljs from 'highlight.js'; // 200KB
import { sanitize } from 'dompurify'; // 30KB
async function MarkdownRenderer({ content }) {
const html = marked(content, {
highlight: (code) => hljs.highlightAuto(code).value
});
const safe = sanitize(html);
return<div dangerouslySetInnerHTML={{ __html: safe }} />;
}
JavaScript
복사
Server Component 코드는 서버에만 존재합니다. 클라이언트 번들 크기: 0KB.
3. 조건부 코드 로딩
async function Editor({ userId }) {
const user = await db.users.get(userId);
if (user.isPremium) {
return<PremiumEditor />; // 프리미엄 사용자만 이 번들 로드
}
return<BasicEditor />; // 일반 사용자는 이것만
}
JavaScript
복사
서버에서 조건을 평가하고, 필요한 Client Component만 클라이언트에 전송합니다.
4. 보안 강화
async function AdminPanel({ userId }) {
const user = await db.users.get(userId);
if (!user.isAdmin) {
return<AccessDenied />;
}
// 이 코드는 서버에만 존재
const secrets = await db.secrets.getAll();
return<SecretsList secrets={secrets} />;
}
JavaScript
복사
민감한 로직과 데이터가 서버에만 존재합니다. 클라이언트 코드를 아무리 분석해도 접근 불가.
RSC의 트레이드오프
RSC가 만능은 아닙니다. 얻는 것이 있으면 잃는 것도 있습니다.
1. 서버 의존성 증가
CSR: [CDN] → 정적 파일 → 클라이언트에서 모든 처리
RSC: [서버] → 매 요청마다 컴포넌트 실행 → 결과 전송
Plain Text
복사
비용 증가:
- 서버 인프라 필요 (Vercel, AWS 등)
- 정적 호스팅(GitHub Pages, Netlify Static)만으로 불가
- 서버리스 함수 호출 비용
- Cold start 문제 (서버리스 환경)
대기 시간 추가:
- 클라이언트 근처 CDN이 아닌 서버까지 왕복
- 서버 컴포넌트 실행 시간
- Edge Runtime으로 완화 가능하지만 제약 있음
2. 새로운 멘탈 모델
// 이게 Server일까 Client일까?
function Counter() {
const [count, setCount] = useState(0); // 에러!
return<button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
JavaScript
복사
학습 곡선:
- Server/Client 경계 이해 필요
- 어떤 컴포넌트에 ’use client’가 필요한지 판단
- children 패턴 같은 새로운 구성 방식
- 직렬화 가능/불가능 타입 숙지
디버깅 복잡성:
- 에러가 서버에서 났는지 클라이언트에서 났는지
- RSC Payload 이해 필요 (개발자 도구로 직접 보기 어려움)
- 하이드레이션 미스매치 디버깅
3. 생태계 호환성
// ❌ 많은 라이브러리가 아직 RSC 미지원
import { DatePicker } from 'legacy-ui-library'; // 'use client' 필요할 수 있음
import { Chart } from 'chart-library'; // window 객체 접근
JavaScript
복사
라이브러리 제약:
- 브라우저 API 사용 라이브러리 → Client Component 필요
- Context API 의존 라이브러리 → 경계 처리 복잡
- CSS-in-JS 일부 (런타임 의존) → 호환성 문제
기존 코드 마이그레이션:
- 대규모 CSR 앱을 RSC로 전환하는 건 쉽지 않음
- 컴포넌트별로 Server/Client 분류 작업 필요
- 상태 관리 패턴 재검토 (전역 상태 → 서버 데이터)
4. 캐싱 복잡성
정적 페이지: [빌드] → HTML 캐시 → 빠른 응답
RSC: [요청] → 서버 실행 → 캐시? → 응답
Plain Text
복사
캐싱 전략 필요:
- RSC Payload를 언제, 어떻게 캐시할 것인가?
- 사용자별 데이터 vs 공통 데이터 분리
- revalidation 전략 (시간 기반, 온디맨드)
- Next.js의 복잡한 캐싱 레이어 이해 필요
5. 테스트 복잡성
// Server Component 테스트
// - 실제 서버 환경 또는 모킹 필요
// - async 컴포넌트 처리
// - DB/API 모킹
// Client Component 테스트
// - 기존 방식과 유사
// - 하지만 props로 받는 서버 데이터 모킹 필요
JavaScript
복사
6. 번들러 의존성
RSC는 번들러와 깊게 통합되어야 합니다:
- Client Component 참조 생성
- 코드 분할
- import 변환
현재 온전한 RSC 지원:
-
Next.js (App Router)
-
Remix (실험적)
-
기타 프레임워크: 제한적이거나 미지원
프레임워크 종속:
- React만으로는 RSC 사용 불가
- 프레임워크 선택지가 제한됨
- 프레임워크 버전 업그레이드에 묶임
트레이드오프 요약
얻는 것 | 잃는 것 |
번들 크기 감소 | 서버 인프라 필요 |
서버 데이터 직접 접근 | 정적 호스팅 불가 |
민감 로직 보호 | 새로운 멘탈 모델 학습 |
Colocation | 디버깅 복잡성 |
조건부 코드 로딩 | 캐싱 전략 필요 |
스트리밍 | 프레임워크 종속성 |
RSC가 적합하지 않은 경우
•
정적 사이트: 블로그, 문서 사이트 → SSG가 더 단순
•
오프라인 우선 앱: PWA → CSR + Service Worker가 적합
•
실시간 앱: 채팅, 게임 → WebSocket 중심 아키텍처
•
서버 비용 민감: 트래픽 예측 어려운 경우 → CSR + CDN
•
팀 역량: RSC 경험 없는 팀 → 학습 비용 고려
비교: 렌더링 아키텍처의 진화
시대 | 방식 | 장점 | 한계 |
2000s | PHP/JSP | 서버 렌더링 | 인터랙션 어려움 |
2010s | SPA (CSR) | 풍부한 인터랙션 | 초기 로딩 느림, SEO |
2015+ | SSR + Hydration | 빠른 FCP, SEO | 전체 번들 필요 |
2020+ | Streaming SSR | TTFB 개선 | 여전히 전체 번들 |
2023+ | RSC | 선택적 번들, 서버 로직 | 서버 의존성, 복잡성 |
RSC 사용 가이드
기본 원칙
Server Component (기본):
- 데이터 페칭
- 마크다운 렌더링, 구문 강조 등 무거운 처리
- 민감한 로직 (API 키, DB 접근)
- 번들 크기 최적화가 필요한 곳
Client Component (‘use client’):
- useState, useEffect 필요할 때
- 이벤트 핸들러 (onClick, onChange 등)
- 브라우저 API 접근 (localStorage, geolocation 등)
- 사용자 인터랙션이 필요한 곳
흔한 실수
// ❌ 불필요하게 'use client' 사용
'use client'; // 왜?
function StaticContent() {
return<div>Hello World</div>; // 상태도 이벤트도 없음
}
// ✅ Server Component로 충분
function StaticContent() {
return<div>Hello World</div>;
}
JavaScript
복사
// ❌ 너무 높은 곳에 'use client'
'use client'; // 전체 페이지가 Client Component가 됨
function Page() {
return (
<div>
<Header />
<Content /> {/* 이것들도 전부 클라이언트 번들에 포함 */}
<Footer />
</div>
);
}
// ✅ 필요한 곳에만 'use client'
function Page() {
return (
<div>
<Header />
<Content />
<InteractiveWidget /> {/* 이것만 'use client' */}
<Footer />
</div>
);
}
JavaScript
복사
정리: RSC의 본질
RSC는 결국 이전 장들의 제약에서 비롯됩니다:
1.
직렬화 제약 (5-1장): 함수는 전송 불가 → 이벤트 핸들러는 클라이언트에 있어야 함
2.
스트리밍 가능 (5-2장): 준비된 것부터 전송 → Suspense와 결합
3.
두 종류의 컴포넌트: 서버 전용 vs 클라이언트 가능
RSC의 핵심 가치:
- Colocation: 데이터 페칭과 UI가 같은 곳에
- Zero-Bundle: 서버 코드는 클라이언트 번들에 불포함
- 보안: 민감한 로직이 서버에만 존재
- 점진적 채택: 기존 코드와 공존 가능
[하나의 컴포넌트 트리]
↓
[Server Components] + [Client Components]
↓ ↓
서버에서만 실행 서버(SSR) + 클라이언트
↓ ↓
RSC Payload로 번들에 포함되어
결과만 전달 Hydration
Plain Text
복사
부록: RSC 용어 정리
용어 | 의미 |
Server Component | 서버에서만 실행되는 컴포넌트 (기본값) |
Client Component | ’use client’가 붙은 컴포넌트 |
RSC Payload | Server Component 렌더링 결과 (Flight 형식) |
Flight | RSC Payload 직렬화/스트리밍 시스템 |
Fizz | HTML 스트리밍 시스템 |
참조 (Reference) | Client Component의 서버 측 표현 |
children 패턴 | Client 안에 Server를 넣는 방법 |
부록: Import 규칙 다이어그램
┌─────────────────────────────────────────┐
│ Server Components │
│ ┌─────────────────────────────────┐ │
│ │ async function Page() { │ │
│ │ const data = await fetch(); │ │
│ │ return <ClientButton />; │ │
│ │ } │ │
│ └─────────────────────────────────┘ │
│ ↓ 참조로 변환 ↓ │
├─────────────────────────────────────────┤
│ 'use client' 경계 │
├─────────────────────────────────────────┤
│ Client Components │
│ ┌─────────────────────────────────┐ │
│ │ 'use client'; │ │
│ │ function ClientButton() { │ │
│ │ const [x, setX] = useState(); │ │
│ │ return <button>...</button>; │ │
│ │ } │ │
│ └─────────────────────────────────┘ │
│ ↑ import 금지 ↑ │
└─────────────────────────────────────────┘
Plain Text
복사
다음: Server Actions
RSC로 서버에서 데이터를 “읽어오는” 방법을 배웠습니다.
하지만 데이터를 “쓰는” 건 어떻게 할까요? 폼 제출, 버튼 클릭으로 서버 상태를 변경하려면?
전통적으로 이건 API 엔드포인트를 만들고, fetch로 POST 요청을 보내는 방식이었습니다:
// 전통적 방식
async function handleSubmit(formData) {
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text: formData.get('text') })
});
}
TypeScript
복사
다음 장에서는 Server Actions를 다룹니다. 클라이언트에서 서버 함수를 직접 호출하는 것처럼 보이게 만드는 기술입니다.
// Server Actions
'use server';
async function addTodo(formData) {
await db.todos.create({ text: formData.get('text') });
}
// 클라이언트에서 그냥 호출
<form action={addTodo}>
<input name="text" />
</form>
TypeScript
복사
이게 어떻게 가능한 걸까요? 답은 오래된 개념, RPC(Remote Procedure Call)에 있습니다.
