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

8 분 소요

지난 시간에는 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 삼단 콤보가 단 두 줄로 줄어드는 모습을 보게 될 것입니다.

X