목차
24 장

Server Components vs Client Components

두 종류의 컴포넌트 차이, `use client` 디렉티브의 정확한 의미, 둘을 섞어 쓰는 패턴(서버가 클라이언트를 import / 클라이언트가 서버를 children으로 받기), 그리고 props 직렬화 제약까지.

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

본 챕터의 props 직렬화 제약은 17장 (props와 children 타이핑)의 props 모델을 RSC 환경에서 다시 짚는 지점입니다. 그리고 27장 (Server Actions와 폼)에서 만날 “서버 함수를 클라이언트에 직접 넘기는” 우아한 예외도 본 챕터의 제약 위에서 의미가 드러납니다.

둘의 차이 한눈에 #

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.tsx
'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’의 정확한 의미 #

정확히는 'use client'는 “서버 / 클라이언트 경계“를 그어 주는 표시입니다. 디렉티브가 있는 파일은 Client Component이고, 그 자식들은 별도 디렉티브 없이도 자동으로 Client Component가 됩니다.

경계 모델
[Server] HomePage
  ↓ import
[Server] ServerOnlyChart
  ↓ import
[Client] Counter ('use client')   ← 여기서부터 경계
  ↓ import
[Client] CounterIcon              ← 자동으로 Client

Counter'use client'가 있으면, Counter가 import 하는 CounterIcon은 별도 디렉티브 없이도 Client가 됩니다. 한 번 경계를 넘으면 그 아래는 모두 Client 트리가 됩니다.

실험 1 — Server Component에서 useState 써 보기 #

직접 에러를 한 번 보면 머릿속에 잘 각인됩니다.

src/app/page.tsx (의도적인 에러)
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.tsx
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의 가장 대표적인 강점 중 하나입니다. 21장 마지막 절의 RSC 미리보기 패턴이 바로 이 모양이었습니다. 자세히는 다음 25장 데이터 페칭과 캐싱에서 다룹니다.

어떻게 섞어 써야 하는가 #

대부분의 페이지는 둘이 섞인 형태가 됩니다. 정적인 부분 (헤더, 본문 텍스트, 데이터 표시)은 Server Component, 인터랙션이 필요한 부분 (폼, 토글, 드롭다운)만 Client Component로.

패턴 1. 서버에서 클라이언트를 import 한다 #

가장 흔한 패턴입니다.

