본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 2장: React Server Components 아키텍처 심층 분석
2026년 1월 25일·웹 개발·

2장: React Server Components 아키텍처 심층 분석

React Server Components의 동작 원리, 직렬화 프로토콜, 번들 전략, 합성 규칙을 심층적으로 분석합니다. 서버와 클라이언트의 경계를 이해합니다.

23분1,002자10개 섹션
reactnextjsperformancefrontendtypescript
공유
react19-rsc2 / 11
1234567891011
이전1장: React 19의 등장과 새로운 패러다임다음3장: Server Actions로 서버-클라이언트 통합하기

1장에서 React 19의 전체 그림을 살펴보았습니다. 이번 장에서는 React 19의 가장 근본적인 변화인 React Server Components(RSC)의 아키텍처를 심층적으로 분석합니다. RSC가 왜 필요한지, 어떻게 동작하는지, 그리고 서버와 클라이언트 컴포넌트의 합성 규칙은 무엇인지를 명확하게 이해하는 것이 이 시리즈 전체를 관통하는 핵심 기반입니다.

Server Components란 무엇인가

React Server Components는 서버 환경에서만 실행되는 컴포넌트입니다. 빌드 타임의 CI 서버에서 실행될 수도 있고, 요청 시점의 웹 서버에서 실행될 수도 있습니다. 핵심은 이 컴포넌트의 JavaScript 코드가 절대 클라이언트 번들에 포함되지 않는다는 점입니다.

Server Component (기본값 -- 디렉티브 불필요)
typescript
// app/article/[id]/page.tsx
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
 
async function ArticlePage({ params }: { params: { id: string } }) {
  const article = await db.articles.findUnique({
    where: { id: params.id },
  });
 
  // marked(~35KB)와 sanitize-html(~40KB)은
  // 클라이언트 번들에 포함되지 않음
  const html = sanitizeHtml(marked(article.content));
 
  return (
    <article>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
      <CommentSection articleId={article.id} />
    </article>
  );
}

이 코드에서 marked와 sanitize-html 라이브러리는 서버에서만 사용되므로, gzip 기준 약 75KB 이상의 JavaScript가 클라이언트 번들에서 제거됩니다.

Server Component vs Client Component vs SSR

이 세 가지 개념은 자주 혼동됩니다. 명확하게 구분해봅시다.

SSR (Server-Side Rendering)

기존의 SSR은 같은 컴포넌트 코드를 서버와 클라이언트 양쪽에서 실행합니다.

  1. 서버에서 컴포넌트를 렌더링하여 HTML을 생성합니다.
  2. 브라우저가 HTML을 받아 즉시 표시합니다 (First Paint).
  3. JavaScript 번들을 다운로드하고 하이드레이션(Hydration)합니다.
  4. 하이드레이션 이후 인터랙션이 가능해집니다.

SSR의 핵심 한계는 모든 컴포넌트의 JavaScript가 여전히 클라이언트로 전송된다는 점입니다.

Server Components

Server Components는 서버에서만 실행됩니다.

  1. 서버에서 컴포넌트를 실행하여 React 트리(RSC Payload)를 생성합니다.
  2. RSC Payload를 클라이언트로 전송합니다.
  3. 클라이언트는 RSC Payload를 해석하여 Client Component와 조합합니다.
  4. Client Component만 하이드레이션합니다.

Server Components의 JavaScript는 클라이언트에 전송되지 않으므로, 번들 크기가 줄어들고 하이드레이션 비용이 감소합니다.

Client Components

'use client' 디렉티브로 표시된 컴포넌트입니다. 기존 React 컴포넌트와 동일하게 동작하며, useState, useEffect 등의 클라이언트 훅을 사용할 수 있습니다.

Client Component
typescript
'use client';
 
import { useState } from 'react';
 
function LikeButton({ articleId }: { articleId: string }) {
  const [liked, setLiked] = useState(false);
 
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '좋아요 취소' : '좋아요'}
    </button>
  );
}
Warning

'use client'는 "이 컴포넌트가 클라이언트에서만 실행된다"는 뜻이 아닙니다. SSR을 사용하는 경우 Client Component도 서버에서 HTML을 생성합니다. 'use client'는 서버-클라이언트 경계를 정의하는 진입점이며, 이 파일과 이 파일이 임포트하는 모듈들이 클라이언트 번들에 포함된다는 의미입니다.

세 가지 비교

