React Server Components 제대로 이해하기: 어떤 컴포넌트를 서버에 두고 어떤 걸 클라이언트에 둘까

프론트엔드

ReactReact Server ComponentsNext.jsApp Router

이 글은 누구를 위한 것인가

  • Next.js App Router를 쓰는데 use client를 어디에 달아야 할지 아직도 헷갈리는 개발자
  • RSC가 기존 SSR(getServerSideProps)과 뭐가 다른지 명확히 이해하고 싶은 분
  • 서버 컴포넌트를 잘못 써서 번들 크기가 오히려 커지거나, 데이터 페칭이 폭포수처럼 되어버린 경험이 있는 분
  • RSC 아키텍처를 어떻게 설계해야 확장성이 좋은지 고민하는 팀 리드

들어가며

Next.js App Router가 안정화되고 RSC(React Server Components)가 실무에 본격 도입되면서, 개발자들 사이에서 공통으로 들리는 말이 있다.

"도대체 use client를 어디에 달아야 해?"

처음에 나도 그랬다. Pages Router에서 App Router로 마이그레이션하면서, use client를 너무 많이 달아서 RSC의 이점을 전혀 못 살리거나, 반대로 너무 조심해서 인터랙티브 컴포넌트까지 서버 컴포넌트로 만들려다 에러만 잔뜩 만나거나.

실제로 2024년 State of React 설문에서 "App Router를 쓰는 데 가장 혼란스러운 부분"으로 RSC 경계 설정이 1위를 차지했다. 개념은 이해했는데 실제로 적용하려니 막막한 거다.

이 글에서는 RSC의 핵심 개념부터 시작해서, 잘못 설계했을 때 어떤 문제가 생기고, 실무에서 어떤 패턴으로 구조를 잡아야 하는지 구체적으로 설명한다.


1. RSC가 나온 이유: 기존 SSR과 무엇이 다른가

1.1 기존 SSR의 한계

Next.js Pages Router에서 SSR은 getServerSideProps로 동작했다.

// Pages Router 방식
export async function getServerSideProps() {
  const data = await fetchData();
  return { props: { data } };
}

export default function Page({ data }) {
  // 이미 서버에서 받아온 data를 렌더링
  return <div>{data.title}</div>;
}

이 방식의 문제:

  1. 모든 컴포넌트 코드가 클라이언트 번들에 포함됨: 서버에서 렌더링해도 해당 컴포넌트의 JS 코드는 클라이언트에도 전송됨
  2. 데이터 페칭이 페이지 단위로만 가능: getServerSideProps는 페이지 최상단에서만 호출 가능. 중첩 컴포넌트에서 서버 데이터를 쓰려면 props로 내려야 함
  3. 클라이언트 JS 실행 필요: hydration 과정이 무거움

1.2 RSC가 해결하는 것

RSC에서 서버 컴포넌트는 클라이언트 번들에 포함되지 않는다. 서버에서만 실행되고, 그 결과(직렬화된 React 트리)만 클라이언트로 전송된다.

기존 SSR:
서버 → HTML 생성 → 클라이언트로 전송 → JS 번들 다운로드 → hydration

RSC:
서버 컴포넌트 → 직렬화된 React 트리(RSC Payload) 전송
클라이언트 컴포넌트만 → 클라이언트 번들에 포함

핵심 차이:

특성기존 SSR (Pages Router)React Server Components
컴포넌트 코드 전송모두 클라이언트 번들에 포함서버 컴포넌트 코드는 미포함
데이터 페칭 위치페이지 레벨만 가능어느 컴포넌트에서든 가능
DB/백엔드 직접 접근불가 (API 필요)가능
인터랙티비티hydration 후 가능서버 컴포넌트는 불가
번들 크기전체 포함서버 컴포넌트만큼 감소

2. 서버 컴포넌트 vs 클라이언트 컴포넌트: 경계 이해하기

2.1 서버 컴포넌트가 할 수 있는 것, 없는 것

// app/products/page.tsx — 서버 컴포넌트 (기본값)
import { db } from '@/lib/database'; // DB 직접 접근 가능!
import { headers } from 'next/headers'; // 서버 전용 API