src/app/page.tsx (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.tsx (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.tsx (Client)
'use client';

import { useState } from 'react';
import type { ReactNode } from 'react';

export default function Wrapper({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(true);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>토글</button>
      {open && <div>{children}</div>}
    </div>
  );
}
src/app/page.tsx (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는 직렬화 가능해야 합니다 (서버에서 만든 값을 직렬화해 클라이언트로 보내는 구조이므로).

17장 (props와 children 타이핑)에서 정의한 props가 본 절의 제약 위에서 한 번 더 의미를 가집니다.

직렬화 가능직렬화 불가
string / number / boolean / null / undefined함수 (이벤트 핸들러 등)
일반 객체 / 배열클래스 인스턴스 (자체 메서드를 가진 객체)
Date / Map / SetSymbol
Promise (26장 use()와 짝)React 컴포넌트가 아닌 임의 React 노드 일부
React 컴포넌트 (자식)

그래서 이벤트 핸들러를 Server Component에서 만들어 Client에 넘길 수는 없습니다.

🚫 안 됨
// page.tsx (Server Component)
export default function HomePage() {
  function handleClick() {  // 이 함수는 클라이언트로 못 감
    console.log('서버에서 정의된 함수');
  }
  return <Button onClick={handleClick} />;  // 에러
}

대신 클라이언트 쪽에서 핸들러를 정의합니다.

✅ 정상
// Button.tsx
'use client';
export default function Button() {
  function handleClick() { /* ... */ }
  return <button onClick={handleClick}>클릭</button>;
}

우아한 예외 — Server Actions #

다음 27장 (Server Actions와 폼)에서 다룰 Server Actions는 위 제약의 우아한 예외입니다. 서버 함수를 클라이언트에 직접 넘길 수 있는 특수한 메커니즘입니다.

27장에서 만날 모델 미리보기
// 서버 함수 (별도 파일 또는 'use server' 디렉티브)
async function deletePost(id: string) {
  'use server';
  await db.posts.delete(id);
}

// 클라이언트에서 props로 받아 호출
<DeleteButton onDelete={deletePost} />

내부적으로 Server Action은 일반 함수 직렬화가 아니라 RPC (Remote Procedure Call) 메커니즘으로 동작합니다. 클라이언트는 함수를 받는 게 아니라 “이 함수를 서버에서 호출해 달라"는 참조를 받는 것입니다.

27장에서 본격적으로 다룹니다. 본 챕터에서는 “직렬화 제약에 우아한 예외가 하나 있다"만 머릿속에 두시면 됩니다.

동작 확인 — 작은 예제 #

23장의 사이트에 인터랙션을 더해 봅시다. 헤더에 다크모드 토글을 추가하고, 포스트 상세 페이지에 좋아요 버튼을 답니다.

src/app/ThemeToggle.tsx:

src/app/ThemeToggle.tsx (Client)
'use client';

import { useState, useEffect } from 'react';

export default function ThemeToggle() {
  const [theme, setTheme] = useState<'light' | 'dark'>('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.tsx:

src/app/LikeButton.tsx (Client)
'use client';

import { useState } from 'react';

type Props = {
  initial?: number;
};

export default function LikeButton({ initial = 0 }: Props) {
  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.tsx 헤더 부분에 ThemeToggle을 추가:

src/app/layout.tsx (수정)
import Link from 'next/link';
import ThemeToggle from './ThemeToggle';
import type { ReactNode } from 'react';
import './globals.css';

export const metadata = { title: '리액트 데모' };

export default function RootLayout({ children }: { children: ReactNode }) {
  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.tsx에 LikeButton을 추가:

src/app/posts/[slug]/page.tsx (수정)
import LikeButton from '../../LikeButton';

type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  return (
    <div style={{ padding: '24px' }}>
      <h1>{slug}</h1>
      <p> 페이지는 슬러그 "{slug}"  본문입니다.</p>
      <LikeButton initial={0} />
    </div>
  );
}

여기서 핵심 — layout.tsxpage.tsx는 여전히 Server Component입니다. 'use client'가 없습니다. 다만 그 안에 Client Component (ThemeToggle, LikeButton)를 임포트해 씁니다. 페이지 전체의 코드는 클라이언트로 안 가고, 작은 인터랙티브 조각들만 클라이언트로 갑니다. RSC의 이점을 그대로 누리는 것입니다.

브라우저 개발자 도구의 Network 탭에서 자바스크립트 번들을 확인해 보면, 페이지를 여러 개 만들어도 클라이언트로 전송되는 JS는 (인터랙티브 컴포넌트 외에는) 별로 늘지 않는 것을 볼 수 있습니다.

연습문제 #

  1. 위 예제에서 ThemeToggle'use client'를 빼 보세요. dev 서버 / 브라우저에 어떤 에러가 뜨는지 확인합니다. 그 뒤 LikeButtoninitial prop 위치에 () => 0 같은 함수를 넘기려 해 보세요. 직렬화 제약에 의한 에러가 어떻게 뜨는지 직접 봅니다.
  2. Wrapper 패턴 직접 만들기. 'use client'가 붙은 Collapsible 컴포넌트를 만들고, 그 자식으로 Server Component ServerOnlyChart를 끼워 넣어 보세요. <Collapsible><ServerOnlyChart /></Collapsible> 형태. Collapsible의 토글이 동작하면서 ServerOnlyChart의 코드는 클라이언트로 안 가는 것을 Network 탭에서 확인합니다.
  3. props 직렬화 경계 탐색. Server Component에서 다음 값들을 Client Component에 props로 넘겨 보고 결과를 관찰하세요. (a) new Date(), (b) { name: '커티스', greet: () => 'hi' }, (c) [1, 2, 3], (d) new URL('https://example.com'). 어떤 게 통과하고 어떤 게 에러를 내는지 직접 손에 익혀 봅니다.

한 줄 요약: Server Component (기본)는 서버에서만 실행되고 코드가 클라이언트로 안 간다. 'use client'를 붙이면 Client Component로, 그 자식들은 자동으로 Client. 페이지 (Server) 안에 인터랙티브 자식 (Client)을 끼워 넣는 패턴이 표준이고, Client가 Server 자식을 받아야 하면 children prop으로 우회한다. props는 직렬화 가능한 값만 (함수 / 클래스 인스턴스 제외). 27장 Server Actions가 이 제약의 우아한 예외다.

다음 챕터 #

다음 25장 데이터 페칭과 캐싱에서는 Server Component의 가장 강력한 기능 — async / await로 데이터를 직접 가져오는 패턴을 본격적으로 다룹니다. 21장의 useEffect + fetch + 로딩 state 삼단 콤보가 단 두 줄로 줄어드는 모습을 보게 됩니다. 그리고 Next.js 15의 cache 모델 — force-cache / no-store / revalidate — 도 한 번에 다룹니다.

X