특성SSRServer ComponentClient Component
실행 위치서버 + 클라이언트서버만클라이언트 (+ SSR)
JS 번들 포함포함미포함포함
useState/useEffect사용 가능사용 불가사용 가능
async/await사용 불가사용 가능사용 불가
DB/파일 직접 접근불가가능불가
이벤트 핸들러가능불가가능
하이드레이션필요불필요필요

RSC 렌더링 파이프라인

Server Components가 실제로 어떤 과정을 거쳐 화면에 표시되는지 단계별로 살펴봅시다.

1단계: 서버 렌더링

서버는 컴포넌트 트리를 순회하며 Server Components를 실행합니다.

컴포넌트 트리 예시
typescript
// Server Component
async function Page() {
  const posts = await fetchPosts();
  return (
    <main>
      <h1>블로그</h1>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
      <Newsletter />   {/* Client Component */}
    </main>
  );
}
 
// Server Component
function PostCard({ post }: { post: Post }) {
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.excerpt}</p>
      <LikeButton postId={post.id} />  {/* Client Component */}
    </article>
  );
}

서버는 Page와 PostCard를 실행하여 렌더링 결과를 생성합니다. Newsletter와 LikeButton은 Client Component이므로, 서버는 이들의 참조(reference)만 남기고 넘어갑니다.

2단계: RSC Payload 생성

렌더링 결과는 RSC Payload라는 특별한 직렬화 형식으로 변환됩니다. 이것은 HTML이 아닌, React 트리를 표현하는 스트리밍 가능한 데이터 형식입니다.

// RSC Payload (간략화)
0: ["$", "main", null, {
  "children": [
    ["$", "h1", null, { "children": "블로그" }],
    ["$", "article", null, {
      "children": [
        ["$", "h2", null, { "children": "React 19 입문" }],
        ["$", "p", null, { "children": "React 19의 새로운..." }],
        ["$", "@1", null, { "postId": "1" }]  // Client Component 참조
      ]
    }],
    ["$", "@2", null, {}]  // Client Component 참조
  ]
}]

여기서 @1과 @2는 Client Component 번들의 참조입니다. 실제 컴포넌트 코드가 아닌, 번들러가 생성한 모듈 참조를 포함합니다.

3단계: 클라이언트 재구성

브라우저는 RSC Payload를 받아서 다음을 수행합니다.

  1. Server Component의 렌더링 결과를 DOM으로 변환합니다.
  2. Client Component 참조를 만나면 해당 JavaScript 모듈을 로드합니다.
  3. Client Component를 렌더링하고 하이드레이션합니다.

이 과정에서 Server Components의 코드는 한 번도 클라이언트에 전송되지 않습니다.

합성 규칙: 서버와 클라이언트의 경계

Server Components와 Client Components를 조합할 때 반드시 지켜야 할 규칙들이 있습니다. 이 규칙들을 정확히 이해하는 것이 RSC 아키텍처의 핵심입니다.

규칙 1: Server Component에서 Client Component 임포트 가능

가장 기본적이고 자연스러운 패턴입니다.

Server Component -> Client Component (허용)
typescript
// app/page.tsx (Server Component)
import { SearchBar } from '@/components/SearchBar'; // Client Component
 
async function Page() {
  const data = await fetchData();
  return (
    <div>
      <SearchBar />        {/* Client Component 렌더링 */}
      <DataTable data={data} />  {/* Server Component */}
    </div>
  );
}

규칙 2: Client Component에서 Server Component 직접 임포트 불가

이것이 가장 흔한 실수입니다. Client Component 파일에서 Server Component를 import하면, 해당 Server Component도 클라이언트 번들에 포함되려고 시도하며 오류가 발생합니다.

Client Component -> Server Component (금지)
typescript
'use client';
 
// 이 임포트는 오류를 발생시킵니다
import { ServerDataFetcher } from './ServerDataFetcher';
 
function Dashboard() {
  return (
    <div>
      {/* Server Component를 직접 임포트하여 사용할 수 없음 */}
      <ServerDataFetcher />
    </div>
  );
}

규칙 3: children/props 패턴으로 합성 가능

Client Component가 Server Component를 직접 임포트할 수는 없지만, props로 전달받는 것은 가능합니다. 이것이 RSC 합성의 핵심 패턴입니다.

children 패턴 (허용)
typescript
// components/ClientWrapper.tsx
'use client';
 
function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);
 
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>토글</button>
      {isOpen && children}  {/* Server Component의 렌더링 결과 */}
    </div>
  );
}
 
