React Server Components (RSC)는 hooks 이후 가장 큰 React 아키텍처 변화입니다.
React Server Components란?
서버에서 렌더링되어 출력을 클라이언트로 보내는 컴포넌트입니다.
RSC 모델에는 세 가지 유형의 컴포넌트가 있습니다.
Server vs Client Components
| 항목 | Server Component | Client Component |
|---|---|---|
| App Router 기본값 | 예 | 아니오 |
| 디렉티브 | 없음 | "use client" |
| 클라이언트 JS | 없음 (제로 JS) | 예 |
| useState/useEffect | 사용 불가 | 사용 가능 |
| 이벤트 핸들러 | 사용 불가 | 사용 가능 |
| DB 직접 접근 | 예 | 아니오 |
| 파일 시스템 | 예 | 아니오 |
| 서버 환경 변수 | 예 | 아니오 |
| 비동기 컴포넌트 | 예 | 아니오 |
| 번들 크기 | 제로 | 증가 |
결정 트리: Server인가 Client인가?
Should this component be a Server or Client Component?
Does it need useState, useEffect, or useRef?
├── YES → Client Component ("use client")
└── NO ↓
Does it need event handlers (onClick, onChange, onSubmit)?
├── YES → Client Component ("use client")
└── NO ↓
Does it use browser-only APIs (window, localStorage, navigator)?
├── YES → Client Component ("use client")
└── NO ↓
Does it use third-party libraries that require hooks or browser APIs?
├── YES → Client Component ("use client")
└── NO ↓
Does it fetch data or access databases/file system?
├── YES → Server Component (default) ← BEST CHOICE
└── NO → Server Component (default) ← still the defaultRSC 패턴
패턴 1: 서버 사이드 데이터 페칭
async/await로 직접 데이터 가져오기.
// app/users/page.tsx — Server Component (default, no directive needed)
import { db } from '@/lib/database';
// This component is async — only possible in Server Components
export default async function UsersPage() {
// Direct database query — this code NEVER reaches the client
const users = await db.user.findMany({
orderBy: { createdAt: 'desc' },
take: 50,
});
return (
<div>
<h1>Users ({users.length})</h1>
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
</div>
);
}
// Benefits:
// - Zero JavaScript sent to client for this component
// - No loading states needed (data is available before render)
// - Database credentials never exposed to the browser
// - No useEffect + fetch + useState dance패턴 2: Server-Client 조합
Server가 Client를 렌더링 가능.
// components/LikeButton.tsx — Client Component
'use client';
import { useState } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(0);
return (
<button onClick={() => {
setLiked(!liked);
setCount(c => liked ? c - 1 : c + 1);
}}>
{liked ? 'Unlike' : 'Like'} ({count})
</button>
);
}
// app/posts/[id]/page.tsx — Server Component
import { db } from '@/lib/database';
import { LikeButton } from '@/components/LikeButton';
export default async function PostPage({ params }: { params: { id: string } }) {
// Server: fetch data
const post = await db.post.findUnique({ where: { id: params.id } });
return (
<article>
{/* Server-rendered content (zero JS) */}
<h1>{post?.title}</h1>
<div>{post?.content}</div>
{/* Client Component island (only this ships JS) */}
<LikeButton postId={params.id} />
</article>
);
}
// Pattern: Server Component as children of Client Component
// components/Sidebar.tsx
'use client';
export function Sidebar({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<aside style={{ display: open ? 'block' : 'none' }}>
<button onClick={() => setOpen(!open)}>Toggle</button>
{children} {/* Server Component content passed as children */}
</aside>
);
}패턴 3: Server Actions
서버 사이드 함수를 직접 호출.
// app/actions.ts — Server Action
'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.post.create({
data: { title, content, authorId: 'user-1' },
});
revalidatePath('/posts');
}
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } });
revalidatePath('/posts');
}
// components/CreatePostForm.tsx — Client Component using Server Action
'use client';
import { createPost } from '@/app/actions';
import { useActionState } from 'react';
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write your post..." required />
<button type="submit">Create Post</button>
</form>
);
}
// Works without API routes!
// The form submits directly to the server action
// Progressive enhancement: works without JavaScript패턴 4: Suspense 스트리밍
느린 데이터 페칭이 전체 페이지를 차단하지 않음.
// app/dashboard/page.tsx — Streaming with Suspense
import { Suspense } from 'react';
// Slow component — fetches from external API
async function AnalyticsChart() {
const data = await fetch('https://api.analytics.com/data', {
next: { revalidate: 60 },
});
const analytics = await data.json();
return <div>Chart: {analytics.visitors} visitors</div>;
}
// Fast component — fetches from local DB
async function RecentPosts() {
const posts = await db.post.findMany({ take: 5 });
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* This renders immediately */}
<Suspense fallback={<div>Loading posts...</div>}>
<RecentPosts />
</Suspense>
{/* This streams in when ready (doesn't block the page) */}
<Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsChart />
</Suspense>
</div>
);
}
// Result: User sees "Dashboard" + posts immediately
// Analytics chart streams in when the API responds
// No client-side loading spinners needed데이터 페칭 전략
순차적 데이터 페칭
의존성이 있는 경우.
// Sequential: second request depends on first
async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId); // 1st request
const posts = await getUserPosts(user.name); // 2nd (depends on user.name)
return <div>{user.name}: {posts.length} posts</div>;
}병렬 데이터 페칭
독립적인 경우 Promise.all 사용.
// Parallel: independent requests — use Promise.all
async function Dashboard() {
// Start all requests simultaneously
const [users, posts, analytics] = await Promise.all([
getUsers(),
getPosts(),
getAnalytics(),
]);
return (
<div>
<UserList users={users} />
<PostList posts={posts} />
<AnalyticsPanel data={analytics} />
</div>
);
}캐싱과 재검증
Next.js는 fetch에 캐싱 제어를 확장합니다.
// Next.js fetch caching strategies
// 1. Static (cached indefinitely until revalidated)
const data = await fetch('https://api.example.com/data');
// Equivalent to: { cache: 'force-cache' }
// 2. Revalidate every 60 seconds (ISR)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 },
});
// 3. Dynamic (no caching — fresh on every request)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store',
});
// 4. Tag-based revalidation
const data = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
// In a Server Action:
import { revalidateTag } from 'next/cache';
revalidateTag('posts'); // Purge all fetches tagged 'posts'성능 이점
RSC는 여러 성능 이점을 제공합니다.
RSC Performance Benefits:
1. Zero Client JavaScript for Server Components
- Only Client Components ship JS to the browser
- Large libraries used on server (markdown, syntax highlighting) add zero bundle
2. Streaming SSR
- HTML streams to browser as components resolve
- First Contentful Paint (FCP) is faster
- Time to Interactive (TTI) is faster (less JS to hydrate)
3. Automatic Code Splitting
- Each Client Component is automatically code-split
- No manual dynamic imports needed
4. Reduced Data Transfer
- Server Components fetch data on the server (same network)
- No client-side fetch waterfalls
- No duplicate data in both HTML and JS payload
5. Smaller Hydration Payload
- Only Client Components need hydration
- Server Component output is static — no hydration needed
Typical Results:
Before RSC: 450KB JS bundle, 2.5s TTI
After RSC: 180KB JS bundle, 1.1s TTI (60% JS reduction)일반적인 실수
// Mistake 1: Adding "use client" unnecessarily
// BAD: This doesn't need client-side JavaScript
'use client'; // REMOVE THIS
export function UserCard({ name, email }: { name: string; email: string }) {
return <div>{name} ({email})</div>; // No hooks, no events = Server Component
}
// Mistake 2: Importing Server Component in Client Component
// BAD: This won't work
'use client';
import { ServerComponent } from './ServerComponent'; // ERROR
// FIX: Pass as children instead
export function ClientWrapper({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
// Mistake 3: Using "use client" too high in the tree
// BAD: Making a layout a Client Component
'use client'; // This makes EVERYTHING below it client-side
export function Layout({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('dark');
return <div className={theme}>{children}</div>;
}
// FIX: Extract the interactive part into a small Client Component
// components/ThemeToggle.tsx
'use client';
export function ThemeToggle() {
const [theme, setTheme] = useState('dark');
return <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>{theme}</button>;
}
// layout.tsx — stays as Server Component
export function Layout({ children }: { children: React.ReactNode }) {
return <div><ThemeToggle />{children}</div>;
}자주 묻는 질문
Next.js 없이 Server Components를 사용할 수 있나요?
기술적으로는 가능하지만, 실용적으로 Next.js가 주요 프레임워크입니다.
Server Components가 SSR을 대체하나요?
아닙니다. 보완합니다.
"use client"는 언제 사용하나요?
hooks, 이벤트 핸들러, 브라우저 API가 필요할 때.
Server Components에서 Context를 사용할 수 있나요?
사용할 수 없습니다.
SEO에 미치는 영향은?
긍정적입니다. 서버에서 HTML 생성.
관련 도구 및 가이드
- JSON Formatter - Format API responses and data
- JSON to TypeScript - Generate types from API responses
- HTML to JSX - Convert HTML to React JSX
- Next.js App Router Guide
- React Hooks Guide
- TypeScript Generics Guide
- Web Performance Optimization