모던 리액트 + Next.js #3 Server Components vs Client Components
지난 시간에는 Next.js 프로젝트를 만들고 App Router의 라우팅을 익혔습니다. 그 과정에서 우리가 만든 페이지들은 전부 Server Component였습니다. 이번 글에서는 두 종류의 컴포넌트(Server / Client)가 어떻게 다르고, 어떻게 섞어 쓰는지를 정리합니다.
둘의 차이 한눈에 #
| Server Component | Client Component | |
|---|---|---|
| 실행 위치 | 서버 (한 번) | 서버(SSR) + 클라이언트(hydration) |
| 코드가 클라이언트로 가나? | ❌ | ✅ |
useState / useEffect | ❌ | ✅ |
이벤트 핸들러 (onClick 등) | ❌ | ✅ |
async/await 직접 사용 | ✅ | (제한적) |
| DB / 환경변수 직접 접근 | ✅ | ❌ |
브라우저 API (window, localStorage) | ❌ | ✅ |
fs, path 같은 Node.js 모듈 | ✅ | ❌ |
| 기본 (App Router에서) | ✅ | (명시적 전환 필요) |
이 표를 외울 필요는 없습니다. 핵심은 **“어디서 실행되는가?”**입니다. 서버에서만 실행되면 브라우저에서만 의미 있는 것들(state, 이벤트, 브라우저 API)을 못 쓰는 게 자연스럽고, 클라이언트로 가는 코드라면 서버 자원(DB, 파일 시스템)에 접근할 수 없는 게 당연합니다.
‘use client’ 디렉티브 #
Client Component로 만들고 싶은 파일은 맨 위에 'use client' 한 줄을 추가합니다.
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
카운트: {count}
</button>
);
}이게 전부입니다. 'use client'가 있는 파일과 그것이 import하는 모든 파일은 클라이언트 번들에 포함됩니다. 반대로, 디렉티브가 없으면 그 파일은 Server Component이고 클라이언트로 가지 않습니다.
'use client'는 “서버/클라이언트 경계“를 그어주는 표시입니다. 디렉티브가 있는 파일은 Client Component이고, 그 자식들은 별도 디렉티브 없이도 자동으로 Client Component가 됩니다. Server Component 안에서 Client Component를 import해 쓸 수도 있고, 그 반대 방향에는 약간의 제약이 있는데(아래 다루겠습니다) 이 경계는 라이브러리 작성자나 큰 코드베이스를 짜는 게 아니라면 자연스럽게 익숙해집니다.실험 1 — Server Component에서 useState 써보기 #
직접 에러를 한 번 보면 머릿속에 잘 자리잡습니다.
import { useState } from 'react'; // 🚫 Server Component에서
export default function HomePage() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}저장하면 dev 서버 / 브라우저가 다음과 비슷한 에러를 보여줍니다.
You're importing a component that needs `useState`. This React hook only works in a client component. To fix, mark the file (or its parent) with the `"use client"` directive.해결책은 'use client'를 맨 위에 추가하거나, useState가 필요한 부분만 별도 Client Component로 빼는 것입니다 (둘째 방법이 보통 더 좋음 — 아래 설명).
실험 2 — Server Component에서 await 써보기 #
반대로, Server Component에서는 함수 자체를 async로 만들고 await을 자유롭게 쓸 수 있습니다.
export default async function HomePage() {
const data = await fetch('https://api.github.com/repos/facebook/react')
.then(res => res.json());
return (
<div style={{ padding: '24px' }}>
<h1>{data.full_name}</h1>
<p>⭐ {data.stargazers_count.toLocaleString()}</p>
<p>{data.description}</p>
</div>
);
}페이지 함수에 async를 붙이고 fetch를 직접 await하고 있습니다. 데이터를 가져온 다음에야 HTML이 만들어지고, 완성된 HTML이 클라이언트로 갑니다. 데이터 페칭 코드가 클라이언트로 안 가니 API 키나 인증 토큰을 안전하게 사용할 수도 있고요.
이건 Client Component에서는 일반적으로 못 하는 일이고, Server Component의 가장 대표적인 강점 중 하나입니다 (자세히는 #4에서).
어떻게 섞어 써야 하는가 #
대부분의 페이지는 둘이 섞인 형태가 됩니다. 정적인 부분(헤더, 본문 텍스트, 데이터 표시)은 Server Component, 인터랙션이 필요한 부분(폼, 토글, 드롭다운)만 Client Component로요.
패턴 1. 서버에서 클라이언트를 import한다 #
가장 흔한 패턴입니다.
import Counter from './Counter';
export default async function HomePage() {
const data = await fetch(/* ... */).then(r => r.json());
return (
<div>
<h1>{data.title}</h1>
<p>{data.description}</p>
<Counter /> {/* Client Component */}
</div>
);
}'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>+1 , {count}</button>;
}페이지의 겉껍데기는 Server Component로 데이터까지 넣어 그리고, 클릭이 필요한 작은 부분(Counter)만 Client Component로 떼어내 끼워 넣었습니다.
이 분리의 핵심: Counter 컴포넌트의 코드와 React/useState만 클라이언트로 가고, 페이지 전체나 가져온 데이터는 클라이언트로 가지 않습니다. 이것이 번들 크기 절감이 실제로 일어나는 방식입니다.
패턴 2. 클라이언트가 서버 자식을 children으로 받는다 #
흔한 함정 하나 — Client Component 안에서 Server Component를 직접 import할 수는 없습니다. 일단 Client 경계를 넘어가면 그 아래는 모두 Client로 간주되기 때문입니다.
'use client';
import ServerOnlyChart from './ServerOnlyChart'; // 자동으로 Client로 변환됨
export default function Wrapper() {
// ...
}해결책은 Server Component를 children prop으로 전달받는 것입니다.
'use client';
import { useState } from 'react';
export default function Wrapper({ children }) {
const [open, setOpen] = useState(true);
return (
<div>
<button onClick={() => setOpen(!open)}>토글</button>
{open && <div>{children}</div>}
</div>
);
}import Wrapper from './Wrapper';
import ServerOnlyChart from './ServerOnlyChart'; // 부모(page)가 Server라 안전
export default function HomePage() {
return (
<Wrapper>
<ServerOnlyChart />
</Wrapper>
);
}Wrapper(Client)는 자식이 무엇인지 모릅니다. 그냥 children을 받아 토글의 보임/숨김만 처리합니다. 실제 자식(ServerOnlyChart)은 부모(HomePage, Server)에서 import되어 이미 서버에서 렌더링된 결과로 들어옵니다. 경계의 침범 없이 두 종류를 섞을 수 있는 트릭입니다.
이 패턴은 Modal, Dialog, 토글 같은 “껍데기는 인터랙티브한데 내용은 정적"인 컴포넌트를 만들 때 매우 유용합니다.
어떤 컴포넌트를 어디에 둘지 — 가이드라인 #
새 컴포넌트를 만들 때 의식할 흐름:
- 기본은 Server Component —
'use client'를 안 붙임 - 다음 중 하나가 필요하면 Client로 전환:
useState,useReducer,useContext,useEffect,useRef, 그 외 훅- 이벤트 핸들러 (
onClick,onChange, …) - 브라우저 API (
window,document,localStorage,geolocation, …) - 클래스 컴포넌트
- 클라이언트 라이브러리 (예: framer-motion 일부)
- 전환할 때는 그 인터랙션이 필요한 가장 작은 부분만 떼어내고, 부모는 Server인 채로 둠
마지막 포인트가 중요합니다. “이 페이지에 한 군데라도 인터랙션이 있으니 페이지 전체를 Client로” 하면 RSC의 이점이 사라집니다. 인터랙션이 있는 자식만 Client로 하고 부모(페이지)는 Server로 유지하세요.
Props로 데이터를 넘길 때 — 직렬화 #
Server Component → Client Component로 props를 넘길 때 한 가지 제약이 있습니다. props는 직렬화 가능해야 합니다 (서버에서 만든 값을 직렬화해 클라이언트로 보내는 구조이므로).
직렬화 가능한 것:
- 원시 값 (string, number, boolean, null, undefined)
- 일반 객체와 배열
- Date
- Map, Set
- Promise (#5에서 다룸)
- React 요소
직렬화 불가:
- 함수
- 클래스 인스턴스 (자체 메서드를 가진 객체)
그래서 이벤트 핸들러를 Server Component에서 만들어 Client에 넘길 수는 없습니다.
// page.js (Server Component)
export default function HomePage() {
function handleClick() { // 이 함수는 클라이언트로 못 감
console.log('서버에서 정의된 함수');
}
return <Button onClick={handleClick} />; // 에러
}대신 클라이언트 쪽에서 핸들러를 정의합니다.
// Button.jsx
'use client';
export default function Button() {
function handleClick() { /* ... */ }
return <button onClick={handleClick}>클릭</button>;
}또는 #6에서 다룰 Server Actions는 이 제약의 우아한 예외입니다 — 서버 함수를 클라이언트에 직접 넘길 수 있는, 일반적인 함수와는 다른 특수한 메커니즘입니다.
동작 확인 — 작은 예제 #
지난 글의 사이트에 인터랙션을 더해봅시다. 헤더에 다크모드 토글을 추가하고, 포스트 상세 페이지에 좋아요 버튼을 답니다.
src/app/ThemeToggle.jsx:
'use client';
import { useState, useEffect } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState('light');
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
return (
<button
onClick={() => setTheme(prev => prev === 'light' ? 'dark' : 'light')}
style={{ marginLeft: 'auto', padding: '4px 12px' }}
>
{theme === 'light' ? '🌙' : '☀'}
</button>
);
}src/app/LikeButton.jsx:
'use client';
import { useState } from 'react';
export default function LikeButton({ initial = 0 }) {
const [count, setCount] = useState(initial);
const [liked, setLiked] = useState(false);
function toggle() {
if (liked) {
setCount(c => c - 1);
setLiked(false);
} else {
setCount(c => c + 1);
setLiked(true);
}
}
return (
<button onClick={toggle} style={{ padding: '8px 16px' }}>
{liked ? '❤' : '🤍'} {count}
</button>
);
}src/app/layout.js 헤더 부분에 ThemeToggle을 추가:
import Link from 'next/link';
import ThemeToggle from './ThemeToggle';
import './globals.css';
export const metadata = { title: '모던 리액트 데모' };
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<header style={{ display: 'flex', alignItems: 'center', padding: '12px 24px', background: '#222', color: '#fff' }}>
<Link href="/" style={{ color: '#fff', marginRight: '16px' }}>홈</Link>
<Link href="/about" style={{ color: '#fff', marginRight: '16px' }}>소개</Link>
<Link href="/posts" style={{ color: '#fff' }}>포스트</Link>
<ThemeToggle />
</header>
<main>{children}</main>
</body>
</html>
);
}src/app/posts/[slug]/page.js에 LikeButton을 추가:
import LikeButton from '../../LikeButton';
export default async function PostPage({ params }) {
const { slug } = await params;
return (
<div style={{ padding: '24px' }}>
<h1>{slug}</h1>
<p>이 페이지는 슬러그 "{slug}"의 본문입니다.</p>
<LikeButton initial={0} />
</div>
);
}여기서 핵심 — layout.js와 page.js는 여전히 Server Component입니다. 'use client'가 없습니다. 하지만 그 안에 Client Component(ThemeToggle, LikeButton)를 임포트해 사용합니다. 페이지 전체의 코드는 클라이언트로 안 가고, 작은 인터랙티브 조각들만 클라이언트로 갑니다. RSC의 이점을 그대로 누리는 것입니다.
브라우저 개발자 도구의 Network 탭에서 자바스크립트 번들을 확인해보면, 페이지를 여러 개 만들어도 클라이언트로 전송되는 JS는 (인터랙티브 컴포넌트 외에는) 별로 늘지 않는 걸 볼 수 있습니다.
마무리 #
이번 글에서는 두 종류의 컴포넌트를 다뤘습니다.
- Server Component (기본) — 서버에서만 실행, async/await 가능, 클라이언트에 코드가 안 감
- Client Component (
'use client') — 브라우저에서도 실행, 훅과 이벤트 핸들러 사용 가능 - 둘은 공존한다 — 페이지(Server) 안에 인터랙티브 자식(Client)을 끼워 넣는 형태
- Server → Client는 자연스럽게, Client → Server는
children으로 우회 - props는 직렬화 가능한 값만
다음 글인 “모던 리액트 + Next.js #4 데이터 페칭과 캐싱"에서는 Server Component의 가장 강력한 기능 — async/await로 데이터를 직접 가져오는 패턴을 본격적으로 다루겠습니다. 클라이언트의 useEffect + fetch + 로딩 state 삼단 콤보가 단 두 줄로 줄어드는 모습을 보게 될 것입니다.