C
Next.js/기초/Lesson 02

Client vs Server 경계 — 'use client' 가 모든 걸 바꾼다

35분·theory
이 챕터
2/3
TypeScript

Client vs Server 경계 — 'use client' 가 모든 걸 바꾼다

💡 왜 배워야 할까요? — Server/Client 경계가 곧 번들 크기와 보안의 경계

🎯 App Router 의 모든 컴포넌트는 기본 Server. 'use client' 를 어디 붙이느냐가 클라이언트 번들 크기를 결정합니다.
💼 Client 안에서 DB·시크릿·`fs` 모듈을 import 하면 빌드 에러 — 경계를 잘 못 그리면 서비스가 빌드조차 안 됩니다.
반대로 모든 걸 'use client' 로 만들면 Server Component 의 장점(0KB 번들, 직접 DB 접근) 을 다 잃습니다 — 그건 그냥 옛 SPA.
🔗 가장 중요한 패턴: **Server 가 Client 를 children 으로 끼우는 composition** — 이게 안 되면 Client 안에서 Server 데이터를 못 씁니다.
🏢 실무에서는
실무에서 가장 흔한 실수: 페이지 컴포넌트 맨 위에 무심코 'use client' 를 적고 그 안에서 `await db.user.findMany()` 호출 → 브라우저에서 DB 가 안 부르니 빌드 에러. 해결책은 'use client' 를 정말 상호작용이 필요한 작은 leaf 컴포넌트(버튼·폼·useState 가 있는 부분) 에만 두고, 나머지는 Server 로 두는 것.

경계의 3가지 핵심 규칙

1. 'use client' 의 진짜 의미

tsx
'use client';
  • 이 파일과 이 파일이 import 하는 모든 컴포넌트 가 Client Component 가 됩니다 (전염성).
  • 즉, 'use client' 는 "이 컴포넌트만" 이 아니라 "여기서부터 시작하는 트리" 의 경계 표시.
  • 한 페이지 안에서 'use client' 경계는 여러 개 있을 수 있고, 그게 자연스럽습니다.

2. 'use client' 가 필요한 5가지 신호

신호예시
상태 hookuseState, useReducer
효과 hookuseEffect, useLayoutEffect
브라우저 APIwindow, localStorage, navigator
이벤트 핸들러onClick, onChange, onSubmit
Context Provider<MyContext.Provider> (대부분)

이 중 하나라도 필요 → 'use client'. 없으면 Server.

3. Server → Client 는 OK, Client → Server 는 ❌

tsx
// ✅ Server 가 Client 를 import 해서 끼움 — 가능
// app/page.tsx (Server)
import { Button } from './Button';  // Button 은 'use client'
export default async function Page() {
  const data = await db.posts.findMany();
  return <div>{data.length}개<Button /></div>;
}
tsx
// ❌ Client 가 Server 를 import 해서 직접 끼우면 빌드 에러
// Header.tsx (Client)
'use client';
import { ServerWidget } from './ServerWidget'; // 'use client' 없음
export function Header() {
  return <ServerWidget />; // ❌ 빌드 에러
}

4. 해결책 — children prop 으로 composition

tsx
// ClientShell.tsx (Client)
'use client';
import { useState } from 'react';
export function ClientShell({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>토글</button>
      {open && children}  {/* Server 콘텐츠가 여기 들어옴 */}
    </div>
  );
}
tsx
// app/page.tsx (Server)
import { ClientShell } from './ClientShell';
import { ServerWidget } from './ServerWidget'; // Server
export default function Page() {
  return (
    <ClientShell>
      <ServerWidget />  {/* Server 가 Client 의 children 으로 들어감 — OK */}
    </ClientShell>
  );
}

핵심: Client 가 Server 를 "import" 해서 직접 끼우는 건 안 되지만, Server 가 만든 JSX 를 Client 의 children prop 으로 "넘겨주는" 건 OK. 이게 App Router 의 가장 중요한 패턴.

5. Server Component 안에서 자주 헷갈리는 것

tsx
// ❌ Server 에서 onClick — 빌드 에러
export default function Page() {
  return <button onClick={() => alert('hi')}>X</button>;
}

// ✅ 클릭 동작은 Client leaf 로 분리
// app/page.tsx
import { AlertButton } from './AlertButton';
export default function Page() {
  return <AlertButton />;
}
// AlertButton.tsx
'use client';
export function AlertButton() {
  return <button onClick={() => alert('hi')}>X</button>;
}
💻 🅰️ 흔한 실수 — 페이지 전체에 'use client' 박기
// ❌ 페이지 전체를 'use client' 로 만든 안티패턴

// 📁 app/posts/page.tsx
'use client';
import { useState, useEffect } from 'react';

interface Post { id: number; title: string; body: string; }

