React Server Components 딥다이브
RSC가 무엇이고, 어떻게 동작하며, 왜 프론트엔드의 패러다임을 바꾸고 있는지 깊이 파고듭니다.
#CSR, SSR 다음은 뭐야?
이전에 "렌더링 방식 고찰" 글에서 CSR, SSR, SSG, ISR을 비교했었다.
그때는 "상황에 맞게 골라서 쓰면 된다"는 결론이었는데,
2025년을 지나면서 느낀 건 — React Server Components(RSC)가 이 모든 것을 아우르는 새로운 패러다임이 되고 있다는 것이다.

RSC는 단순히 "서버에서 렌더링하는 컴포넌트"가 아니다.
서버와 클라이언트의 경계를 컴포넌트 단위로 나누는 아키텍처 혁명이다.
이 글에서는 RSC가 정확히 무엇이고, 어떻게 동작하며, 왜 이전의 렌더링 방식들과 근본적으로 다른지 파고들어보겠다.
먼저, SSR과 RSC의 차이를 명확히
많은 사람들이 RSC와 SSR을 헷갈린다. 나도 처음에 그랬다.
"둘 다 서버에서 렌더링하는 거 아닌가?"
아니다. 근본적으로 다르다.
SSR: 페이지 단위 렌더링
1. 서버에서 전체 페이지를 HTML로 렌더링
2. HTML + 거대한 JS 번들을 클라이언트에 전송
3. 클라이언트에서 Hydration (HTML에 이벤트 리스너 연결)
4. Hydration 완료 후 인터랙션 가능
핵심: 모든 컴포넌트의 JS 코드가 클라이언트로 전송된다. 서버에서 렌더링했지만, 클라이언트에서도 같은 코드를 다시 실행해야 한다 (Hydration).
RSC: 컴포넌트 단위 분리
1. Server Component → 서버에서만 실행, JS 코드가 클라이언트에 전송되지 않음
2. Client Component → 기존처럼 클라이언트에서 실행
3. 둘을 컴포넌트 트리에서 자유롭게 섞어서 사용
핵심: Server Component의 JS 코드는 브라우저에 절대 도달하지 않는다. 서버에서 실행된 결과(RSC Payload)만 전송된다.
비유로 이해하기
SSR: 요리사가 레스토랑에서 음식을 만들어서 배달한다. 하지만 **레시피(JS 코드)**도 같이 배달한다. 손님이 "이거 데워줘"라고 하면 레시피를 보고 다시 만들어야 한다. (Hydration)
RSC: 요리사가 레스토랑에서 음식을 만들어서 배달한다. 레시피는 안 보낸다. 손님은 먹기만 하면 된다. 단, "소스 추가해줘" 같은 인터랙션이 필요한 부분만 별도의 작은 레시피(Client Component JS)를 보낸다.

