메인 콘텐츠로 바로가기

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 ComponentClient 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 Hooks
  • onClick, onChange 등 이벤트 핸들러
  • window, localStorage 등 브라우저 API
  • Class Component
  • Context API

디버깅 팁

1. Component 타입 확인

개발자 도구에서 컴포넌트 이름 확인:

  • Server Component: 파란색
  • Client Component: 초록색

2. 번들 분석

# 번들 사이즈 확인 pnpm build # 상세 분석 npx @next/bundle-analyzer

3. 일반적인 에러

에러: “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 접근
  • ❌ 서버 리소스 직접 접근 불가

핵심 원칙

  1. 기본은 Server Component - 필요할 때만 Client Component 사용
  2. Leaf에서 선언 - 가능한 한 컴포넌트 트리 말단에서 ‘use client’ 선언
  3. Props로 조합 - Server Component를 Client Component의 children으로 전달

관련 포스트:

댓글

developjik
All content is licensed under CC BY-NC-SA 4.0 unless otherwise noted.