서버/클라이언트 컴포넌트 합성 패턴: use client 경계를 어떻게 좁힐까
async 서버 컴포넌트 글에서 'use client'는 "이 파일은 클라이언트에서 돈다"가 아니라 경계 선언에 가깝다고 짚었습니다. 이 글은 그 한 줄을 잘못 두면 어떤 일이 벌어지는지, 그리고 경계를 좁게 유지하려면 어떤 합성 패턴을 써야 하는지를 한 편으로 풀어 봅니다.
처음 App Router를 만지면 흔히 이런 순서로 막힙니다. 상호작용이 필요해서 컴포넌트에 'use client'를 붙였더니, 그 아래 있던 서버 컴포넌트들이 전부 클라이언트로 끌려가고, 서버 컴포넌트의 이점이 통째로 사라집니다. 이게 왜 생기는지부터 보겠습니다.
한눈에 보면
'use client'는 파일 하나가 아니라 거기서부터 시작되는 경계입니다. 그 모듈이 import하는 것들도 클라이언트로 딸려 들어갑니다.- 그래서 클라이언트 컴포넌트는 서버 컴포넌트를 직접 import할 수 없습니다. import하는 순간 클라이언트로 바뀝니다.
- 해법은 import 대신 합성입니다. 서버 컴포넌트를
children이나 props로 클라이언트 컴포넌트에 끼워 넣습니다(이른바 도넛 패턴). - Context Provider는 얇은 클라이언트 래퍼로 만들고, 그 안의
children은 서버 컴포넌트로 유지합니다. - 경계는 가능한 한 잎사귀 쪽으로 미룹니다. 상호작용이 필요한 작은 부분만 클라이언트로 떼어 냅니다.
- props로 넘기는 값은 직렬화가 가능해야 합니다. 함수나 클래스 인스턴스는 경계를 넘지 못합니다.
'use client'는 파일이 아니라 경계다
'use client'를 파일 맨 위에 두면, 그 파일과 그 파일이 import하는 모든 모듈이 클라이언트 번들에 포함됩니다. 한 번 클라이언트 경계가 그어지면, 그 아래로 이어지는 import 체인은 자동으로 클라이언트 쪽으로 분류됩니다.
여기서 가장 자주 하는 실수가 나옵니다. 페이지 최상단에 'use client'를 붙이는 겁니다.
'use client'; // ❌ 이 한 줄로 페이지 트리 전체가 클라이언트가 됩니다
import { Chart } from './chart';
import { HeavyTable } from './heavy-table';
export default function Page() {
// 상호작용은 사실 Chart 하나뿐인데...
}상호작용이 필요한 건 차트 하나뿐인데, 경계를 페이지 꼭대기에 그어 버리면 그 아래 무거운 테이블까지 전부 클라이언트 번들로 넘어갑니다. 서버에서 처리할 수 있었던 일을 브라우저로 떠넘기는 셈입니다.
클라이언트 컴포넌트는 서버 컴포넌트를 import할 수 없다
조금 더 깊이 들어가면, 클라이언트 컴포넌트 안에서 서버 컴포넌트를 import해서 쓰는 것 자체가 막혀 있습니다.
'use client';
import { ServerInfo } from './server-info'; // 서버 컴포넌트
export function Panel() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen((v) => !v)}>토글</button>
{open && <ServerInfo />} {/* 의도대로 동작하지 않음 */}
</div>
);
}이유는 실행 시점에 있습니다. 클라이언트 번들은 빌드 시점에 만들어지는데, 그 시점에는 서버 컴포넌트가 서버에서 실제로 실행된 결과가 없습니다. 클라이언트 모듈이 서버 컴포넌트를 import하면, React는 그걸 평범한 클라이언트 컴포넌트로 취급할 수밖에 없습니다. 서버에서 await로 데이터를 읽던 컴포넌트라면 그대로 깨집니다.
정리하면 방향이 정해져 있습니다. 서버 컴포넌트는 클라이언트 컴포넌트를 렌더링할 수 있지만, 클라이언트 컴포넌트는 서버 컴포넌트를 import해서 렌더링할 수 없습니다. 그럼 클라이언트 컴포넌트 안에 서버 콘텐츠를 넣고 싶을 때는 어떻게 할까요?
해법: children으로 서버 컴포넌트를 끼워 넣기
핵심은 import가 아니라 합성입니다. 클라이언트 컴포넌트가 서버 컴포넌트를 직접 들고 오는 대신, 부모인 서버 컴포넌트가 서버 콘텐츠를 만들어 children(또는 props)으로 클라이언트 컴포넌트에 넘겨줍니다.
// client-panel.tsx — 상호작용만 담당하는 클라이언트 컴포넌트
'use client';
import { useState } from 'react';
export function ClientPanel({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen((v) => !v)}>토글</button>
{open && children} {/* 무엇이 들어오는지는 알 필요가 없음 */}
</div>
);
}// page.tsx — 서버 컴포넌트가 합성을 담당
import { ClientPanel } from './client-panel';
import { ServerInfo } from './server-info';
export default function Page() {
return (
<ClientPanel>
<ServerInfo /> {/* 서버에서 렌더된 결과가 children으로 들어감 */}
</ClientPanel>
);
}ClientPanel은 children이 서버 컴포넌트인지 아닌지 신경 쓰지 않습니다. 그저 받은 노드를 자리에 끼워 넣을 뿐입니다. ServerInfo는 서버에서 먼저 렌더되어 그 결과가 props로 전달되므로, 클라이언트 경계 안쪽에 있어도 서버 컴포넌트로 남습니다. 상호작용을 감싸는 클라이언트 껍데기 안에 서버 콘텐츠가 들어앉은 모양이라, 흔히 도넛(donut) 패턴이라고 부릅니다.
flowchart TD
A["Page (서버)"] --> B["ClientPanel (클라이언트)"]
A --> C["ServerInfo (서버)"]
C -. children으로 주입 .-> B
B --> D["useState 토글 등 상호작용"]Context Provider도 같은 패턴이다
'use client'가 필요한 대표적인 경우가 Context Provider입니다. 테마, 상태 관리 스토어 같은 Provider는 내부적으로 상태와 effect를 쓰기 때문에 클라이언트 컴포넌트여야 합니다. 그렇다고 앱 전체를 클라이언트로 만들 필요는 없습니다. Provider를 얇은 클라이언트 래퍼로 떼어 내고, 그 안의 children은 서버로 유지하면 됩니다.
// providers.tsx — 얇은 클라이언트 래퍼
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}// layout.tsx — 서버 컴포넌트인 루트 레이아웃
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Providers만 클라이언트일 뿐, 그 안에 들어오는 페이지 트리는 여전히 서버 컴포넌트입니다. Provider가 클라이언트라고 해서 그 아래가 전부 클라이언트가 되지는 않습니다. 경계는 Provider 컴포넌트 자신에서 끝납니다.
props 직렬화라는 또 하나의 경계
서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때는 그 값이 직렬화 가능해야 합니다. 서버에서 만든 값이 네트워크를 건너 클라이언트로 전달되기 때문입니다. 문자열, 숫자, 배열, 평범한 객체는 괜찮지만 함수, 클래스 인스턴스, Symbol 같은 값은 경계를 넘지 못합니다.
// ❌ 함수는 직렬화되지 않아 props로 넘길 수 없음
<ClientButton onClick={() => db.delete(id)} />이벤트 핸들러처럼 함수가 필요하면 그 로직은 클라이언트 컴포넌트 쪽에 두거나, 서버에서 실행해야 하는 작업이라면 Server Action으로 분리합니다. Server Action은 'use server'로 표시되어 참조만 클라이언트에 전달되므로, 일반 함수와 달리 경계를 넘을 수 있습니다.
자주 하는 실수
마지막으로 리뷰에서 자주 짚게 되는 것들을 모았습니다.
- 페이지나 레이아웃 최상단에
'use client'를 붙이는 것. 경계가 너무 위로 올라가 트리 전체가 클라이언트가 됩니다. 상호작용이 있는 잎사귀로 내려야 합니다. - 상호작용 한 조각 때문에 큰 컴포넌트를 통째로 클라이언트로 만드는 것. 그 조각만 작은 클라이언트 컴포넌트로 떼어 내고 나머지는 서버로 둡니다.
- 서버에서 읽은 데이터를 굳이 클라이언트로 내려보내 다시 가공하는 것. 서버에서 끝낼 수 있는 가공은 서버에서 끝내고, 결과만 넘깁니다.
- 클라이언트 컴포넌트에 서버 컴포넌트를 import하려다 막힌 뒤, 서버 컴포넌트를 클라이언트로 바꿔 버리는 것. 그럴 땐 import가 아니라
children합성으로 풀어야 합니다.
정리하면
서버/클라이언트 합성의 핵심은 "어디에 경계를 긋느냐"입니다.
'use client'는 그 지점부터 시작되는 경계이고, import 체인을 따라 아래로 퍼진다.- 클라이언트 컴포넌트는 서버 컴포넌트를 import할 수 없지만,
children/props로 끼워 받을 수는 있다. - Provider처럼 클라이언트가 꼭 필요한 부분은 얇게 감싸고, 그 안의 트리는 서버로 유지한다.
- 함수는 직렬화 경계를 넘지 못하므로, 서버 작업은 Server Action으로 분리한다.
결국 잘 만든 App Router 트리는 서버가 큰 틀을 그리고, 상호작용이 필요한 잎사귀에만 클라이언트 섬이 박혀 있는 모양에 가깝습니다. 경계를 의식하면서 합성하는 감각이, 이 모델을 다루는 가장 실용적인 도구라고 느낍니다.
서버/클라이언트 컴포넌트 모델은 React·프레임워크 버전에 따라 세부가 달라질 수 있습니다. 이 글은 React 19, Next.js App Router 기준입니다.