export default async function ProductsPage() {
  // await 직접 사용 가능 (async 컴포넌트)
  const products = await db.products.findMany({
    where: { active: true },
  });

  // 요청 헤더에 직접 접근 가능
  const userAgent = headers().get('user-agent');

  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

서버 컴포넌트에서 불가능한 것:

  • useState, useEffect 등 React 훅 사용
  • 이벤트 핸들러 (onClick, onChange 등)
  • 브라우저 전용 API (window, document, localStorage)
  • 클라이언트 전용 라이브러리 (차트 라이브러리 등)

2.2 클라이언트 컴포넌트가 필요한 시점

// 'use client'가 반드시 필요한 경우들
'use client';

import { useState } from 'react';

// 1. 상태가 필요한 경우
export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// 2. 이벤트 핸들러가 필요한 경우
export function SearchBar({ onSearch }) {
  return <input onChange={e => onSearch(e.target.value)} />;
}

// 3. 브라우저 API가 필요한 경우
export function GeolocationButton() {
  const handleClick = () => {
    navigator.geolocation.getCurrentPosition(pos => {
      console.log(pos.coords);
    });
  };
  return <button onClick={handleClick}>내 위치 찾기</button>;
}

2.3 use client는 파일 단위, 경계는 컴포넌트 트리

가장 중요한 개념이다. use client는 해당 파일과 그 파일이 임포트하는 모든 모듈에 적용된다.

app/
  page.tsx (서버 컴포넌트)
    └── ProductList.tsx (서버 컴포넌트)
          ├── ProductCard.tsx (서버 컴포넌트)
          └── AddToCartButton.tsx ('use client' 선언)
                └── useCart.ts (클라이언트 훅)

AddToCartButtonuse client를 달면:

  • AddToCartButton → 클라이언트 번들에 포함
  • useCartAddToCartButton이 임포트하므로 클라이언트 번들에 포함
  • ProductCard, ProductList, page → 여전히 서버 컴포넌트

3. use client 경계를 어디에 두는 것이 최적인가

3.1 나쁜 예: 너무 높은 곳에 경계 설정

// ❌ 나쁜 예: 페이지 전체를 클라이언트로 만들기
'use client'; // 이 파일에서 임포트하는 모든 것이 클라이언트 번들에 포함됨

import { useState } from 'react';
import { ProductList } from './ProductList'; // DB 접근 코드도 번들에 포함!
import { Header } from './Header';
import { Footer } from './Footer';
import { HeavyChartLibrary } from 'chart-library'; // 필요 없는 곳까지 포함

export default function Page() {
  const [filter, setFilter] = useState('all');
  // ...
}

이렇게 하면:

  • ProductList의 DB 접근 코드가 클라이언트 번들에 포함 (실행은 안 되지만 용량 증가)
  • 서버 컴포넌트 이점이 사라짐
  • 번들 크기가 불필요하게 커짐

3.2 좋은 예: 경계를 최대한 아래로, 좁게

// ✅ 좋은 예: 인터랙티브한 부분만 클라이언트로 분리
// app/products/page.tsx (서버 컴포넌트)
import { db } from '@/lib/database';
import { ProductGrid } from './ProductGrid';
import { FilterBar } from './FilterBar'; // 클라이언트 컴포넌트

export default async function ProductsPage() {
  // 서버에서 직접 DB 접근
  const categories = await db.categories.findMany();
  const initialProducts = await db.products.findMany({ take: 20 });

  return (
    <main>
      {/* FilterBar는 'use client' — 상태 관리 필요 */}
      <FilterBar categories={categories} />
      {/* ProductGrid는 서버 컴포넌트 — 순수 렌더링 */}
      <ProductGrid products={initialProducts} />
    </main>
  );
}
// app/products/FilterBar.tsx
'use client'; // 여기만 클라이언트

import { useState } from 'react';

export function FilterBar({ categories }) {
  const [selected, setSelected] = useState('all');

  return (
    <div>
      {categories.map(cat => (
        <button
          key={cat.id}
          className={selected === cat.id ? 'active' : ''}
          onClick={() => setSelected(cat.id)}
        >
          {cat.name}
        </button>
      ))}
    </div>
  );
}

3.3 서버 컴포넌트를 클라이언트 컴포넌트의 children으로 전달하기

이 패턴이 RSC에서 가장 중요한 패턴 중 하나다.

// Layout.tsx (클라이언트 컴포넌트)
'use client';

import { useState } from 'react';

export function Layout({ children }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>메뉴</button>
      {isOpen && <nav>...</nav>}
      <main>{children}</main> {/* children은 서버 컴포넌트일 수 있음! */}
    </div>
  );
}
// page.tsx (서버 컴포넌트)
import { Layout } from './Layout';
import { ServerContent } from './ServerContent'; // 서버 컴포넌트