// app/page.tsx (Server Component)
import { ClientWrapper } from '@/components/ClientWrapper';
 
async function Page() {
  const data = await fetchData();
  return (
    <ClientWrapper>
      {/* ServerDataTable은 Server Component이지만,
          Page(Server Component)가 렌더링한 결과를
          ClientWrapper에 children으로 전달 */}
      <ServerDataTable data={data} />
    </ClientWrapper>
  );
}

이 패턴이 동작하는 이유는, ServerDataTable이 Server Component(Page)에 의해 이미 렌더링된 결과가 children으로 전달되기 때문입니다. Client Component는 Server Component의 렌더링 결과(React 엘리먼트)를 받을 뿐, Server Component의 코드 자체를 실행하지 않습니다.

규칙 4: 직렬화 가능한 props만 경계를 넘을 수 있음

Server Component에서 Client Component로 전달하는 props는 직렬화 가능(Serializable)해야 합니다.

직렬화 가능한 props
typescript
// 허용: 원시 값, 배열, 객체, Date, Map, Set, JSX
<ClientComponent
  name="Kreath"
  count={42}
  tags={['react', 'rsc']}
  createdAt={new Date()}
>
  <ServerChild />  {/* JSX도 직렬화 가능 */}
</ClientComponent>
 
// 금지: 함수, 클래스 인스턴스, Symbol
<ClientComponent
  onClick={() => console.log('click')}  // 함수는 직렬화 불가
  validator={new Validator()}            // 클래스 인스턴스 불가
/>
Tip

Server Actions('use server'로 표시된 함수)는 예외적으로 직렬화되어 경계를 넘을 수 있습니다. 이에 대해서는 3장에서 자세히 다룹니다.

'use client' 경계의 의미

'use client' 디렉티브를 이해하는 핵심은 이것이 모듈 단위의 경계라는 점입니다.

경계 전파 규칙
typescript
// components/Dashboard.tsx
'use client';  // 이 파일부터 클라이언트 경계 시작
 
import { Chart } from './Chart';       // Chart도 Client Component가 됨
import { Tooltip } from './Tooltip';   // Tooltip도 Client Component가 됨
 
function Dashboard() {
  return (
    <div>
      <Chart />
      <Tooltip />
    </div>
  );
}

Dashboard.tsx에 'use client'를 선언하면, Dashboard뿐만 아니라 이 파일이 임포트하는 모든 모듈도 클라이언트 번들에 포함됩니다. Chart와 Tooltip에 'use client'가 없더라도 마찬가지입니다.

이것이 의미하는 바는 중요합니다. 'use client' 경계는 가능한 트리의 깊은 곳(leaf)에 배치해야 클라이언트 번들 크기를 최소화할 수 있습니다.

경계를 잘못 배치한 예
typescript
// 나쁜 예: 최상위에 'use client'
// app/dashboard/page.tsx
'use client';  // 전체 페이지가 Client Component
 
function DashboardPage() {
  // 이 페이지의 모든 하위 컴포넌트가 클라이언트 번들에 포함
  return (
    <div>
      <StaticHeader />      {/* 서버 컴포넌트가 될 수 있었음 */}
      <DataSummary />        {/* 서버 컴포넌트가 될 수 있었음 */}
      <InteractiveChart />   {/* 실제로 클라이언트가 필요한 부분 */}
    </div>
  );
}
경계를 올바르게 배치한 예
typescript
// 좋은 예: 인터랙션이 필요한 곳에만 'use client'
// app/dashboard/page.tsx (Server Component)
async function DashboardPage() {
  const summary = await fetchSummary();
  return (
    <div>
      <StaticHeader />
      <DataSummary data={summary} />
      <InteractiveChart />   {/* 이것만 Client Component */}
    </div>
  );
}
 
// components/InteractiveChart.tsx
'use client';
function InteractiveChart() {
  const [range, setRange] = useState('7d');
  // ... 인터랙티브 차트 로직
}

비동기 컴포넌트: async/await의 힘

Server Components의 가장 강력한 특성 중 하나는 async 함수로 정의할 수 있다는 점입니다.

비동기 Server Component
typescript
async function UserProfile({ userId }: { userId: string }) {
  // 데이터베이스에서 직접 조회
  const user = await db.users.findUnique({
    where: { id: userId },
    include: { posts: { take: 5, orderBy: { createdAt: 'desc' } } },
  });
 
  if (!user) return <NotFound />;
 
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
      <section>
        <h2>최근 글</h2>
        {user.posts.map(post => (
          <PostPreview key={post.id} post={post} />
        ))}
      </section>
    </div>
  );
}