RSC의 동작 원리: React Flight Protocol
RSC의 내부 동작을 이해하려면 React Flight Protocol을 알아야 한다.
RSC는 HTML을 보내는 게 아니다. 직렬화된 React 컴포넌트 트리를 보낸다.
이것을 RSC Payload라고 부른다.
일반 SSR: 서버 → HTML 문자열 → 클라이언트
RSC: 서버 → RSC Payload (직렬화된 컴포넌트 트리) → 클라이언트
RSC Payload가 뭔데?
쉽게 말하면 "React 컴포넌트 트리의 설계도" 다.
// 서버에서 이런 컴포넌트가 렌더링되면
function UserProfile({ userId }) {
const user = await db.query(`SELECT * FROM users WHERE id = $1`, [userId]);
return (
<div>
<h1>{user.name}</h1> // Server Component 결과
<p>{user.bio}</p> // Server Component 결과
<FollowButton userId={userId} /> // Client Component 참조
</div>
);
}
서버는 이렇게 변환해서 보낸다 (간소화):
// RSC Payload (실제로는 더 복잡하지만 개념적으로)
{
type: "div",
children: [
{ type: "h1", children: "김준범" }, // 이미 렌더링됨
{ type: "p", children: "프론트엔드 개발자" }, // 이미 렌더링됨
{ type: "@client/FollowButton", // 클라이언트에서 렌더링
props: { userId: "123" } }
]
}
h1과 p는 이미 서버에서 결과가 나왔으니 텍스트만 보낸다.
FollowButton은 클라이언트에서 실행해야 하니 참조만 보낸다. 클라이언트가 이 참조를 받아서 실제 컴포넌트를 렌더링한다.
Streaming: 기다리지 말고 보여줘
RSC의 진짜 파워는 Streaming과 결합했을 때 나온다.
기존 SSR의 문제
요청 → 데이터 전부 가져오기 (3초) → HTML 전부 렌더링 → 전부 전송 → 화면에 표시
사용자는 3초 동안 빈 화면을 본다.
RSC + Streaming
요청 → 헤더/사이드바 즉시 전송 → 사용자가 헤더를 봄
→ 메인 데이터 로딩 중... (Suspense fallback 표시)
→ 데이터 도착 → 해당 부분만 스트리밍으로 전송 → 화면 업데이트
사용자는 즉시 헤더와 네비게이션을 보고, 데이터가 준비되는 대로 나머지가 채워진다.
// Next.js App Router에서의 Streaming
export default async function DashboardPage() {
return (
<div>
<Header /> {/* 즉시 렌더링 */}
<Sidebar /> {/* 즉시 렌더링 */}
<Suspense fallback={<ChartSkeleton />}>
<ChartSection /> {/* 데이터 로딩 후 스트리밍 */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable /> {/* 데이터 로딩 후 스트리밍 */}
</Suspense>
</div>
);
}
// 이 컴포넌트는 3초 걸려도, 다른 부분은 이미 보여지고 있다
async function ChartSection() {
const data = await fetchAnalytics(); // 3초 소요
return <Chart data={data} />;
}실제 성능 차이는 극적이다.
| 지표 | 기존 SSR | RSC + Streaming |
|---|---|---|
| TTFB | 350~550ms (모든 데이터 대기) | 40~90ms (정적 쉘 즉시 전송) |
| LCP | 데이터 로딩에 의존 | 정적 콘텐츠 즉시 |
| 체감 속도 | "로딩 중..." 한참 | 즉시 레이아웃 보임 |
이전에 내가 측정했던 SSR의 TTFB가 0.533s였는데, RSC + Streaming으로 0.04~0.09s까지 줄어든다니 정말 인상적이다.

"use client"와 "use server" 경계
RSC에서 가장 중요한 개념은 서버-클라이언트 경계다.
규칙 1: 기본값은 Server Component
Next.js App Router에서 모든 컴포넌트는 기본적으로 Server Component다.
// app/page.tsx — Server Component (기본값)
export default async function Page() {
// ✅ 서버에서 직접 DB 접근 가능!
const posts = await db.post.findMany();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}규칙 2: 인터랙션이 필요하면 "use client"
"use client"; // 이 지시어가 경계를 만든다
import { useState } from "react";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "❤️" : "🤍"} 좋아요
</button>
);
}useState, useEffect, onClick 같은 브라우저 API나 인터랙션이 필요하면 "use client"를 선언한다.
규칙 3: 조합 규칙
이 부분이 처음에 가장 헷갈렸다.
✅ Server Component → Client Component (import 가능)
❌ Client Component → Server Component (import 불가!)
✅ Client Component의 children으로 Server Component 전달 (가능!)
세 번째가 핵심이다. children prop을 통한 합성(composition) 이 RSC의 탈출구다.
// ✅ 이렇게 하면 된다
// layout.tsx (Server Component)
export default function Layout({ children }) {
return (
<ThemeProvider> {/* Client Component */}
{children} {/* Server Component가 children으로 들어감 */}
</ThemeProvider>
);
}// ❌ 이렇게 하면 안 된다
"use client";
import { ServerComponent } from "./ServerComponent"; // Error!
export function ClientWrapper() {
return <ServerComponent />; // Client에서 Server를 import할 수 없음
}Server Actions: RPC가 이렇게 쉬워?
Server Actions는 서버에서 실행되는 함수를 클라이언트에서 직접 호출할 수 있게 해준다.
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// 서버에서 직접 DB에 저장
await db.post.create({ data: { title, content } });
// 캐시 무효화
revalidatePath("/posts");
}// components/CreatePostForm.tsx
"use client";
import { createPost } from "@/app/actions";
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="제목" />
<textarea name="content" placeholder="내용" />
<button type="submit">작성</button>
</form>
);
}API Route를 만들 필요가 없다. fetch를 쓸 필요도 없다. 그냥 함수를 호출하면 서버에서 실행된다.
솔직히 처음 봤을 때 "이게 된다고?" 싶었다.