export default function Page() {
  return (
    <Layout>
      {/* ServerContent는 서버에서 렌더링, Layout은 클라이언트에서 */}
      <ServerContent />
    </Layout>
  );
}

Layout'use client'를 선언해도, children으로 전달된 ServerContent는 서버에서 렌더링된다. children은 이미 렌더링된 결과(RSC payload)를 전달받기 때문이다.


4. 데이터 페칭 패턴: 서버에서 fetch하기

4.1 서버 컴포넌트에서 직접 fetch

// app/dashboard/page.tsx
async function DashboardPage() {
  // 서버에서 직접 API 호출 — 클라이언트에는 결과만 전달
  const [stats, recentActivity] = await Promise.all([
    fetch('https://api.example.com/stats', {
      headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
      // Next.js 캐싱 옵션
      next: { revalidate: 60 }, // 60초마다 재검증
    }).then(r => r.json()),
    fetch('https://api.example.com/activity', {
      next: { tags: ['activity'] }, // 태그 기반 재검증
    }).then(r => r.json()),
  ]);

  return (
    <div>
      <StatsGrid stats={stats} />
      <ActivityFeed items={recentActivity} />
    </div>
  );
}

4.2 React cache()로 중복 요청 방지

같은 페이지에서 여러 컴포넌트가 동일한 데이터를 필요로 할 때 사용한다.

// lib/data.ts
import { cache } from 'react';

// cache()로 감싸면 같은 요청 사이클에서 중복 호출 방지
export const getUser = cache(async (userId: string) => {
  const user = await db.users.findUnique({ where: { id: userId } });
  return user;
});

export const getUserPosts = cache(async (userId: string) => {
  const posts = await db.posts.findMany({ where: { authorId: userId } });
  return posts;
});
// UserProfile.tsx (서버 컴포넌트)
async function UserProfile({ userId }) {
  const user = await getUser(userId); // 첫 번째 호출 → DB 쿼리 실행
  return <div>{user.name}</div>;
}

// UserPosts.tsx (서버 컴포넌트)
async function UserPosts({ userId }) {
  const user = await getUser(userId); // 두 번째 호출 → 캐시에서 반환 (DB 쿼리 없음)
  const posts = await getUserPosts(userId);
  return <div>{posts.length}개의 포스트</div>;
}

// page.tsx
async function UserPage({ params }) {
  // 두 컴포넌트 모두 getUser를 호출하지만 실제 DB 쿼리는 한 번만 실행
  return (
    <>
      <UserProfile userId={params.id} />
      <UserPosts userId={params.id} />
    </>
  );
}

5. Streaming SSR과 Suspense로 초기 로딩 개선

5.1 기존 방식의 문제

기존 SSR은 모든 데이터가 준비될 때까지 사용자가 빈 화면을 본다.

기존 SSR 타임라인:
요청 → 데이터1 대기 → 데이터2 대기 → 데이터3 대기 → HTML 전송 → 사용자 화면 표시
(모든 데이터가 준비될 때까지 전송 불가)

5.2 Streaming + Suspense 조합

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      {/* 빠른 데이터 — 즉시 렌더링 */}
      <Header />

      {/* 느린 데이터 — 로딩 상태 먼저 보여주고, 데이터 오면 교체 */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection /> {/* async 서버 컴포넌트 */}
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart /> {/* 더 느린 데이터 */}
      </Suspense>
    </div>
  );
}
// StatsSection.tsx (async 서버 컴포넌트)
async function StatsSection() {
  // 이 fetch가 완료될 때까지 Suspense fallback이 표시됨
  const stats = await fetch('/api/stats').then(r => r.json());
  return <StatsGrid data={stats} />;
}

타임라인이 이렇게 바뀐다:

Streaming 타임라인:
요청 → Header 즉시 전송 → Skeleton 표시
     → 데이터1 완료 → StatsSection 스트리밍 → 화면 업데이트
     → 데이터2 완료 → RevenueChart 스트리밍 → 화면 업데이트

