Server Component vs Client Component — 경계를 어디에 그을 것인가
Article
처음엔 나도 그냥 붙였다
Next.js App Router를 처음 쓸 때, use client를 어디에 붙여야 하는지 기준이 없었습니다.
onClick이 필요하면 붙이고, useState가 필요하면 붙이고. 그러다 보니 거의 모든 컴포넌트에 use client가 붙어 있었습니다. 그때는 몰랐습니다. 그게 사실상 React SPA를 만들고 있는 거라는 걸.
Server Component의 장점을 하나도 못 쓰고 있었던 겁니다.
Server Component가 기본인 이유
App Router에서 모든 컴포넌트는 기본적으로 Server Component입니다. 이건 의도된 설계입니다.
Server Component는:
- 번들에 포함되지 않습니다. 클라이언트로 JavaScript가 전송되지 않습니다.
- 데이터베이스, 파일 시스템에 직접 접근할 수 있습니다.
- async/await를 컴포넌트 레벨에서 쓸 수 있습니다.
// Server Component — 기본값
async function ProductList() {
const products = await db.product.findMany();
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
이 코드는 서버에서 실행되고, 클라이언트에는 렌더링된 HTML만 내려갑니다. JavaScript 번들 크기? 0입니다.
Client Component가 필요한 순간
Client Component는 브라우저에서 실행되어야 하는 코드에만 써야 합니다.
useState,useEffect,useRef같은 React 훅onClick,onChange같은 이벤트 핸들러window,document같은 브라우저 API- 서드파티 라이브러리 중 클라이언트 전용인 것 (차트, 에디터 등)
'use client';
import { useState } from 'react';
function AddToCartButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
await addToCart(productId);
setLoading(false);
};
return (
<button onClick={handleClick} disabled={loading}>
{loading ? '담는 중...' : '장바구니 담기'}
</button>
);
}
여기서 중요한 건 버튼 하나 때문에 use client를 쓴다는 겁니다. 전체 페이지가 아니라요.
잘못된 경계 vs 올바른 경계
잘못된 예: 페이지 전체를 Client Component로
// ❌ 이렇게 하면 Server Component의 이점이 사라진다
'use client';
import { useState, useEffect } from 'react';
export default function ProductPage() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(res => res.json()).then(setProducts);
}, []);
return (
<div>
<h1>상품 목록</h1>
<ul>
{products.map(product => (
<li key={product.id}>
{product.name}
<button onClick={() => addToCart(product.id)}>담기</button>
</li>
))}
</ul>
</div>
);
}
이 코드의 문제:
- 데이터를 클라이언트에서 fetch합니다. 서버에서 바로 가져올 수 있는데요.
- 전체 컴포넌트가 번들에 포함됩니다. 상품 목록 렌더링 코드까지 전부.
- 초기 로딩 시 빈 화면이 보입니다. useEffect가 실행되기 전까지.
올바른 예: 인터랙션 부분만 격리
// page.tsx — Server Component (기본값)
export default async function ProductPage() {
const products = await db.product.findMany();
return (
<div>
<h1>상품 목록</h1>
<ul>
{products.map(product => (
<li key={product.id}>
{product.name}
<AddToCartButton productId={product.id} />
</li>
))}
</ul>
</div>
);
}
// AddToCartButton.tsx — Client Component
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
return (
<button onClick={() => { /* ... */ }} disabled={loading}>
담기
</button>
);
}
달라진 것:
- 상품 목록은 서버에서 렌더링 → 초기 로딩이 빠릅니다
- API 라우트가 필요 없습니다 → 서버에서 직접 DB 조회
- 클라이언트 번들에는 버튼 코드만 포함됩니다
- SEO에 유리합니다 → HTML에 상품 정보가 이미 있습니다
내가 쓰는 판단 기준
경계를 정할 때 저는 이 순서로 생각합니다.
1단계: 기본은 Server Component로 시작합니다.
모든 컴포넌트를 Server Component로 만듭니다. use client는 아직 안 붙입니다.
2단계: 빌드해봅니다. 에러가 나는 곳을 확인합니다.
useState, onClick 같은 걸 쓴 곳에서 에러가 납니다. 그 컴포넌트에 use client를 붙입니다.
3단계: 가능한 한 트리의 말단(leaf)에 붙입니다.
Page (Server) ← 여기에 붙이지 마라
├── Header (Server)
├── ProductList (Server)
│ ├── ProductCard (Server)
│ │ └── AddToCartButton (Client) ← 여기에 붙여라
│ └── ProductCard (Server)
│ └── AddToCartButton (Client)
└── Footer (Server)
핵심 원칙: use client는 가능한 한 아래로 밀어 내려라.
트리의 상위에 붙일수록, 그 아래 모든 컴포넌트가 Client Component가 됩니다. 반대로 말단에 붙이면 나머지는 전부 Server Component의 이점을 유지합니다.
흔한 실수: children 패턴을 모르면 생기는 일
레이아웃에 상태가 필요할 때 흔히 이런 실수를 합니다.
// ❌ 레이아웃 전체가 Client Component
'use client';
export default function Layout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="flex">
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<main>{children}</main>
</div>
);
}
이러면 children으로 들어오는 모든 페이지 컴포넌트도 Client Component처럼 동작할 수 있습니다.
해결: 상태가 필요한 부분만 분리합니다.
// layout.tsx — Server Component
export default function Layout({ children }) {
return (
<div className="flex">
<SidebarWrapper />
<main>{children}</main>
</div>
);
}
// SidebarWrapper.tsx — Client Component
'use client';
import { useState } from 'react';
export function SidebarWrapper() {
const [open, setOpen] = useState(true);
return <Sidebar open={open} onToggle={() => setOpen(!open)} />;
}
이제 레이아웃은 Server Component이고, 사이드바의 토글 상태만 클라이언트에서 관리됩니다.
정리
| 기준 | Server Component | Client Component |
|---|---|---|
| 데이터 가져오기 | ✅ 직접 DB/API 접근 | ❌ fetch 필요 |
| 브라우저 API | ❌ 사용 불가 | ✅ window, document |
| React 훅 | ❌ useState 등 불가 | ✅ 자유롭게 사용 |
| 이벤트 핸들러 | ❌ onClick 등 불가 | ✅ 자유롭게 사용 |
| 번들 크기 | ✅ 0 | ⚠️ 번들에 포함 |
| 초기 로딩 | ✅ HTML 즉시 렌더링 | ⚠️ JS 로드 후 렌더링 |
use client는 문법이 아닙니다. 아키텍처 결정입니다. 어디에 붙이느냐에 따라 번들 크기, 초기 로딩 속도, SEO, 사용자 경험이 전부 달라집니다.
기본은 Server. 필요한 곳만 Client. 가능한 한 아래로 밀어내세요.
이 원칙만 지키면 Next.js App Router를 제대로 쓰고 있는 겁니다.