모던 리액트 + Next.js #3 Server Components vs Client Components

18 분 소요

지난 시간에는 Next.js 프로젝트를 만들고 App Router의 라우팅을 익혔습니다. 그 과정에서 우리가 만든 페이지들은 전부 Server Component였어요. 이번 글에서는 두 종류의 컴포넌트(Server / Client)가 어떻게 다르고, 어떻게 섞어 쓰는지를 정리합니다.

둘의 차이 한눈에

Server ComponentClient 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' 한 줄을 추가합니다.

src/app/Counter.jsx
'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 써보기

직접 에러를 한 번 보면 머릿속에 잘 자리잡습니다.

src/app/page.js (의도적인 에러)
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을 자유롭게 쓸 수 있습니다.

src/app/page.js
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한다

가장 흔한 패턴입니다.

src/app/page.js (Server Component)
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>
  );
}
src/app/Counter.jsx (Client Component)
'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으로 전달받는 것입니다.

src/app/Wrapper.jsx (Client)
'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>
  );
}
src/app/page.js (Server)
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, 토글 같은 "껍데기는 인터랙티브한데 내용은 정적"인 컴포넌트를 만들 때 매우 유용합니다.

어떤 컴포넌트를 어디에 둘지 — 가이드라인

새 컴포넌트를 만들 때 의식할 흐름:

  1. 기본은 Server Component'use client'를 안 붙임
  2. 다음 중 하나가 필요하면 Client로 전환:
    • useState, useReducer, useContext, useEffect, useRef, 그 외 훅
    • 이벤트 핸들러 (onClick, onChange, ...)
    • 브라우저 API (window, document, localStorage, geolocation, ...)
    • 클래스 컴포넌트
    • 클라이언트 라이브러리 (예: framer-motion 일부)
  3. 전환할 때는 그 인터랙션이 필요한 가장 작은 부분만 떼어내고, 부모는 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:

src/app/ThemeToggle.jsx (Client)
'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:

src/app/LikeButton.jsx (Client)
'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을 추가:

src/app/layout.js (수정)
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을 추가:

src/app/posts/[slug]/page.js (수정)
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.jspage.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 삼단 콤보가 단 두 줄로 줄어드는 모습을 보게 될 거예요.