사용자 체감 성능이 크게 좋아진다. LCP(Largest Contentful Paint)가 모든 데이터를 기다릴 필요 없이 첫 콘텐츠부터 렌더링되기 때문이다.


6. 자주 만나는 RSC 에러와 해결법

6.1 직렬화 에러

Error: Only plain objects, arrays, and primitives are serializable.
Functions are not serializable.

서버 컴포넌트에서 클라이언트 컴포넌트로 함수를 props로 전달할 수 없다.

// ❌ 에러: 서버 컴포넌트에서 함수를 클라이언트 컴포넌트로 전달
// ServerComponent.tsx (서버 컴포넌트)
export default function ServerComponent() {
  const handleClick = () => console.log('click'); // 함수는 직렬화 불가!
  return <ClientButton onClick={handleClick} />; // 에러!
}
// ✅ 해결: 이벤트 핸들러는 클라이언트 컴포넌트 내부에 정의
// ClientButton.tsx
'use client';
export function ClientButton({ label }) {
  const handleClick = () => console.log('click'); // 클라이언트에서 정의
  return <button onClick={handleClick}>{label}</button>;
}

// ServerComponent.tsx
export default function ServerComponent() {
  return <ClientButton label="클릭하기" />; // 직렬화 가능한 값만 전달
}

직렬화 가능한 타입: string, number, boolean, null, undefined, Array, plain object, Date, BigInt, Symbol (일부), Promise (서버 액션에서 사용)

6.2 클라이언트 전용 모듈 에러

Error: document is not defined

서버 컴포넌트에서 window, document, localStorage 같은 브라우저 API를 사용할 때 발생한다.

// ❌ 에러: 서버 컴포넌트에서 브라우저 API 사용
export default function BadComponent() {
  const theme = localStorage.getItem('theme'); // 서버에는 localStorage 없음!
  return <div data-theme={theme}>...</div>;
}

// ✅ 해결 1: 클라이언트 컴포넌트로 변환
'use client';
import { useState, useEffect } from 'react';

export function ThemeComponent() {
  const [theme, setTheme] = useState('light');
  useEffect(() => {
    setTheme(localStorage.getItem('theme') || 'light');
  }, []);
  return <div data-theme={theme}>...</div>;
}

// ✅ 해결 2: 서버에서는 쿠키로 테마 처리
import { cookies } from 'next/headers';
export default function ThemeComponent() {
  const theme = cookies().get('theme')?.value || 'light';
  return <div data-theme={theme}>...</div>;
}

6.3 서버 컴포넌트에서 훅 사용 에러

Error: Hooks can only be called inside of the body of a function component.
// ❌ 에러
// app/page.tsx (서버 컴포넌트)
import { useState } from 'react';

export default function Page() {
  const [open, setOpen] = useState(false); // 서버 컴포넌트에서 훅 사용 불가!
}

// ✅ 해결: 훅을 쓰는 부분만 분리
// ModalController.tsx
'use client';
import { useState } from 'react';

export function ModalController({ trigger, children }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>{trigger}</button>
      {open && <div>{children}</div>}
    </>
  );
}

7. RSC를 잘못 쓰면 생기는 문제들

7.1 번들 크기 폭증

가장 흔한 실수는 use client 경계를 너무 높이 설정해서 서버에서만 필요한 코드까지 번들에 포함시키는 것이다.

// ❌ 나쁜 예: 페이지 상단에 'use client' 선언
'use client';
import { db } from '@/lib/database'; // DB 클라이언트 코드가 번들에 포함!
import { marked } from 'marked'; // 마크다운 파서가 번들에 포함 (서버에서만 써도 되는데)
import { SyntaxHighlighter } from 'react-syntax-highlighter'; // 크고 무거운 라이브러리

export default function BlogPost({ slug }) {
  // ...
}

서버에서만 필요한 marked (마크다운 파서, ~20KB)나 db 같은 모듈이 클라이언트 번들에 포함되면 불필요한 용량이 늘어난다.

7.2 데이터 페칭 폭포수 (Waterfall)

