Next.js Server Component vs Client Component 이해하기
개요
Next.js 13의 App Router에서는 기본적으로 모든 컴포넌트가 Server Component로 동작합니다. Server Component는 서버에서만 렌더링되며, Client Component는 브라우저에서 인터랙티브한 기능을 제공합니다.
이 두 컴포넌트의 가장 큰 차이는 어디서 실행되는가와 어떤 기능을 사용할 수 있는가입니다. 적절한 선택은 애플리케이션의 성능과 사용자 경험에 직접적인 영향을 미칩니다.
배경
왜 Server Component가 필요한가?
전통적인 React 애플리케이션(CSR)의 문제점:
- 모든 JavaScript 번들을 브라우저로 전송
- 초기 로딩 시간이 길고 JavaScript 파싱 비용이 큼
- SEO 최적화가 어려움
- 서버 리소스(데이터베이스, 파일 시스템)에 직접 접근 불가
등장 이전의 방식
Next.js는 이전부터 SSR(Server-Side Rendering)과 SSG(Static Site Generation)를 제공했지만, 여전히 hydration 후에는 전체 페이지가 클라이언트 컴포넌트로 동작했습니다. 이는 불필요한 JavaScript를 브라우저로 보내는 결과를 초래했습니다.
Server Component는 이 문제를 해결합니다:
- 서버에서만 실행되는 컴포넌트는 JavaScript 번들에 포함되지 않음
- 인터랙션이 필요한 부분만 선택적으로 Client Component로 분리
- 더 작은 번들 사이즈와 빠른 초기 로딩
동작 원리
Server Component의 렌더링 흐름
[서버]
1. 컴포넌트 실행
2. 데이터 페칭 (직접 DB 쿼리 가능)
3. RSC Payload 생성 (JSON-like 구조)
↓
[네트워크 전송]
↓
[브라우저]
4. RSC Payload 파싱
5. HTML 렌더링 (JavaScript 실행 없음)Client Component의 렌더링 흐름
[서버]
1. 컴포넌트 번들링
2. HTML 프리렌더링 (SSR)
↓
[네트워크 전송]
↓
[브라우저]
3. HTML 표시
4. JavaScript 다운로드
5. Hydration (이벤트 핸들러 연결)
6. 인터랙티브 가능시각적 비교
┌─────────────────────────────────────────┐
│ Page (Server Component) │
│ ┌───────────────────────────────────┐ │
│ │ Header (Server Component) │ │
│ │ - DB에서 사용자 정보 조회 │ │
│ │ - JavaScript 번들 X │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ "use client" │ │
│ │ SearchBar (Client Component) │ │
│ │ - useState, onClick 사용 │ │
│ │ - JavaScript 번들 O │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ ProductList (Server Component) │ │
│ │ - DB에서 상품 목록 조회 │ │
│ │ - JavaScript 번들 X │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘주요 특징 비교
Server Component
특징 1: 서버 리소스에 직접 접근
// app/products/page.tsx
import { db } from '@/lib/database';
// ✅ Server Component: 데이터베이스 직접 접근 가능
async function ProductsPage() {
// 서버에서 실행되므로 직접 DB 쿼리
const products = await db.product.findMany({
include: { category: true }
});
return (
<div>
<h1>상품 목록</h1>
{products.map(product => (
<div key={product.id}>
<h2>{product.name}</h2>
<p>{product.price}원</p>
</div>
))}
</div>
);
}
export default ProductsPage;장점:
- API 라우트 불필요 (직접 DB 접근)
- 민감한 정보(API 키, 토큰)를 서버에만 보관
- 서버 사이드 라이브러리 사용 가능
특징 2: JavaScript 번들에 포함되지 않음
// app/blog/[slug]/page.tsx
import { marked } from 'marked'; // 대용량 마크다운 파서
// ✅ Server Component: marked 라이브러리가 클라이언트로 전송되지 않음
async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
const htmlContent = marked(post.content); // 서버에서 변환
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
</article>
);
}장점:
- 더 작은 JavaScript 번들
- 빠른 초기 로딩
- 대용량 라이브러리도 부담 없이 사용
특징 3: 자동 코드 스플리팅
Server Component는 자동으로 청크로 분리되어, 필요한 부분만 로드됩니다.
Client Component
특징 1: 인터랙티브 기능 사용
// components/SearchBar.tsx
'use client'; // ⚠️ 필수: Client Component 선언
import { useState } from 'react';
export function SearchBar() {
const [query, setQuery] = useState('');
const handleSearch = () => {
console.log('검색:', query);
// 검색 로직
};
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="상품 검색..."
/>
<button onClick={handleSearch}>검색</button>
</div>
);
}사용 가능한 기능:
- React Hooks (useState, useEffect, useContext 등)
- 이벤트 핸들러 (onClick, onChange 등)
- 브라우저 API (localStorage, window 등)
특징 2: 실시간 상태 관리
// components/CartButton.tsx
'use client';
import { useState, useEffect } from 'react';
export function CartButton() {
const [itemCount, setItemCount] = useState(0);
useEffect(() => {
// 로컬스토리지에서 장바구니 개수 가져오기
const count = localStorage.getItem('cartCount') || '0';
setItemCount(parseInt(count));
}, []);
return (
<button>
장바구니 ({itemCount})
</button>
);
}특징 3: Third-party 라이브러리 사용
// components/DatePicker.tsx
'use client';
import { useState } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
export function DateSelector() {
const [selectedDate, setSelectedDate] = useState(new Date());
return (
<DatePicker
selected={selectedDate}
onChange={(date) => setSelectedDate(date)}
/>
);
}비교 테이블
| 구분 | Server Component | Client Component |
|---|---|---|
| 선언 방법 | 기본값 (선언 불필요) | 'use client' 지시어 필요 |
| 실행 위치 | 서버에서만 | 서버(SSR) + 클라이언트 |
| 번들 포함 | ❌ 포함되지 않음 | ✅ 포함됨 |
| 데이터 페칭 | async/await 직접 사용 | useEffect + fetch/SWR |
| DB 접근 | ✅ 가능 | ❌ 불가능 (API 필요) |
| React Hooks | ❌ 사용 불가 | ✅ 사용 가능 |
| 이벤트 핸들러 | ❌ 사용 불가 | ✅ 사용 가능 |
| 브라우저 API | ❌ 사용 불가 | ✅ 사용 가능 |
| Context | ❌ 사용 불가 | ✅ 사용 가능 |
| 재렌더링 | ❌ 없음 | ✅ 상태 변경 시 |
실제 사용 사례
사례 1: 블로그 포스트 페이지
// app/blog/[slug]/page.tsx
// ✅ Server Component: 블로그 콘텐츠 렌더링
import { getPost, getRelatedPosts } from '@/lib/posts';
import { CommentSection } from '@/components/CommentSection';
import { ShareButtons } from '@/components/ShareButtons';
async function BlogPostPage({ params }: { params: { slug: string } }) {
// 서버에서 데이터 페칭
const [post, relatedPosts] = await Promise.all([
getPost(params.slug),
getRelatedPosts(params.slug)
]);
return (
<article>
{/* Server Component: 정적 콘텐츠 */}
<h1>{post.title}</h1>
<p className="author">작성자: {post.author}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Client Component: 인터랙티브 공유 버튼 */}
<ShareButtons title={post.title} url={post.url} />
{/* Client Component: 댓글 작성 폼 */}
<CommentSection postId={post.id} />
{/* Server Component: 관련 포스트 목록 */}
<aside>
<h2>관련 글</h2>
{relatedPosts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</aside>
</article>
);
}// components/ShareButtons.tsx
'use client';
export function ShareButtons({ title, url }: { title: string; url: string }) {
const handleShare = (platform: string) => {
// 브라우저 API 사용
if (navigator.share) {
navigator.share({ title, url });
} else {
// 폴백 로직
window.open(`https://twitter.com/share?url=${url}&text=${title}`);
}
};
return (
<div>
<button onClick={() => handleShare('twitter')}>
트위터 공유
</button>
<button onClick={() => handleShare('facebook')}>
페이스북 공유
</button>
</div>
);
}사례 2: E-commerce 상품 목록
// app/products/page.tsx
// ✅ Server Component: 상품 목록 페이지
import { db } from '@/lib/database';
import { FilterBar } from '@/components/FilterBar';
import { AddToCartButton } from '@/components/AddToCartButton';
async function ProductsPage({
searchParams
}: {
searchParams: { category?: string; sort?: string }
}) {
// 서버에서 DB 쿼리
const products = await db.product.findMany({
where: { category: searchParams.category },
orderBy: { price: searchParams.sort === 'price' ? 'asc' : 'desc' }
});
return (
<div>
<h1>상품 목록</h1>
{/* Client Component: 필터 및 정렬 UI */}
<FilterBar />
{/* Server Component: 상품 목록 */}
<div className="grid">
{products.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} />
<h2>{product.name}</h2>
<p>{product.price.toLocaleString()}원</p>
{/* Client Component: 장바구니 추가 버튼 */}
<AddToCartButton productId={product.id} />
</div>
))}
</div>
</div>
);
}// components/AddToCartButton.tsx
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId }: { productId: string }) {
const [isAdding, setIsAdding] = useState(false);
const handleAddToCart = async () => {
setIsAdding(true);
try {
// API 호출
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId, quantity: 1 })
});
alert('장바구니에 추가되었습니다!');
} catch (error) {
alert('오류가 발생했습니다.');
} finally {
setIsAdding(false);
}
};
return (
<button
onClick={handleAddToCart}
disabled={isAdding}
>
{isAdding ? '추가 중...' : '장바구니 담기'}
</button>
);
}사례 3: 대시보드
// app/dashboard/page.tsx
// ✅ Server Component: 대시보드 레이아웃
import { getServerSession } from 'next-auth';
import { getAnalytics } from '@/lib/analytics';
import { Chart } from '@/components/Chart';
import { RealtimeStats } from '@/components/RealtimeStats';
async function DashboardPage() {
// 서버에서 인증 확인
const session = await getServerSession();
if (!session) {
redirect('/login');
}
// 서버에서 분석 데이터 조회
const analytics = await getAnalytics(session.user.id);
return (
<div>
<h1>{session.user.name}님의 대시보드</h1>
{/* Server Component: 정적 통계 */}
<div className="stats-grid">
<StatCard title="총 방문자" value={analytics.totalVisitors} />
<StatCard title="페이지뷰" value={analytics.pageViews} />
<StatCard title="전환율" value={`${analytics.conversionRate}%`} />
</div>
{/* Client Component: 인터랙티브 차트 */}
<Chart data={analytics.chartData} />
{/* Client Component: 실시간 업데이트 */}
<RealtimeStats userId={session.user.id} />
</div>
);
}
function StatCard({ title, value }: { title: string; value: string | number }) {
return (
<div className="stat-card">
<h3>{title}</h3>
<p className="value">{value}</p>
</div>
);
}선택 가이드
Server Component를 사용해야 하는 경우
✅ 다음 경우에 Server Component 사용:
- 데이터베이스나 API에서 데이터를 가져와야 할 때
- 민감한 정보(API 키, 토큰)를 다룰 때
- 대용량 라이브러리를 사용해야 할 때
- SEO가 중요한 콘텐츠일 때
- 정적이고 변하지 않는 UI일 때
예시:
- 블로그 포스트 내용
- 상품 목록
- 사용자 프로필 (읽기 전용)
- 헤더, 푸터, 네비게이션
Client Component를 사용해야 하는 경우
✅ 다음 경우에 Client Component 사용:
- 이벤트 리스너가 필요할 때 (onClick, onChange 등)
- React Hooks를 사용해야 할 때 (useState, useEffect 등)
- 브라우저 전용 API를 사용할 때 (localStorage, window 등)
- 실시간으로 업데이트되는 UI일 때
- Third-party 라이브러리가 Client Component를 요구할 때
예시:
- 검색바, 필터
- 폼 입력
- 모달, 토스트 알림
- 차트, 대시보드
- 장바구니, 좋아요 버튼
구성 패턴
패턴 1: 컴포지션 (권장)
Server Component 안에 Client Component를 children으로 전달:
// app/layout.tsx (Server Component)
import { Header } from '@/components/Header';
import { Sidebar } from '@/components/Sidebar';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{/* Server Component */}
<Header />
<div className="container">
{/* Client Component */}
<Sidebar />
{/* children은 Server/Client 모두 가능 */}
<main>{children}</main>
</div>
</body>
</html>
);
}패턴 2: Props로 Server Component 전달
// components/ClientWrapper.tsx
'use client';
import { useState } from 'react';
export function ClientWrapper({
children,
serverData
}: {
children: React.ReactNode;
serverData: any;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
토글
</button>
{isOpen && (
<div>
{/* Server Component를 children으로 받음 */}
{children}
</div>
)}
</div>
);
}// app/page.tsx (Server Component)
import { ClientWrapper } from '@/components/ClientWrapper';
async function HomePage() {
const data = await fetchData();
return (
<ClientWrapper serverData={data}>
{/* 이 부분은 Server Component로 유지됨 */}
<ServerOnlyComponent data={data} />
</ClientWrapper>
);
}❌ 피해야 할 패턴: Client에서 Server import
// ❌ 잘못된 예시
'use client';
// Server Component를 Client Component에서 import하면
// 자동으로 Client Component로 변환됨
import { ServerComponent } from './ServerComponent';
export function ClientComponent() {
return (
<div>
{/* ServerComponent는 이제 Client Component가 됨 */}
<ServerComponent />
</div>
);
}올바른 방법: Props로 전달
// ✅ 올바른 예시
'use client';
export function ClientComponent({
serverComponent
}: {
serverComponent: React.ReactNode
}) {
return (
<div>
{/* Server Component를 props로 받아서 사용 */}
{serverComponent}
</div>
);
}데이터 페칭 전략
Server Component: 직접 async/await
// app/users/page.tsx
async function UsersPage() {
// ✅ Server Component: async 컴포넌트로 직접 데이터 페칭
const users = await fetch('https://api.example.com/users')
.then(res => res.json());
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Client Component: useEffect + State
// components/UserList.tsx
'use client';
import { useState, useEffect } from 'react';
export function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// ❌ Client Component: useEffect 필요
fetch('https://api.example.com/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
});
}, []);
if (loading) return <div>로딩 중...</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}혼합 전략: Server에서 초기 데이터, Client에서 업데이트
// app/dashboard/page.tsx
async function DashboardPage() {
// Server Component: 초기 데이터 페칭
const initialData = await getAnalytics();
return (
<div>
{/* Client Component: 초기 데이터를 받아 실시간 업데이트 */}
<RealtimeChart initialData={initialData} />
</div>
);
}// components/RealtimeChart.tsx
'use client';
import { useState, useEffect } from 'react';
export function RealtimeChart({ initialData }: { initialData: any }) {
const [data, setData] = useState(initialData);
useEffect(() => {
// 5초마다 데이터 업데이트
const interval = setInterval(async () => {
const newData = await fetch('/api/analytics').then(r => r.json());
setData(newData);
}, 5000);
return () => clearInterval(interval);
}, []);
return <Chart data={data} />;
}성능 최적화
전략 1: 최소한의 Client Component 사용
// ❌ 나쁜 예: 전체를 Client Component로
'use client';
export function Page() {
const [count, setCount] = useState(0);
return (
<div>
<Header /> {/* 불필요하게 Client Component가 됨 */}
<StaticContent /> {/* 불필요하게 Client Component가 됨 */}
<button onClick={() => setCount(count + 1)}>
클릭: {count}
</button>
</div>
);
}// ✅ 좋은 예: 필요한 부분만 Client Component로
// page.tsx (Server Component)
import { Header } from './Header';
import { StaticContent } from './StaticContent';
import { Counter } from './Counter';
export default function Page() {
return (
<div>
<Header /> {/* Server Component */}
<StaticContent /> {/* Server Component */}
<Counter /> {/* Client Component만 분리 */}
</div>
);
}
// Counter.tsx
'use client';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
클릭: {count}
</button>
);
}전략 2: Server Component에서 병렬 데이터 페칭
// ✅ 좋은 예: Promise.all로 병렬 페칭
async function ProductPage({ params }: { params: { id: string } }) {
// 병렬로 데이터 페칭
const [product, reviews, relatedProducts] = await Promise.all([
getProduct(params.id),
getReviews(params.id),
getRelatedProducts(params.id)
]);
return (
<div>
<ProductDetail product={product} />
<Reviews reviews={reviews} />
<RelatedProducts products={relatedProducts} />
</div>
);
}전략 3: Streaming을 위한 Suspense 활용
// app/products/page.tsx
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<div>
<h1>상품 목록</h1>
{/* 빠르게 로드되는 부분 */}
<Suspense fallback={<CategorySkeleton />}>
<Categories />
</Suspense>
{/* 느리게 로드되는 부분 */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
</div>
);
}
async function Categories() {
const categories = await getCategories(); // 빠름
return <CategoryNav categories={categories} />;
}
async function ProductList() {
const products = await getProducts(); // 느림
return <Products products={products} />;
}자주 묻는 질문 (FAQ)
Q1: 모든 컴포넌트를 Server Component로 만들어야 하나요?
A: 아니요. 인터랙션이 필요한 부분은 Client Component로 만들어야 합니다. 일반적으로 80-90%는 Server Component, 10-20%는 Client Component로 구성하는 것이 이상적입니다.
Q2: Client Component에서 데이터베이스에 접근하려면?
A: API Route를 통해 접근해야 합니다:
// app/api/products/route.ts (API Route)
import { db } from '@/lib/database';
export async function GET() {
const products = await db.product.findMany();
return Response.json(products);
}
// components/ProductList.tsx (Client Component)
'use client';
export function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products')
.then(r => r.json())
.then(setProducts);
}, []);
return <div>{/* render products */}</div>;
}Q3: useContext를 Server Component에서 사용할 수 있나요?
A: 아니요. Context는 Client Component에서만 사용 가능합니다:
// providers/ThemeProvider.tsx
'use client';
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext('light');
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);// app/layout.tsx (Server Component)
import { ThemeProvider } from '@/providers/ThemeProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{/* Client Component로 감싸기 */}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}Q4: 언제 ‘use client’를 파일 상단에 추가해야 하나요?
A: 다음 중 하나라도 사용할 때:
useState,useEffect등 React HooksonClick,onChange등 이벤트 핸들러window,localStorage등 브라우저 API- Class Component
- Context API
디버깅 팁
1. Component 타입 확인
개발자 도구에서 컴포넌트 이름 확인:
- Server Component: 파란색
- Client Component: 초록색
2. 번들 분석
# 번들 사이즈 확인
pnpm build
# 상세 분석
npx @next/bundle-analyzer3. 일반적인 에러
에러: “You’re importing a component that needs useState…”
해결: 해당 컴포넌트 상단에 'use client' 추가에러: “Cannot access window in Server Component”
해결:
1. 해당 로직을 Client Component로 분리
2. 또는 typeof window !== 'undefined' 체크관련 자료
공식 문서
심화 학습
요약
Server Component
- ✅ 기본값, 서버에서만 실행
- ✅ DB 직접 접근, 민감 정보 보호
- ✅ JavaScript 번들에 포함 안 됨
- ❌ 인터랙션 불가
Client Component
- ✅ ‘use client’ 필수
- ✅ React Hooks, 이벤트 핸들러 사용
- ✅ 브라우저 API 접근
- ❌ 서버 리소스 직접 접근 불가
핵심 원칙
- 기본은 Server Component - 필요할 때만 Client Component 사용
- Leaf에서 선언 - 가능한 한 컴포넌트 트리 말단에서 ‘use client’ 선언
- Props로 조합 - Server Component를 Client Component의 children으로 전달
관련 포스트: