React Server Components (RSC) 심층 가이드: 프론트엔드의 새로운 패러다임
React Server Components (RSC)는 React 애플리케이션의 성능과 개발 경험을 혁신하는 기술입니다. 이 가이드에서는 RSC의 기본 개념, 작동 방식, 데이터 페칭, 그리고 Next.js App Router에서의 활용법을 심층적으로 다룹니다. 클라이언트 컴포넌트와의 차이점을 이해하고 실전 코드 예제를 통해 프론트엔드 개발의 새로운 패러다임을 경험하세요.
React Server Components (RSC) 심층 가이드: 프론트엔드의 새로운 패러다임
React Server Components (RSC)는 React 생태계에 등장한 가장 혁신적인 변화 중 하나로, 프론트엔드 개발의 패러다임을 근본적으로 바꾸고 있습니다. 특히 Next.js 13 버전부터 도입된 App Router와 함께 RSC가 기본으로 채택되면서, 개발자들은 서버와 클라이언트의 경계를 넘나들며 더욱 효율적이고 성능 좋은 애플리케이션을 구축할 수 있게 되었습니다. 이 글에서는 React Server Components가 무엇인지, 어떻게 작동하는지, 그리고 실제 프로젝트에서 어떻게 활용할 수 있는지 심층적으로 탐구해 보겠습니다.
React Server Components (RSC)란 무엇인가요?
기존의 React 애플리케이션은 기본적으로 클라이언트 사이드에서 JavaScript를 실행하여 UI를 렌더링했습니다. 이는 풍부한 상호작용성을 제공했지만, 초기 로딩 시 번들 크기가 커지고 JavaScript 실행에 필요한 시간 때문에 성능 저하가 발생할 수 있었습니다. React Server Components는 이러한 문제를 해결하기 위해 서버에서 렌더링되는 컴포넌트를 도입합니다.
RSC는 클라이언트 번들에 포함되지 않고, 서버에서 데이터를 가져와 UI를 렌더링한 후, 최종 HTML 또는 특별한 직렬화된 데이터 형식(RSC Payload)을 클라이언트로 전송합니다. 이를 통해 클라이언트가 다운로드하고 파싱해야 할 JavaScript의 양을 크게 줄여 초기 로딩 속도를 향상시키고, 더 나아가 서버의 강력한 컴퓨팅 자원을 활용하여 복잡한 로직이나 데이터 페칭을 수행할 수 있게 됩니다.
기존 React 컴포넌트(Client Components)와의 차이점
가장 큰 차이점은 컴포넌트가 실행되는 환경입니다.
- Client Components: 브라우저(클라이언트)에서 실행되며,
useState,useEffect와 같은 React Hooks를 사용하여 상태 관리와 상호작용을 처리합니다. 사용자 인터랙션이 필요한 모든 UI는 Client Component여야 합니다. - Server Components: 서버에서 실행되며, 클라이언트 번들에 포함되지 않습니다. 데이터베이스 쿼리나 파일 시스템 접근과 같은 서버 전용 로직을 직접 수행할 수 있습니다. 상태 관리나 이벤트 핸들러와 같은 클라이언트 전용 기능은 사용할 수 없습니다.
Server Components와 Client Components의 작동 방식
React Server Components는 .js, .jsx, .ts, .tsx 파일에서 기본적으로 Server Component로 작동합니다. Client Component로 명시하려면 파일 상단에 'use client' 지시자를 추가해야 합니다.
// app/page.tsx (Server Component by default)
import MyClientComponent from './MyClientComponent';
async function getData() {
const res = await fetch('https://api.example.com/items');
if (!res.ok) {
throw new Error('Failed to fetch data');
}
return res.json();
}
export default async function HomePage() {
const data = await getData(); // 서버에서 직접 데이터 페칭
return (
<div>
<h1>Welcome to Server Component Page</h1>
<p>Data fetched on server: {data.message}</p>
<MyClientComponent /> {/* 서버 컴포넌트 안에서 클라이언트 컴포넌트 사용 */}
</div>
);
}
// app/MyClientComponent.tsx
'use client'; // 이 파일은 클라이언트 컴포넌트임을 명시
import { useState } from 'react';
export default function MyClientComponent() {
const [count, setCount] = useState(0);
return (
<div>
<h2>Client Component</h2>
<p>You clicked {count} times.</p>
<button onClick={() => setCount(count + 1)}>Click Me</button>
</div>
);
}
위 예시에서 HomePage는 Server Component로 동작하며, getData 함수를 통해 서버에서 직접 데이터를 페칭합니다. 이 데이터는 클라이언트 번들에 포함되지 않고, 브라우저로 전송될 때 이미 HTML 형태로 렌더링되어 있습니다. MyClientComponent는 'use client' 지시자로 인해 클라이언트 컴포넌트가 되며, useState를 사용하여 사용자 상호작용을 처리합니다.
Server Component는 Client Component를 자식으로 포함할 수 있지만, Client Component는 Server Component를 직접 import 할 수 없습니다. Client Component가 Server Component의 내용을 렌더링해야 할 때는, Server Component에서 Client Component의 children prop으로 Server Component의 내용을 전달하는 패턴을 사용해야 합니다.
// app/ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
// 클라이언트 컴포넌트의 로직
return (
<div style={{ border: '1px solid blue', padding: '10px' }}>
<h3>Client Wrapper</h3>
{children} {/* 서버 컴포넌트로부터 전달받은 내용을 렌더링 */}
<button onClick={() => alert('Client interaction!')}>Interact</button>
</div>
);
}
// app/page.tsx (Server Component)
import ClientWrapper from './ClientWrapper';
import ServerContent from './ServerContent'; // Server Component
export default function HomePage() {
return (
<ClientWrapper>
<ServerContent /> {/* ClientWrapper의 children으로 Server Component 전달 */}
<p>This text is also part of the Server Component content.</p>
</ClientWrapper>
);
}
// app/ServerContent.tsx (Server Component)
export default function ServerContent() {
return (
<div style={{ border: '1px solid green', padding: '5px' }}>
<h4>Server Content inside Client Wrapper</h4>
<p>This content is rendered on the server.</p>
</div>
);
}
이 패턴을 통해 Server Component는 Client Component의 "props"로써 전달될 수 있습니다.
Hydration 과정
초기 로딩 시, 서버는 Server Component를 포함한 페이지의 HTML을 생성하여 클라이언트로 보냅니다. 클라이언트 브라우저는 이 HTML을 즉시 렌더링하여 사용자에게 빠른 시각적 피드백을 제공합니다. 이후 React JavaScript 번들이 로드되면, 클라이언트 컴포넌트들은 서버에서 렌더링된 HTML과 자신의 가상 DOM을 비교하여 이벤트 리스너를 부착하고 상호작용 가능한 상태로 만듭니다. 이 과정을 Hydration이라고 합니다. Server Component는 Hydration 과정에 참여하지 않습니다.
Data Fetching과 RSC
RSC의 가장 강력한 이점 중 하나는 데이터 페칭 방식의 변화입니다. 기존 클라이언트 컴포넌트에서는 useEffect와 useState를 사용하여 데이터를 비동기적으로 가져왔지만, RSC에서는 async/await를 사용하여 컴포넌트 내부에서 직접 데이터를 페칭할 수 있습니다.
// app/products/page.tsx (Server Component)
import ProductCard from './ProductCard';
interface Product {
id: number;
name: string;
price: number;
description: string;
}
async function getProducts(): Promise<Product[]> {
// 실제 API 호출 시 보안에 민감한 정보(API 키 등)를 서버에만 유지 가능
const res = await fetch('https://api.example.com/products', { cache: 'no-store' }); // 캐싱 전략 설정 가능
if (!res.ok) {
throw new Error('Failed to fetch products');
}
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
<h1>Our Products</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px' }}>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
// app/products/ProductCard.tsx (Client Component)
'use client';
import { useState } from 'react';
interface Product {
id: number;
name: string;
price: number;
description: string;
}
export default function ProductCard({ product }: { product: Product }) {
const [quantity, setQuantity] = useState(0);
return (
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px' }}>
<h3>{product.name}</h3>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginTop: '10px' }}>
<button onClick={() => setQuantity(Math.max(0, quantity - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(quantity + 1)}>+</button>
<button onClick={() => alert(`${product.name} ${quantity}개 추가!`)} style={{ marginLeft: 'auto' }}>
Add to Cart
</button>
</div>
</div>
);
}이 방식은 다음과 같은 이점을 제공합니다.
- 성능: 데이터 페칭이 서버에서 이루어지므로 클라이언트는 데이터를 기다릴 필요 없이 즉시 HTML을 받을 수 있습니다.
- 보안: 민감한 API 키나 데이터베이스 자격 증명을 클라이언트 번들에 노출할 위험 없이 서버에서 직접 사용할 수 있습니다.
- 개발 편의성:
useEffect훅이나 로딩 상태를 수동으로 관리할 필요 없이, 동기 코드처럼 데이터를 기다릴 수 있습니다.
RSC와 상호작용 (Interactivity) 관리
Server Component는 클라이언트에서 실행되지 않으므로, onClick, onChange와 같은 이벤트 핸들러를 직접 가질 수 없습니다. 상호작용이 필요한 부분은 반드시 Client Component로 분리해야 합니다.
하지만 Next.js 13.4부터 도입된 Server Actions를 사용하면 Client Component에서 서버의 기능을 직접 호출하여 상호작용을 구현할 수 있습니다. Server Actions는 'use server' 지시자를 사용하여 정의된 비동기 함수로, 클라이언트에서 호출될 때 서버에서 실행됩니다.
// app/actions.ts
'use server'; // 이 파일의 모든 함수는 Server Action으로 작동
import { revalidatePath } from 'next/cache';
export async function createTodo(formData: FormData) {
const todoText = formData.get('todo') as string;
console.log(`Server received: ${todoText}`);
// 실제 데이터베이스에 저장하는 로직 (예: Prisma, TypeORM 등)
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate DB call
// ... db.todos.create({ data: { text: todoText, completed: false } });
console.log(`Todo "${todoText}" created on server.`);
// 데이터를 갱신하고 UI를 업데이트하기 위해 캐시를 무효화
revalidatePath('/todos');
}
// app/todos/page.tsx (Server Component)
import TodoForm from './TodoForm';
interface Todo {
id: number;
text: string;
completed: boolean;
}
async function getTodos(): Promise<Todo[]> {
// 실제 DB에서 데이터 가져오기 (예: Prisma)
// const todos = await db.todos.findMany();
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate DB call
return [
{ id: 1, text: 'Learn React Server Components', completed: false },
{ id: 2, text: 'Build a Next.js App', completed: true },
];
}
export default async function TodosPage() {
const todos = await getTodos();
return (
<div>
<h1>Todo List</h1>
<TodoForm />
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</li>
))}
</ul>
</div>
);
}
// app/todos/TodoForm.tsx (Client Component)
'use client';
import { useRef } from 'react';
import { createTodo } from '../actions'; // Server Action import
export default function TodoForm() {
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
await createTodo(formData); // Server Action 호출
formRef.current?.reset(); // 폼 초기화
}
return (
<form ref={formRef} action={handleSubmit} style={{ marginBottom: '20px' }}>
<input type="text" name="todo" placeholder="Add a new todo" required style={{ padding: '8px', marginRight: '10px' }} />
<button type="submit" style={{ padding: '8px 15px' }}>Add Todo</button>
</form>
);
}
이 예시에서 TodoForm은 Client Component이지만, createTodo라는 Server Action을 직접 호출하여 서버에서 할 일을 생성합니다. 이는 클라이언트와 서버 간의 통신을 매우 간소화하며, API 엔드포인트를 따로 만들 필요 없이 함수 호출처럼 서버 로직을 실행할 수 있게 해줍니다.
React Server Components의 장점과 고려 사항
장점
- 번들 사이즈 최적화: 서버에서 렌더링되는 컴포넌트의 JavaScript 코드는 클라이언트 번들에 포함되지 않아 초기 로딩 시 다운로드할 JavaScript 양이 크게 줄어듭니다.
- 초기 로딩 성능 향상: 서버에서 미리 HTML을 생성하여 전송하므로, 사용자는 더 빠르게 콘텐츠를 볼 수 있습니다. 이는 First Contentful Paint (FCP) 및 Largest Contentful Paint (LCP)와 같은 핵심 웹 지표를 개선합니다.
- 보안 강화: 데이터베이스 쿼리, API 키 등 민감한 서버 전용 로직을 클라이언트에 노출하지 않고 서버에서 안전하게 실행할 수 있습니다.
- 데이터 페칭 간소화: 컴포넌트 내부에서
async/await를 사용하여 데이터를 직접 페칭할 수 있어useEffect와 같은 클라이언트 사이드 데이터 페칭 로직이 줄어듭니다. - 서버 리소스 활용: 서버의 컴퓨팅 자원을 활용하여 복잡한 데이터 처리나 파일 시스템 접근과 같은 작업을 효율적으로 수행할 수 있습니다.
고려 사항
- 학습 곡선: Server Components와 Client Components 간의 명확한 구분과 통신 방식에 대한 이해가 필요하여 초기 학습 곡선이 존재합니다.
- 클라이언트 상태 관리: Server Component는 상태를 가질 수 없으므로, 클라이언트 상태는 여전히 Client Component 내에서
useState,useReducer또는 전역 상태 관리 라이브러리(Zustand, Recoil 등)를 통해 관리해야 합니다. - 디버깅 복잡성: 서버와 클라이언트 양쪽에서 코드가 실행되므로 디버깅이 다소 복잡해질 수 있습니다.
- 환경 제약: Server Component는 브라우저 API(예:
window,localStorage)에 접근할 수 없습니다.
Server Components vs Client Components 비교
| 특징 | Server Components (기본값) | Client Components ('use client') |
|---|---|---|
| 실행 환경 | 서버 | 브라우저 (클라이언트) |
| 번들 포함 | 포함되지 않음 | 포함됨 |
| 상태 관리 | 불가 (useState, useReducer 사용 불가) | 가능 (useState, useReducer 등 사용) |
| 상호작용 | 불가 (이벤트 핸들러 직접 사용 불가) | 가능 (이벤트 핸들러, 브라우저 API 사용) |
| 데이터 페칭 | async/await로 직접 페칭 가능 (보안 이점) | useEffect를 통해 비동기 페칭 (API 엔드포인트 필요) |
| 브라우저 API | 접근 불가 (window, localStorage 등) | 접근 가능 |
| 용도 | 데이터 페칭, 정적 콘텐츠 렌더링, 보안 로직 | 사용자 상호작용, 동적 UI, 클라이언트 전용 로직 |
Next.js App Router와 RSC
Next.js 13 버전부터 도입된 App Router는 React Server Components를 기본 아키텍처로 채택하고 있습니다. 이는 Next.js가 SSR(Server-Side Rendering)과 CSR(Client-Side Rendering)의 장점을 결합하여 더욱 강력한 웹 애플리케이션 개발 환경을 제공하려는 노력의 일환입니다.
App Router에서는 모든 컴포넌트가 기본적으로 Server Component로 간주되며, 필요에 따라 'use client' 지시자를 사용하여 Client Component로 전환합니다. 이는 개발자가 명시적으로 Client Component를 선언하지 않는 한, 대부분의 코드가 서버에서 실행되어 성능 이점을 누릴 수 있도록 합니다.
App Router의 주요 특징인 레이아웃, 페이지, 로딩 UI 등은 모두 RSC와 깊이 연관되어 있습니다. 예를 들어, layout.tsx 파일은 기본적으로 Server Component로 작동하여, 여러 페이지에 걸쳐 공유되는 UI를 서버에서 효율적으로 렌더링하고 데이터를 페칭할 수 있습니다.
// app/layout.tsx (Server Component)
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<header style={{ background: '#f0f0f0', padding: '10px' }}>
<nav>
<a href="/">Home</a> | <a href="/products">Products</a> | <a href="/todos">Todos</a>
</nav>
</header>
<main style={{ padding: '20px' }}>
{children} {/* 자식 페이지/레이아웃 콘텐츠 */}
</main>
<footer style={{ background: '#f0f0f0', padding: '10px', marginTop: '20px' }}>
<p>© 2026 My App</p>
</footer>
</body>
</html>
);
}
이처럼 Next.js App Router는 RSC를 통해 서버와 클라이언트의 역할을 재정의하며, 개발자가 성능과 개발 편의성 모두를 고려한 웹 애플리케이션을 구축할 수 있도록 돕습니다.
마무리
React Server Components는 React 애플리케이션의 성능, 보안, 그리고 개발 경험을 혁신하는 강력한 도구입니다. 서버와 클라이언트의 역할을 명확히 구분하고, 각 환경의 장점을 최대한 활용함으로써, 우리는 더 빠르고 효율적인 웹 애플리케이션을 만들 수 있습니다. 초기에는 다소 생소하게 느껴질 수 있지만, Server Components와 Client Components의 작동 원리, 데이터 페칭 방식, 그리고 Server Actions를 통한 상호작용 관리법을 이해한다면, 현대 프론트엔드 개발의 새로운 지평을 열 수 있을 것입니다. Next.js App Router와 함께 React Server Components의 잠재력을 최대한 활용하여 더욱 견고하고 사용자 친화적인 웹 서비스를 구축하시길 바랍니다.
관련 게시글
Vite Build Tool: Fast Frontend Development Guide
Vite는 현대적인 프론트엔드 개발을 위한 빠르고 효율적인 빌드 도구입니다. 이 가이드에서는 Vite의 핵심 기능, React 및 TypeScript 프로젝트 설정, 플러그인 활용법, 그리고 빌드 최적화 전략까지 완벽하게 다룹니다.
React Server Components (RSC) 심층 가이드: Next.js와 함께하는 Full-stack React
React Server Components (RSC)의 개념, 등장 배경, 동작 원리, 그리고 Next.js 13+ App Router에서의 활용법을 심층적으로 다룹니다. 클라이언트/서버 컴포넌트 분리 전략과 실전 코드 예제를 통해 RSC의 강력한 이점을 이해하고 웹 애플리케이션 성능을 최적화하는 방법을 알아봅니다.
Next.js Middleware: 강력한 요청 처리 활용법
Next.js Middleware를 활용하여 사용자 인증, 국제화, A/B 테스트 등 다양한 요청 처리 로직을 효율적으로 구현하는 방법을 심층적으로 알아봅니다. 실전 코드 예제를 통해 Next.js 애플리케이션의 프론트엔드 기능을 강화하세요.