jblog
React Server Components 딥다이브
Frontend

React Server Components 딥다이브

RSC가 무엇이고, 어떻게 동작하며, 왜 프론트엔드의 패러다임을 바꾸고 있는지 깊이 파고듭니다.

2025-12-0916 min readfrontend, react, next.js, optimization

#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" } }
  ]
}

h1p는 이미 서버에서 결과가 나왔으니 텍스트만 보낸다.

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} />;
}

실제 성능 차이는 극적이다.

지표기존 SSRRSC + Streaming
TTFB350~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를 비교해보면,

CSRSSRSSGISRRSC
렌더링 위치클라이언트서버 (페이지 단위)빌드 타임빌드 타임 + 주기적서버 (컴포넌트 단위)
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는 단순한 렌더링 방식의 변화가 아니라, 프론트엔드 아키텍처의 패러다임 전환이다.

요약하면,

  1. Server Component: 서버에서만 실행, JS 번들 0KB, DB 직접 접근 가능
  2. Client Component: 기존처럼 브라우저에서 실행, 인터랙션 담당
  3. Streaming: 준비된 부분부터 즉시 전송, Suspense로 로딩 상태 관리
  4. Server Actions: API Route 없이 서버 함수 직접 호출
  5. 보안: 새로운 공격 표면에 대한 인식 필요

이전에 "렌더링 방식 고찰" 글에서 이런 표를 만들었었다.

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로 데이터를 가져올 수 있는 경험은 정말 신세계다.


#Reference

댓글

댓글을 불러오는 중...