// ❌ 나쁜 예: 클라이언트에서 순차적으로 데이터 페칭
'use client';
function UserDashboard({ userId }) {
  const user = useFetch(`/api/users/${userId}`); // 첫 번째 요청
  // user 데이터가 올 때까지 대기...
  const posts = useFetch(`/api/users/${userId}/posts`); // user 온 후 두 번째 요청
  // posts 올 때까지 대기...
  const analytics = useFetch(`/api/analytics/${userId}`); // 세 번째 요청
  // 세 요청이 순차적으로 실행 → 총 지연 = 요청1 + 요청2 + 요청3
}
// ✅ 좋은 예: 서버 컴포넌트에서 병렬 페칭
async function UserDashboard({ userId }) {
  // 서버에서 병렬로 모두 요청
  const [user, posts, analytics] = await Promise.all([
    db.users.findUnique({ where: { id: userId } }),
    db.posts.findMany({ where: { authorId: userId } }),
    db.analytics.findMany({ where: { userId } }),
  ]);
  // 총 지연 = max(요청1, 요청2, 요청3)

  return <Dashboard user={user} posts={posts} analytics={analytics} />;
}

8. Next.js App Router 실전 패턴

8.1 컴포넌트 구조 설계 원칙

app/
  dashboard/
    page.tsx          ← 서버 컴포넌트 (데이터 페칭, 레이아웃 조립)
    _components/
      StatsGrid.tsx   ← 서버 컴포넌트 (순수 렌더링)
      FilterBar.tsx   ← 'use client' (상태 관리 필요)
      ChartSection.tsx ← 'use client' (차트 라이브러리 사용)
      SearchBar.tsx   ← 'use client' (입력 처리)

8.2 서버 액션으로 데이터 변경

// app/posts/actions.ts
'use server';

import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.posts.create({
    data: { title, content },
  });

  revalidatePath('/posts'); // 포스트 목록 캐시 재검증
}
// app/posts/NewPostForm.tsx
'use client';
import { createPost } from './actions';

export function NewPostForm() {
  return (
    <form action={createPost}> {/* 서버 액션을 폼에 직접 연결 */}
      <input name="title" placeholder="제목" />
      <textarea name="content" placeholder="내용" />
      <button type="submit">작성</button>
    </form>
  );
}

8.3 의사결정 가이드: 어느 쪽으로 둘까

컴포넌트 설계 시 체크리스트:

이 컴포넌트에서...
│
├─ useState, useEffect, useRef, useCallback 쓰는가?
│   └─ YES → use client 필요
│
├─ onClick, onChange 같은 이벤트 핸들러 있는가?
│   └─ YES → use client 필요
│
├─ window, document, localStorage 접근하는가?
│   └─ YES → use client 필요
│
├─ 클라이언트 전용 라이브러리를 임포트하는가?
│   (차트, 에디터, 드래그앤드롭 등)
│   └─ YES → use client 필요
│
└─ 위에 해당 없음 → 서버 컴포넌트로 유지 (기본값)
    └─ DB/API 직접 접근 가능
    └─ 환경 변수(API 키 등) 안전하게 사용 가능
    └─ 번들 크기에 포함 안 됨

맺으며

RSC의 핵심을 한 문장으로 요약하면: "인터랙티브한 부분만 클라이언트에, 나머지는 서버에" 다.

실무에서 가장 흔한 실수 두 가지를 항상 기억하면 좋다.

첫째, use client를 너무 일찍, 너무 넓게 쓰는 것. 새 파일을 만들 때 "일단 use client 달고 시작하자"는 습관은 RSC의 이점을 전부 날려버린다. 반대로, 서버 컴포넌트로 시작해서 훅이나 이벤트 핸들러가 필요해지면 그때 use client를 추가하는 습관을 들이자.

둘째, 클라이언트에서 순차적으로 데이터를 가져오는 폭포수 패턴. 가능하면 서버에서 Promise.all로 병렬 페칭하고, 느린 데이터는 Suspense로 스트리밍하는 패턴을 기본으로 삼자.

RSC 아키텍처는 처음 배울 때 개념적으로 어렵지만, 한 번 제대로 이해하면 "서버에서 할 수 있는 건 서버에서"라는 원칙 하나로 대부분의 결정을 내릴 수 있다. 그리고 그 결과는 번들 크기 감소, 초기 로딩 속도 개선, 데이터 페칭 단순화라는 실질적인 이득으로 돌아온다.