React 19 Server Components 심화 — 서버와 클라이언트의 경계를 이해하다

ReactReact19ServerComponentsRSCNext.js성능최적화

React 19가 정식 출시되면서 Server Components(RSC)가 안정화되었습니다. 이 글에서는 기본 개념을 넘어 실전에서 마주치는 문제들과 최적화 기법을 다룹니다.


Server Components 동작 원리

RSC 직렬화 과정

Server Component가 클라이언트에 전달되는 과정:

// Server Component (서버에서 실행)
async function ProductList() {
  // 이 코드는 브라우저에 전송되지 않음
  const products = await db.query('SELECT * FROM products');

  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>{p.name} - {p.price}원</li>
      ))}
    </ul>
  );
}

RSC Payload (네트워크로 전송되는 데이터):

{
  "type": "ul",
  "children": [
    {"type": "li", "children": "상품A - 10000원"},
    {"type": "li", "children": "상품B - 20000원"}
  ]
}

핵심: DB 쿼리 코드나 비밀 키는 클라이언트에 전송되지 않습니다.


서버/클라이언트 경계

'use client' 경계 이해하기

app/
├── page.tsx           (Server Component)
├── ProductList.tsx    (Server Component)
└── AddToCartButton.tsx (Client Component - 'use client')
// page.tsx (Server)
import ProductList from './ProductList';
import AddToCartButton from './AddToCartButton';

export default async function Page() {
  return (
    <div>
      {/* 서버에서 데이터 가져옴 */}
      <ProductList />

      {/* 클라이언트에서 상호작용 */}
      <AddToCartButton />
    </div>
  );
}

경계에서의 Props 전달

// ✅ 올바른 패턴: 직렬화 가능한 데이터만 전달
// Server Component
async function ProductPage({ id }) {
  const product = await getProduct(id);

  return (
    <ClientComponent
      name={product.name}        // ✅ string
      price={product.price}       // ✅ number
      createdAt={product.createdAt.toISOString()} // ✅ string으로 변환
    />
  );
}

// ❌ 잘못된 패턴: 함수, Date 객체 등은 전달 불가
<ClientComponent
  onClick={handleClick}  // ❌ 함수
  date={new Date()}       // ❌ Date 객체
  component={<Other />}   // ❌ JSX element (일부 경우)
/>

데이터 페칭 패턴

병렬 데이터 페칭

// ❌ 순차 실행 (느림)
async function Dashboard() {
  const user = await getUser();
  const posts = await getPosts();
  const notifications = await getNotifications();
  // 총 시간: user + posts + notifications
}

// ✅ 병렬 실행 (빠름)
async function Dashboard() {
  const [user, posts, notifications] = await Promise.all([
    getUser(),
    getPosts(),
    getNotifications(),
  ]);
  // 총 시간: max(user, posts, notifications)
}

Suspense와 스트리밍

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      {/* 즉시 표시 */}
      <Header />

      {/* 독립적으로 로딩 */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList />
      </Suspense>

      <Suspense fallback={<ReviewSkeleton />}>
        <Reviews />
      </Suspense>
    </div>
  );
}

중첩 Suspense 전략

// 점진적 로딩
<Suspense fallback={<PageSkeleton />}>
  <Layout>
    <Suspense fallback={<SidebarSkeleton />}>
      <Sidebar />
    </Suspense>

    <Main>
      <Suspense fallback={<ContentSkeleton />}>
        <Content />
      </Suspense>
    </Main>
  </Layout>
</Suspense>

캐싱 전략

React의 cache()

import { cache } from 'react';

// 같은 요청 내에서 중복 호출 방지
const getUser = cache(async (id) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
});

// 여러 컴포넌트에서 호출해도 1번만 실행
async function UserProfile({ id }) {
  const user = await getUser(id);
  // ...
}

async function UserPosts({ id }) {
  const user = await getUser(id); // 캐시된 결과 사용
  // ...
}

Next.js의 fetch 캐싱

// 기본: 캐시됨 (빌드 시)
const data = await fetch('https://api.example.com/data');

// 재검증 주기 설정
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 } // 1시간마다 재검증
});

// 캐시 비활성화 (항상 새로운 데이터)
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store'
});

수동 캐시 무효화

import { revalidatePath, revalidateTag } from 'next/cache';

// 특정 경로 재검증
async function updateProduct(id, data) {
  await db.products.update(id, data);
  revalidatePath('/products');
  revalidatePath(`/products/${id}`);
}

// 태그 기반 재검증
const data = await fetch(url, {
  next: { tags: ['products'] }
});

// 태그로 무효화
revalidateTag('products');

흔한 실수와 해결

1. 클라이언트 컴포넌트에서 async

// ❌ Client Component는 async 불가
'use client';
export default async function Button() { // Error!
  const data = await fetchData();
}

// ✅ useEffect 또는 상위 Server Component에서 처리
'use client';
export default function Button({ data }) {
  // data는 Server Component에서 props로 전달
}

2. 서버 전용 코드 누출

// ❌ 클라이언트에 서버 코드 노출 위험
import { db } from './db'; // 클라이언트 번들에 포함될 수 있음

// ✅ server-only 패키지 사용
import 'server-only';
import { db } from './db';

export async function getProducts() {
  return db.query('...');
}
npm install server-only

3. Context 사용

// ❌ Server Component에서 Context 사용 불가
// 'use client'가 없으면 useContext 사용 불가

// ✅ Provider는 Client Component로 분리
// providers.tsx
'use client';
export function Providers({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        {children}
      </AuthProvider>
    </ThemeProvider>
  );
}

// layout.tsx (Server Component)
export default function Layout({ children }) {
  return <Providers>{children}</Providers>;
}

4. 컴포넌트 Import 혼동

// ❌ Server Component 내부에서 client import 후 사용
// 경고: 전체가 Client Component가 됨

// ✅ 명확한 분리
// page.tsx (Server)
import ClientButton from './ClientButton'; // 'use client' 파일

export default async function Page() {
  const data = await fetchData();
  return <ClientButton data={data} />;
}

성능 최적화

컴포넌트 청킹

import dynamic from 'next/dynamic';

// 필요할 때만 로드
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // 클라이언트에서만 렌더링
});

부분 프리렌더링 (PPR)

// next.config.js
module.exports = {
  experimental: {
    ppr: true,
  },
};

// page.tsx
export default function Page() {
  return (
    <div>
      {/* 정적 부분: 빌드 시 생성 */}
      <Header />
      <StaticContent />

      {/* 동적 부분: 요청 시 스트리밍 */}
      <Suspense fallback={<Skeleton />}>
        <DynamicContent />
      </Suspense>
    </div>
  );
}

마이그레이션 체크리스트

기존 React 앱을 RSC로 전환할 때:

  • 데이터 페칭 로직을 Server Component로 이동
  • 'use client'가 필요한 컴포넌트 식별 (이벤트, 훅 사용)
  • Context Provider를 최상위 Client Component로 분리
  • server-only 패키지로 서버 코드 보호
  • Suspense 경계 설정
  • fetch 캐싱 전략 수립
  • 번들 크기 확인 및 최적화

React 19의 Server Components는 단순히 새로운 기능이 아니라 React 아키텍처의 패러다임 전환입니다. 서버와 클라이언트의 경계를 이해하고 적절히 활용하면, 더 빠르고 안전한 애플리케이션을 만들 수 있습니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.