Server Component와 Client Component
Server Component와 Client Component
요약: 기본은 서버 컴포넌트로 두고, 클릭·입력처럼 브라우저가 필요한 부분만 Client로 나눈다.
1. 왜 이 주제가 필요한가
App Router의 컴포넌트는 기본이 RSC(React Server Component, 서버에서만 실행되는 컴포넌트) 다.
서버에서 돌면 DB·파일·비밀키에 바로 접근할 수 있다. 그 코드는 브라우저 번들에 안 실려서 JS가 줄어든다.
모든 파일을 'use client'로 열면 이 이점이 사라진다. 반대로 서버 파일에 onClick을 붙이면 빌드가 실패한다. 경계를 아는 것이 App Router의 핵심이다.
2. 한 줄 규칙
데이터와 마크업은 서버에 둔다. useState·이벤트·브라우저 API가 필요한 잎만 Client로 만든다.
페이지 전체가 Client일 필요는 없다. Client가 Server를 import하면 그 트리도 클라이언트가 된다. 반대로 Server는 Client를 자식으로 둘 수 있다.
3. 예제
서버가 할 수 있는 것 / 없는 것
| Server Component | Client Component | |
|---|---|---|
| 기본 | app/ 파일, 지시어 없음 |
파일 상단 'use client' |
| 데이터 | async/await, DB, fs |
fetch·훅. 시크릿·fs 금지 |
| 상태 | 불가 | useState / useEffect |
| 이벤트 | 불가 | onClick 등 |
| 번들 | 클라이언트 JS에 안 실림 | 실림 |
Client는 잎에 가깝게
// app/posts/_components/counter.tsx
'use client';
import {useState} from 'react';
export function Counter() {
const [n, setN] = useState(0);
return (
<button type="button" onClick={() => setN((v) => v + 1)}>
{n}
</button>
);
}
'use client'는 그 파일과, 그 파일이 import하는 모듈을 클라이언트 경계로 만든다. 지시어는 파일 최상단, import보다 위다.
서버 페이지가 Client를 조합한다
// lib/posts.ts — 서버 전용
export type Post = {slug: string; title: string};
export async function getPosts(): Promise<Post[]> {
return [
{slug: 'hello', title: '첫 글'},
{slug: 'world', title: '둘째 글'},
];
}
// app/posts/_components/post-filters.tsx
'use client';
import {useState} from 'react';
type Props = {
titles: string[];
};
export function PostFilters({titles}: Props) {
const [q, setQ] = useState('');
const filtered = titles.filter((t) => t.includes(q));
return (
<div>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="제목 검색"
/>
<ul>
{filtered.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
</div>
);
}
// app/posts/page.tsx — Server Component
import {getPosts} from '@/lib/posts';
import {PostFilters} from './_components/post-filters';
import {Counter} from './_components/counter';
export default async function PostsPage() {
const posts = await getPosts();
const titles = posts.map((p) => p.title);
return (
<main>
<h1>글 목록</h1>
<PostFilters titles={titles} />
<Counter />
</main>
);
}
서버가 데이터를 가져와 직렬화 가능한 props(문자열, 숫자, 평범한 객체·배열)만 Client에 넘긴다.
넘기면 안 되는 것: 함수, Class 인스턴스, Date를 제외한 특수 객체. 함수를 props로 넘기면 직렬화 에러가 난다.
공유 모듈이 경계를 오염시킨다
Client 파일이 lib/format.ts를 import하면, 그 모듈은 클라이언트 번들에 들어간다. 그 lib/format.ts가 fs를 import하면 브라우저 빌드가 실패한다.
서버 전용 코드는 server-only 패키지로 표시할 수 있다.
import 'server-only';
export function loadSecret() {
return process.env.API_SECRET;
}
Client가 이 파일을 import하면 빌드 단계에서 막힌다.
큰 라이브러리
차트·에디터처럼 무거운 패키지는 그 UI를 쓰는 Client 파일에서만 import한다. 루트 레이아웃에 두면 모든 페이지 번들이 커진다.
브라우저 전용 라이브러리는 동적 import로 나눈다.
'use client';
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./chart'), {
ssr: false,
loading: () => <p>차트 로딩</p>,
});
ssr: false는 정말 브라우저에서만 돌아야 할 때만 쓴다. 남용하면 첫 HTML이 비어 검색·미리보기에 불리하다.
4. 흔한 실수
| 실수 | 결과 | 대안 |
|---|---|---|
page.tsx 전체에 'use client' |
서버 페칭·시크릿 불가 | 버튼·폼만 분리 |
Server에서 window / document |
빌드·렌더 에러 | Client로 이동 |
Client에서 fs / DB 클라이언트 |
번들 실패 또는 시크릿 유출 | 서버에서 읽고 props |
| 이벤트 핸들러를 Server에서 작성 | 컴파일 에러 | Client 자식에 위임 |
| Client가 Server 컴포넌트를 import | 그 파일도 클라이언트화 | Server가 Client를 자식으로 둔다 |
| 훅이 있는 유틸을 서버가 import | 경계가 무너진다 | 훅 파일에 'use client' |
정리
기본은 Server다. Client는 상호작용이 있는 잎이다.
서버가 데이터를 가져오고, 직렬화 가능한 값만 Client에 넘긴다. 페이지를 Client로 여는 것은 최후의 수단이다.
연습
- 서버 페이지에서 글 목록을 읽고, Client 검색 입력으로 제목을 거른다.
'use client'가 붙은 파일을 찾아, 상태·이벤트가 없는 파일은 지시어를 제거한다.npm run build출력에서 큰 Client 번들이 들어간 라우트를 확인하고, 무거운 import를 해당 페이지로 옮긴다.
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.