물론 내부적으로는 HTTP 요청이 발생한다. 하지만 개발자 경험(DX) 관점에서는 그냥 함수 호출처럼 느껴진다.
PHP 시절의 "서버에서 다 하던" 방식이 모던한 형태로 돌아온 느낌이다. 역사는 반복된다.
이전 렌더링 방식과의 비교
내가 이전에 정리했던 렌더링 방식들과 RSC를 비교해보면,
| CSR | SSR | SSG | ISR | RSC | |
|---|---|---|---|---|---|
| 렌더링 위치 | 클라이언트 | 서버 (페이지 단위) | 빌드 타임 | 빌드 타임 + 주기적 | 서버 (컴포넌트 단위) |
| JS 번들 | 전체 | 전체 (Hydration) | 전체 (Hydration) | 전체 (Hydration) | Server Component는 0KB |
| 데이터 페칭 | 클라이언트 | 서버 (getServerSideProps) | 빌드 타임 | 빌드 + revalidate | 컴포넌트 내부에서 직접 |
| 인터랙션 | 즉시 | Hydration 후 | Hydration 후 | Hydration 후 | Client Component만 |
| TTFB | 빠름 | 느림 | 빠름 | 빠름 | 매우 빠름 (Streaming) |
RSC의 가장 큰 차별점은 "컴포넌트 단위로 서버/클라이언트를 나눌 수 있다" 는 것이다.
이전에는 페이지 전체가 CSR이거나 SSR이었는데, RSC에서는 같은 페이지 안에서 어떤 컴포넌트는 서버에서, 어떤 컴포넌트는 클라이언트에서 실행된다.
RSC의 멘탈 모델
Vercel의 블로그에서 본 멘탈 모델이 정말 와닿았다.
"UI를 대부분 정적인 문서 + 인터랙티브한 아일랜드로 생각해라."
블로그 글을 예로 들면,
- 제목, 본문, 메타데이터 → 정적인 문서 (Server Component)
- 좋아요 버튼, 댓글 폼, 목차 토글 → 인터랙티브 아일랜드 (Client Component)
대부분의 웹 페이지는 사실 읽기 전용 콘텐츠가 90% 이상이다. 인터랙션이 필요한 부분은 생각보다 적다.
RSC는 이 현실을 반영한 아키텍처다.
주의할 점: React2Shell의 교훈
"Frontend Wrapped 2025" 글에서도 다뤘지만, RSC는 새로운 공격 표면을 만든다.
서버에서 코드를 실행한다는 것은, 잘못된 입력이 서버에서 실행될 수 있다는 뜻이다.
Server Actions에서 입력 검증을 반드시 해야 한다.
"use server";
import { z } from "zod";
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
});
export async function createPost(formData: FormData) {
// ⚠️ 반드시 서버에서 검증!
const parsed = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!parsed.success) {
return { error: "유효하지 않은 입력입니다" };
}
await db.post.create({ data: parsed.data });
}클라이언트 검증만으로는 부족하다. 서버에서도 반드시 검증해야 한다.
정리하며
RSC는 단순한 렌더링 방식의 변화가 아니라, 프론트엔드 아키텍처의 패러다임 전환이다.
요약하면,
- Server Component: 서버에서만 실행, JS 번들 0KB, DB 직접 접근 가능
- Client Component: 기존처럼 브라우저에서 실행, 인터랙션 담당
- Streaming: 준비된 부분부터 즉시 전송, Suspense로 로딩 상태 관리
- Server Actions: API Route 없이 서버 함수 직접 호출
- 보안: 새로운 공격 표면에 대한 인식 필요
이전에 "렌더링 방식 고찰" 글에서 이런 표를 만들었었다.
CSR: 2.40s / SSR: 1.10s / SSG: 0.60s / ISR: 0.95s (LCP 기준)
RSC + Streaming이라면 여기서 또 한 단계 줄어든다.
프론트엔드의 역사를 보면 결국 같은 패턴이 반복된다.
서버에서 다 하던 시대 (PHP) → 클라이언트에서 다 하던 시대 (SPA/CSR) → 다시 서버로 돌아가는 시대 (RSC)
하지만 단순히 "과거로 돌아가는 것"이 아니다. SPA 시대에 쌓은 경험(컴포넌트 기반 설계, 선언적 UI, 타입 안전성)을 가지고 서버의 장점을 결합하는 것이다.
"The best of both worlds."

RSC를 아직 써보지 않았다면, Next.js App Router로 작은 프로젝트를 하나 만들어보는 것을 추천한다.
"use client"를 붙이지 않아도 async/await로 데이터를 가져올 수 있는 경험은 정말 신세계다.