이 패턴이 가능한 이유는 Server Component가 서버에서만 실행되기 때문입니다. 서버 환경에서는 await가 자연스럽고, 렌더링이 완료될 때까지 기다린 후 결과를 클라이언트에 전송합니다.

Warning

Client Component는 async로 정의할 수 없습니다. 클라이언트에서 비동기 데이터를 다루려면 use() API를 사용해야 합니다. 이에 대해서는 4장에서 다룹니다.

스트리밍과 Suspense 통합

Server Components는 Suspense와 자연스럽게 통합됩니다. 데이터의 우선순위에 따라 일부는 즉시 렌더링하고, 나머지는 스트리밍할 수 있습니다.

스트리밍 패턴
typescript
async function ArticlePage({ params }: { params: { id: string } }) {
  // 핵심 데이터: 즉시 로드 (서버 렌더링을 블로킹)
  const article = await db.articles.findUnique({
    where: { id: params.id },
  });
 
  // 부가 데이터: Promise를 생성하되 await하지 않음
  const commentsPromise = db.comments.findMany({
    where: { articleId: params.id },
  });
  const relatedPromise = db.articles.findRelated(params.id);
 
  return (
    <article>
      <h1>{article.title}</h1>
      <div>{article.content}</div>
 
      {/* 댓글은 준비되면 스트리밍 */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
 
      {/* 관련 글도 별도로 스트리밍 */}
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedArticles relatedPromise={relatedPromise} />
      </Suspense>
    </article>
  );
}

이 패턴에서 article 데이터는 즉시 렌더링되어 사용자에게 표시되고, 댓글과 관련 글은 각각의 Suspense 경계 안에서 독립적으로 스트리밍됩니다. 사용자는 본문을 읽는 동안 나머지 데이터가 점진적으로 나타나는 것을 볼 수 있습니다.

Server Components에서 할 수 없는 것

Server Components의 제약을 명확히 이해하는 것도 중요합니다.

사용 불가한 것들

Server Component에서 불가능한 것
typescript
// 1. useState, useEffect 등 클라이언트 훅
function ServerComponent() {
  const [count, setCount] = useState(0);  // 오류
  useEffect(() => { /* ... */ }, []);     // 오류
}
 
// 2. 이벤트 핸들러
function ServerComponent() {
  return <button onClick={() => alert('click')}>클릭</button>;  // 오류
}
 
// 3. 브라우저 전용 API
function ServerComponent() {
  const width = window.innerWidth;  // 오류: window 미정의
  localStorage.setItem('key', 'value');  // 오류
}
 
// 4. Context 생성 (소비는 가능)
function ServerComponent() {
  return (
    <ThemeContext.Provider value="dark">  // 오류
      <Children />
    </ThemeContext.Provider>
  );
}

사용 가능한 것들

Server Component에서 가능한 것
typescript
// 1. 데이터베이스 접근
const user = await db.users.findUnique({ where: { id: userId } });
 
// 2. 파일 시스템 접근
const content = await fs.readFile('./data/config.json', 'utf-8');
 
// 3. 환경 변수 (서버 전용)
const apiKey = process.env.SECRET_API_KEY;
 
// 4. 서버 전용 라이브러리
import { marked } from 'marked';
import { createHash } from 'crypto';
 
// 5. async/await
async function fetchAndRender() { /* ... */ }
 
// 6. Context 소비 (use API 사용)
import { use } from 'react';
const theme = use(ThemeContext);

실전 아키텍처 패턴

데이터 페칭 레이어 분리

서버 데이터 레이어
typescript
// lib/data/articles.ts (서버 전용)
import { cache } from 'react';
 
export const getArticle = cache(async (id: string) => {
  const article = await db.articles.findUnique({
    where: { id },
    include: { author: true, tags: true },
  });
  return article;
});
 
export const getArticleComments = cache(async (articleId: string) => {
  return db.comments.findMany({
    where: { articleId },
    include: { author: true },
    orderBy: { createdAt: 'desc' },
  });
});

React의 cache 함수는 동일한 요청 내에서 같은 인자로 호출된 함수의 결과를 메모이제이션합니다. 여러 Server Component가 같은 데이터를 필요로 할 때 중복 쿼리를 방지합니다.

Container/Presentational 패턴의 진화

전통적인 Container/Presentational 패턴이 Server/Client Component 경계와 자연스럽게 매핑됩니다.

Server Component = Container
typescript
// app/articles/page.tsx (Server Component = Container)
async function ArticlesPage() {
  const articles = await getArticles();
  const categories = await getCategories();
 
  return (
    <div>
      <CategoryFilter categories={categories} />  {/* Client */}
      <ArticleGrid articles={articles} />          {/* Client */}
    </div>
  );
}
Client Component = Presentational + Interactive
typescript
// components/ArticleGrid.tsx (Client Component = Presentational)
'use client';
 
import { useState } from 'react';
 
function ArticleGrid({ articles }: { articles: Article[] }) {
  const [sortBy, setSortBy] = useState<'date' | 'title'>('date');
 
  const sorted = [...articles].sort((a, b) =>
    sortBy === 'date'
      ? b.date.getTime() - a.date.getTime()
      : a.title.localeCompare(b.title)
  );
 
  return (
    <div>
      <select onChange={(e) => setSortBy(e.target.value as 'date' | 'title')}>
        <option value="date">최신순</option>
        <option value="title">제목순</option>
      </select>
      <div className="grid grid-cols-3 gap-4">
        {sorted.map(article => (
          <ArticleCard key={article.id} article={article} />
        ))}
      </div>
    </div>
  );
}

핵심 요약

  • Server Components는 서버에서만 실행되며, JavaScript가 클라이언트 번들에 포함되지 않습니다.
  • RSC Payload는 Server Component의 렌더링 결과를 직렬화한 스트리밍 가능한 데이터 형식입니다.
  • 'use client' 경계는 모듈 단위로 전파되며, 가능한 트리의 깊은 곳에 배치해야 합니다.
  • Client Component에서 Server Component를 직접 임포트할 수 없지만, children 패턴으로 합성할 수 있습니다.
  • Server Component는 async/await를 직접 사용할 수 있고, Suspense와 통합하여 스트리밍 렌더링을 구현합니다.
  • 서버-클라이언트 경계를 넘는 props는 직렬화 가능해야 합니다 (Server Actions 제외).

다음 장에서는 클라이언트에서 서버 로직을 안전하게 호출하는 Server Actions의 동작 원리와 실전 활용법을 살펴봅니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#react#nextjs#performance#frontend#typescript

관련 글

웹 개발

3장: Server Actions로 서버-클라이언트 통합하기

Server Actions의 동작 원리, 폼 처리 패턴, 데이터 뮤테이션, 에러 핸들링, 보안 고려사항을 실전 코드와 함께 다룹니다.

2026년 1월 27일·22분
웹 개발

1장: React 19의 등장과 새로운 패러다임

React 19가 가져온 근본적인 변화를 살펴봅니다. Actions, Server Components, 새로운 훅, React Compiler까지 React의 새로운 패러다임을 이해합니다.

2026년 1월 23일·16분
웹 개발

4장: use() API와 새로운 데이터 패턴

React 19의 use() API로 Promise와 Context를 조건부로 소비하는 방법, 서버-클라이언트 스트리밍 패턴, 기존 훅과의 차이를 다룹니다.

2026년 1월 29일·15분
이전 글1장: React 19의 등장과 새로운 패러다임
다음 글3장: Server Actions로 서버-클라이언트 통합하기

댓글

목차

약 23분 남음
  • Server Components란 무엇인가
  • Server Component vs Client Component vs SSR
    • SSR (Server-Side Rendering)
    • Server Components
    • Client Components
    • 세 가지 비교
  • RSC 렌더링 파이프라인
    • 1단계: 서버 렌더링
    • 2단계: RSC Payload 생성
    • 3단계: 클라이언트 재구성
  • 합성 규칙: 서버와 클라이언트의 경계
    • 규칙 1: Server Component에서 Client Component 임포트 가능
    • 규칙 2: Client Component에서 Server Component 직접 임포트 불가
    • 규칙 3: children/props 패턴으로 합성 가능
    • 규칙 4: 직렬화 가능한 props만 경계를 넘을 수 있음
  • 'use client' 경계의 의미
  • 비동기 컴포넌트: async/await의 힘
  • 스트리밍과 Suspense 통합
  • Server Components에서 할 수 없는 것
    • 사용 불가한 것들
    • 사용 가능한 것들
  • 실전 아키텍처 패턴
    • 데이터 페칭 레이어 분리
    • Container/Presentational 패턴의 진화
  • 핵심 요약