Ch.1에서 우리는 CSR의 문제를 살펴보았습니다. 빈 HTML, 큰 번들, API 키 노출... 이 문제들을 해결하려면 서버가 필요하다는 결론에 도달했죠. 서버가 해결책이라면, 극단적으로 모든 것을 서버에서 처리하는 게 최선처럼 보일 수도 있습니다:
그러면 번들 사이즈 문제도, API 키 노출 문제도 한꺼번에 해결됩니다. 하지만 서버에서는 할 수 없는 일이 있습니다:
onClick)useState)localStorage, window 등)이것들이 불가능한 이유는 단순합니다. 서버 컴포넌트의 코드는 서버에서만 실행되고 브라우저에는 실행 결과만 전달되기 때문입니다. 코드 자체가 브라우저에 존재하지 않으니, 클릭에 반응하거나 상태를 유지하는 것은 원천적으로 불가능합니다. 그래서 React는 컴포넌트를 두 종류로 나눕니다. 서버 컴포넌트와 클라이언트 컴포넌트.
App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다. 아무 지시어도 붙이지 않으면 서버에서 실행됩니다.
서버 컴포넌트의 코드는 클라이언트 번들에 포함되지 않습니다. 서버에서 실행된 뒤 결과만 클라이언트에 전달되며, 브라우저에서 다시 실행되지 않습니다. (React 자체가 사라지는 것이 아니라, 이 컴포넌트의 코드가 번들에서 빠지는 것입니다.) 브라우저가 파싱하고 실행할 JS가 줄어들수록 초기 인터랙션은 빨라집니다. 그래서 "기본은 서버 컴포넌트"라는 설계가 성능 관점에서 합리적입니다.
// app/blog/page.tsx - 서버 컴포넌트 (기본값)
// 이 코드는 서버에서만 실행됩니다. 브라우저에 JS가 전송되지 않습니다.
import { marked } from 'marked'; // 서버 컴포넌트에서만 쓰이면 번들에 포함되지 않음
const posts = [
{
id: 1,
title: 'Next.js 시작하기',
body: '## 소개\nNext.js는 **React 기반** 풀스택 프레임워크입니다.',
},
{
id: 2,
title: 'React 서버 컴포넌트',
body: '## 개요\nRSC는 서버에서만 실행되는 *새로운 유형*의 컴포넌트입니다.',
},
];
export default function Blog() {
return (
<main>
<h1>블로그</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<div dangerouslySetInnerHTML={{ __html: marked(post.body) }} />
</article>
))}
</main>
);
}
데이터베이스, 파일 시스템, 환경 변수 등 서버 자원에 직접 접근할 수 있습니다. CSR에서는 브라우저 → API 라우트 → 데이터베이스로 이어지는 우회 경로가 필수였지만, 서버 컴포넌트는 데이터가 있는 곳에서 바로 실행되므로 중간 API 계층이 사라집니다. 민감 정보 역시 서버에만 머물고, 클라이언트에는 렌더링 결과만 전달되므로 노출 위험이 없습니다.
// 서버 컴포넌트에서는 이런 것들이 가능합니다
import { readFile } from 'fs/promises';
import { db } from '@/lib/database';
export default async function Page() {
// 파일 시스템 접근
const content = await readFile('./data/posts.json', 'utf-8');
// 데이터베이스 직접 쿼리
const users = await db.query('SELECT * FROM users');
// 환경 변수 (민감 정보도 안전)
const apiKey = process.env.SECRET_API_KEY; // 클라이언트에 노출 안 됨
return <div>{/* ... */}</div>;
}
서버 컴포넌트는 서버에서 렌더링될 때만 실행되며, 그 실행 결과가 브라우저로 전달됩니다. 즉 서버 컴포넌트의 코드는 브라우저에서 동작하지 않으므로, 브라우저에서 유지되는 상태(useState 등)나 클릭 같은 DOM 이벤트에 직접 반응할 수 없습니다. 사용자 인터랙션이 필요한 UI는 클라이언트 컴포넌트로 분리해야 하며, 이를 명시적으로 표시하기 위해 'use client' 지시어가 존재합니다.
// ❌ 서버 컴포넌트에서 이것들은 동작하지 않습니다
export default function ServerComponent() {
// ❌ useState - 클라이언트 상태
const [count, setCount] = useState(0);
// ❌ useEffect - 브라우저에서 실행되는 사이드이펙트
useEffect(() => { /* ... */ }, []);
// ❌ onClick - 브라우저 이벤트
return <button onClick={() => alert('클릭!')}>버튼</button>;
}
파일 최상단에 'use client' 지시어를 추가하면 클라이언트 컴포넌트가 됩니다. 이것은 기존 React 컴포넌트와 동일합니다.
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
클릭 횟수: {count}
</button>
);
}
App Router에서의 사고방식은 이렇습니다:
1. 새 컴포넌트를 만든다
2. 인터랙션이 필요한가? (클릭, 입력, 상태 등)
├── 아니오 → 서버 컴포넌트 (아무것도 안 해도 됨)
└── 예 → 'use client' 추가
기존 React에서는 컴포넌트가 기본적으로 브라우저에서 실행된다는 전제가 익숙해서, Next.js(App Router)의 방식이 처음엔 낯설 수 있습니다. 하지만 핵심은 간단합니다. App Router에서는 기본이 서버 컴포넌트이고, 상태 유지나 클릭 같은 인터랙션이 필요한 컴포넌트에만 파일 상단에 'use client'를 선언해 클라이언트 컴포넌트로 전환하면 됩니다.
Ch.1의 마지막 실습에서 우리는 이런 코드를 작성했습니다:
'use client'; // ← 레이아웃 전체가 클라이언트 컴포넌트
import { useState } from 'react';
export default function BlogLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const [isOpen, setIsOpen] = useState(true);
return (
<>
<aside>
<button onClick={() => setIsOpen(!isOpen)}>
카테고리 {isOpen ? '닫기' : '열기'}
</button>
{isOpen && (
<ul>
<li>React</li>
<li>Next.js</li>
<li>TypeScript</li>
</ul>
)}
</aside>
{children}
</>
);
}
이 코드의 문제는 무엇일까요? useState가 필요한 것은 카테고리 토글 버튼뿐인데, 레이아웃 전체에 'use client'를 붙였습니다. 이렇게 하면 레이아웃의 코드 전부가 클라이언트 번들에 포함됩니다.
| 서버 컴포넌트 | 클라이언트 컴포넌트 | |
|---|---|---|
| 지시어 | 없음 (기본값) | 'use client' |
| 실행 위치 | 서버 렌더링에서만 | 초기 요청 시 prerender + 브라우저(hydration/재렌더) |
| JS 번들 | 포함 안 됨 | 포함됨 |
| 상태 (useState) | ❌ | ✅ |
| 이벤트 (onClick) | ❌ | ✅ |
| useEffect | ❌ | ✅ |
| 서버 자원 접근 | ✅ (DB, 파일 등) | ❌ |
| async/await | ✅ (컴포넌트 자체가 async 가능) | ❌ (컴포넌트 자체는 불가) |
| 브라우저 API | ❌ | ✅ (window, localStorage 등) |
다음 세션에서는 'use client'가 만드는 경계가 무엇이고, 이 경계를 컴포넌트 트리에서 어디에 배치해야 하는지 학습합니다.