Search

06-3_RSC

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
복사
MarkdownRenderermarkedhighlight.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’는 클라이언트에서만 실행된다”
왜 이렇게 오해하기 쉬운가? - 이름이 “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)에 있습니다.