export default function PostsPage() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [liked, setLiked] = useState<Set<number>>(new Set());

  // 클라이언트에서 데이터 fetch — 깜빡임·SEO 손해·번들 무거움
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(setPosts);
  }, []);

  const toggle = (id: number) => {
    const n = new Set(liked);
    n.has(id) ? n.delete(id) : n.add(id);
    setLiked(n);
  };

  return (
    <ul>
      {posts.map(p => (
        <li key={p.id}>
          <h2>{p.title}</h2>
          <p>{p.body}</p>
          <button onClick={() => toggle(p.id)}>
            {liked.has(p.id) ? '❤️' : '🤍'}
          </button>
        </li>
      ))}
    </ul>
  );
}

// 단점:
// - posts 데이터·렌더 코드 전부 클라이언트 번들에 포함
// - 초기 페이지 = 빈 화면 → useEffect 후 깜빡 → 표시
// - SEO 손해 (검색엔진이 빈 HTML 만 봄)
// - DB 직접 접근 불가 → 무조건 /api/posts 거쳐야 함
💻 🅱️ 경계 잘 그린 패턴 — Server 가 데이터, Client 는 좋아요 버튼만
// ✅ 경계를 leaf 까지 내림 — Server 가 데이터, Client 는 토글만

// 📁 app/posts/page.tsx (Server)
import { LikeButton } from './LikeButton';

interface Post { id: number; title: string; body: string; }

// 컴포넌트 자체가 async — DB 직접
export default async function PostsPage() {
  const posts: Post[] = await db.post.findMany();

  return (
    <ul>
      {posts.map(p => (
        <li key={p.id}>
          <h2>{p.title}</h2>
          <p>{p.body}</p>
          <LikeButton postId={p.id} />  {/* Client leaf */}
        </li>
      ))}
    </ul>
  );
}

// 📁 app/posts/LikeButton.tsx (Client)
'use client';
import { useState } from 'react';

export function LikeButton({ postId }: { postId: number }) {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

// 📁 app/dashboard/page.tsx — children 으로 Server 콘텐츠 끼우기
import { ClientShell } from './ClientShell';
import { ServerStats } from './ServerStats';

export default function Dashboard() {
  return (
    <ClientShell>
      <ServerStats />  {/* Server 컴포넌트를 children 으로 — OK */}
    </ClientShell>
  );
}

// 📁 app/dashboard/ClientShell.tsx
'use client';
import { useState } from 'react';

export function ClientShell({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(true);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>토글</button>
      {open && children}  {/* Server JSX 가 그대로 렌더됨 */}
    </div>
  );
}

// 장점:
// - PostsPage 의 코드는 클라이언트 번들에 ❌ — HTML 만 전송
// - LikeButton 만 작은 청크로 분리되어 hydration
// - SEO 완벽 (서버에서 모든 posts 렌더됨)
// - DB 직접 접근 가능

💡 💡 경계 잘 그리는 5가지 규칙

1. 'use client' 는 가능한 leaf 에 가깝게
페이지·레이아웃 같은 큰 컴포넌트에 'use client' 박는 건 거의 잘못된 결정. 진짜 상호작용이 필요한 작은 컴포넌트(버튼·인풋·드롭다운)에만.

2. 'use client' 의 전염성 기억하기
'use client' 가 붙은 파일이 import 하는 모든 것은 Client. Server Component 라도 그 import 트리 안에 들어가면 Client 처리됨.

3. Client 안에서 Server 콘텐츠가 필요하면 children 으로 받기

tsx
<ClientLayout><ServerContent /></ClientLayout>
// ClientLayout 의 props 로 children 만 받는다

4. Server 에 onClick 못 박는다 — Client leaf 로 추출
빌드 에러로 알려주긴 하지만, 처음부터 패턴 알고 짜는 게 빠름.

5. 'use client' 를 붙여도 hydration 후 그 컴포넌트는 여전히 서버에서도 렌더됨
초기 HTML 은 서버가 그리고, 브라우저에서 hydration 으로 살아남. 즉 SSR 은 항상 일어남. 'use client' = '브라우저 전용 실행' 이 아니라 '브라우저에서도 살아남' 의 표시.

⚡ 직접 실행해보기 — 경계 위반 검사 시뮬레이션

각 컴포넌트가 어떤 환경에서 동작 가능한지 시뮬레이션합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

다음 중 Server Component 에서 그대로 사용 가능한 것은? (Client Component 로 추출이 필요하지 않은 것)
💡 DB·환경변수·파일시스템 접근은 **Server 전용** 능력입니다. `await db.user.findMany()` 는 Server Component 에서 직접 사용 가능 (오히려 Server 의 핵심 장점). useState·onClick·useEffect 는 모두 브라우저에서만 동작하므로 Client Component 로 분리 필수. 이게 Server Component 의 가장 중요한 결정 기준입니다.
Client vs Server 경계 - Next.js