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의 문제점부터 봐야 합니다.

  1. 기존 React(CSR)는 모든 컴포넌트 코드를 JS 번들로 클라이언트에 전송 합니다.
  2. 컴포넌트가 많아질수록 번들 크기가 커지고, 초기 로딩이 느려집니다.
  3. 그런데 많은 컴포넌트는 단순히 데이터를 표시할 뿐, useStateonClick이 필요 없습니다.
  4. 이런 컴포넌트를 서버에서 렌더링하고 결과 HTML만 보내면 JS 번들에 포함되지 않습니다.
  5. 그래서 Next.js App Router는 ** 기본을 Server Component로 설정 **하고, 클라이언트 기능이 필요한 것만 명시적으로 선언하게 했습니다.

결과적으로 클라이언트에 전송되는 JS 양이 줄어들고, 초기 로딩 속도가 빨라집니다.


Server Component에서 할 수 있는 것

TSX
// 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)클라이언트 사이드 네비게이션
브라우저 APIlocalStorage, window, navigator
TSX
'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'를 배치하는 것이 좋습니다.

PLAINTEXT
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, undefinedSymbol
JSX (React Element)EventEmitter 등
TSX
// 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 패턴 입니다.

TSX
// ClientWrapper.tsx
'use client';

export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);
  return isOpen ? <div>{children}</div> : null;
}
TSX
// 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에서만 사용할 수 있습니다.

TSX
'use client';

// 이건 에러!
export default async function Dashboard() {
  const data = await fetchData();
  return <div>{data}</div>;
}

클라이언트에서 데이터를 가져오려면 useEffect + useState 또는 React Query 같은 라이브러리를 사용해야 합니다.


정리

구분Server ComponentClient Component
선언기본 (아무것도 안 쓰면)'use client'
실행 위치서버만서버(SSR) + 클라이언트
상태/이벤트불가가능
DB/파일 접근가능불가
JS 번들 포함미포함포함
async 컴포넌트가능불가

기억할 한 줄: "기본은 서버, 인터랙션이 필요한 잎사귀만 클라이언트."

댓글 로딩 중...