Server Components vs Client Components — 서버와 클라이언트의 경계
Server Components vs Client Components — 서버와 클라이언트의 경계
Next.js App Router에서 컴포넌트를 만들면 기본이 Server Component입니다. 그런데
useState를 쓰려면'use client'를 붙여야 한다는데, 그 경계는 어떻게 나누는 걸까요?
이 경계를 잘못 설계하면 불필요한 JS 번들이 클라이언트로 전송되거나, 반대로 인터랙티브한 기능이 동작하지 않습니다.
"일단 다 'use client' 붙이면 되지 않나?"라고 생각할 수 있지만, 그러면 Next.js를 쓰는 의미가 절반으로 줄어듭니다.
개념 정의
Server Component 는 서버에서만 실행되어 HTML로 렌더링된 결과만 클라이언트에 전달하는 컴포넌트입니다.
Client Component 는 'use client' 지시어를 선언하여 브라우저에서도 실행되는 컴포넌트입니다. 상태 관리, 이벤트 핸들러 등 인터랙션이 필요할 때 사용합니다.
왜 Server Component가 기본인가
이 질문에 답하려면 기존 React의 문제점부터 봐야 합니다.
- 기존 React(CSR)는 모든 컴포넌트 코드를 JS 번들로 클라이언트에 전송 합니다.
- 컴포넌트가 많아질수록 번들 크기가 커지고, 초기 로딩이 느려집니다.
- 그런데 많은 컴포넌트는 단순히 데이터를 표시할 뿐,
useState나onClick이 필요 없습니다. - 이런 컴포넌트를 서버에서 렌더링하고 결과 HTML만 보내면 JS 번들에 포함되지 않습니다.
- 그래서 Next.js App Router는 ** 기본을 Server Component로 설정 **하고, 클라이언트 기능이 필요한 것만 명시적으로 선언하게 했습니다.
결과적으로 클라이언트에 전송되는 JS 양이 줄어들고, 초기 로딩 속도가 빨라집니다.
Server Component에서 할 수 있는 것
// app/posts/page.tsx — Server Component (기본)
import { db } from '@/lib/db';
export default async function PostsPage() {
const posts = await db.post.findMany(); // DB 직접 접근 가능
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Server Component에서는 이런 것들이 가능합니다.
- DB에 직접 접근 (Prisma, Drizzle 등)
- ** 파일 시스템 읽기** (
fs.readFile) - ** 환경 변수 사용** (API 키 같은 민감한 정보)
- async/await 사용 (컴포넌트 자체가 async 가능)
- ** 무거운 라이브러리 사용** (번들에 포함되지 않으므로)
Client Component가 필요한 순간
다음 기능이 필요하면 'use client'를 선언해야 합니다.
| 기능 | 이유 |
|---|---|
useState, useReducer | 상태는 브라우저 메모리에 존재 |
useEffect | 브라우저 API(DOM, window) 접근 |
onClick, onChange | 이벤트 핸들러는 브라우저에서 동작 |
useRouter (from next/navigation) | 클라이언트 사이드 네비게이션 |
| 브라우저 API | localStorage, window, navigator 등 |
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
클릭: {count}
</button>
);
}
경계 설계 패턴 — 잎사귀를 Client Component로
'use client'를 선언하면 ** 그 파일과 그 파일이 import하는 모든 모듈 **이 클라이언트 번들에 포함됩니다.
그래서 컴포넌트 트리에서 가능한 한 ** 아래쪽(잎사귀)**에 'use client'를 배치하는 것이 좋습니다.
app/page.tsx (Server Component)
├── Header.tsx (Server Component — 정적 텍스트)
├── PostList.tsx (Server Component — DB에서 데이터 fetch)
│ └── LikeButton.tsx (Client Component — onClick 필요)
└── Footer.tsx (Server Component — 정적 텍스트)
이 구조에서 클라이언트 번들에 포함되는 것은 LikeButton.tsx뿐입니다.
만약 page.tsx에 'use client'를 붙였다면 전체가 클라이언트 번들에 들어갑니다.
직렬화 제약 — Server에서 Client로 넘길 수 있는 것
Server Component가 Client Component에 props를 전달할 때, 그 props는 ** 서버와 클라이언트 사이를 네트워크로 이동 **합니다. 따라서 직렬화 가능한(JSON으로 변환 가능한) 값만 전달할 수 있습니다.
| 전달 가능 | 전달 불가 |
|---|---|
| string, number, boolean | 함수 (function) |
| 배열, 일반 객체 | 클래스 인스턴스 |
| Date (문자열로 변환됨) | Map, Set |
| null, undefined | Symbol |
| JSX (React Element) | EventEmitter 등 |
// Server Component
import LikeButton from './LikeButton';
export default async function Post() {
const post = await getPost();
return (
<div>
<h1>{post.title}</h1>
{/* postId(number)는 직렬화 가능 → 전달 가능 */}
<LikeButton postId={post.id} />
{/* 함수를 props로 넘기면 에러 */}
{/* <LikeButton onLike={() => console.log('liked')} /> ← 불가! */}
</div>
);
}
주의할 점
"일단 'use client' 붙이자"는 안티패턴
모든 파일에 'use client'를 붙이면 Server Component의 이점(작은 번들, DB 직접 접근, 민감 정보 보호)을 모두 포기하는 것입니다.
이건 CRA와 다를 게 없습니다.
**판단 기준 **: useState, useEffect, 이벤트 핸들러, 브라우저 API를 사용하지 않는다면 Server Component로 유지합니다.
Server Component 안에 Client Component를 import할 수 있지만, 반대는 불가
Server Component에서 Client Component를 렌더링하는 것은 가능합니다. 하지만 Client Component에서 Server Component를 직접 import하면 그 Server Component도 클라이언트 번들에 포함됩니다.
우회 방법은 children 패턴 입니다.
// ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return isOpen ? <div>{children}</div> : null;
}
// page.tsx (Server Component)
import ClientWrapper from './ClientWrapper';
import ServerContent from './ServerContent'; // Server Component
export default function Page() {
return (
<ClientWrapper>
<ServerContent /> {/* Server Component를 children으로 전달 */}
</ClientWrapper>
);
}
이렇게 하면 ServerContent는 서버에서 렌더링된 결과(JSX)로 전달되므로 클라이언트 번들에 포함되지 않습니다.
async Client Component는 에러
Client Component에 async를 붙이면 에러가 발생합니다. async 컴포넌트는 Server Component에서만 사용할 수 있습니다.
'use client';
// 이건 에러!
export default async function Dashboard() {
const data = await fetchData();
return <div>{data}</div>;
}
클라이언트에서 데이터를 가져오려면 useEffect + useState 또는 React Query 같은 라이브러리를 사용해야 합니다.
정리
| 구분 | Server Component | Client Component |
|---|---|---|
| 선언 | 기본 (아무것도 안 쓰면) | 'use client' |
| 실행 위치 | 서버만 | 서버(SSR) + 클라이언트 |
| 상태/이벤트 | 불가 | 가능 |
| DB/파일 접근 | 가능 | 불가 |
| JS 번들 포함 | 미포함 | 포함 |
| async 컴포넌트 | 가능 | 불가 |
기억할 한 줄: "기본은 서버, 인터랙션이 필요한 잎사귀만 클라이언트."