4. 경계 설계
원칙은 하나다 — 경계를 아래로, 작게
서버 컴포넌트가 기본값이라는 건 알겠다. 그런데 Provider는? 서드파티 라이브러리는? Context는? 이 장은 실무에서 반드시 부딪히는 다섯 가지 상황과 각각의 정석 패턴을 다룬다.
이 장의 내용은 12장(토큰)·16장(자산화)과 함께 이 덱에서 가장 중요한 세 장이다.
상황 1 — Provider가 필요하다
섹션 제목: “상황 1 — Provider가 필요하다”테마, 쿼리 클라이언트, 세션… Provider는 Context를 쓰므로 반드시 클라이언트 컴포넌트다. 그런데 앱 최상단에 필요하다. 그럼 앱 전체가 클라이언트가 되나?
아니다. children으로 받으면 그 내용은 여전히 서버에서 렌더된다.
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) { return <ThemeProvider>{children}</ThemeProvider>}// app/layout.tsx — 서버 컴포넌트로 유지!import { Providers } from './providers'
export default function RootLayout({ children }) { return ( <html> <body> <Providers>{children}</Providers> </body> </html> )}도넛 패턴
섹션 제목: “도넛 패턴”이 구조를 도넛(donut)이라고 부른다. 테두리만 클라이언트, 구멍은 서버.
flowchart TB
P["Providers — 클라이언트 (테두리)"] --> D["Dashboard — 서버 (구멍)"]
D --> R["RevenueChart — 서버"]
D --> K["DateRangePicker — 클라이언트"]
classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
class P,K warn
class D,R ok
상황 2 — 서드파티에 use client가 없다
섹션 제목: “상황 2 — 서드파티에 use client가 없다”// ❌ 서버 컴포넌트에서 바로 쓰면 터진다import { Carousel } from 'some-old-carousel'
export default function Page() { return <Carousel /> // 내부에서 useState를 쓰는데 'use client'가 없음}// ✅ 내 쪽에서 감싸서 경계를 만들어 준다'use client'export { Carousel } from 'some-old-carousel'한 줄짜리 re-export 파일이면 충분하다.
잘 관리되는 라이브러리는 이미 use client를 넣어 배포하지만, 오래된 것들은 직접 감싸야 한다.
상황 3 — 클라이언트에 데이터가 필요하다
섹션 제목: “상황 3 — 클라이언트에 데이터가 필요하다”// app/page.tsx (서버)export default async function Page() { const user = await getUser() return <ProfileEditor user={user} />}'use client'export function ProfileEditor({ user }: { user: User }) { const [name, setName] = useState(user.name) // ...}가장 단순하고 대부분의 경우 이걸로 충분하다.
단점: 서버가 getUser()를 끝낼 때까지 아무것도 못 보낸다.
// 서버 — await하지 않는다export default function Page() { const userPromise = getUser() return <ProfileEditor userPromise={userPromise} />}'use client'import { use } from 'react'
export function ProfileEditor({ userPromise }) { const user = use(userPromise) // Suspense와 함께 동작}껍데기를 먼저 보내고 데이터는 도착하는 대로 채운다. 느린 조회가 화면 전체를 막지 않는다. (5장의 Suspense와 짝이다)
상황 4 — Context를 쓰고 싶다
섹션 제목: “상황 4 — Context를 쓰고 싶다”결론부터: 서버 컴포넌트 트리에서는 Context를 쓸 수 없다. 그리고 대안이 대부분 더 낫다.
| 쓰고 싶었던 것 | 서버 우선 대안 |
|---|---|
| 현재 사용자 정보 | 서버에서 getUser() 호출. React.cache()로 중복 제거 |
| 테마 | CSS 변수 + 쿠키. Provider는 토글 버튼만 감싼다 |
| 필터·정렬 상태 | URL의 searchParams. 공유·뒤로가기가 공짜로 따라온다 |
| 폼 상태 | 폼 컴포넌트 안에 지역 상태로 |
| 장바구니 | 클라이언트 Provider가 맞다 (진짜 전역 클라이언트 상태) |
React.cache()로 감싼 함수는 한 요청 안에서 같은 인자에 대해 한 번만 실행된다.
서버 컴포넌트 여러 곳에서 getUser()를 불러도 DB 조회는 한 번이다. (5장)
상태를 URL로 올리기
섹션 제목: “상태를 URL로 올리기”의외로 많은 클라이언트 상태가 사실 URL에 있어야 할 것이다.
'use client'const [sort, setSort] = useState('new')const [page, setPage] = useState(1)
// 새로고침하면 날아감// 링크 공유 불가// 뒤로가기 동작 안 함// 목록 컴포넌트까지 클라이언트가 된다// app/posts/page.tsx (서버)export default async function Page({ searchParams }: PageProps<'/posts'>) { const { sort = 'new', page = '1' } = await searchParams const posts = await getPosts(sort, +page) return <PostList posts={posts} />}목록 컴포넌트가 서버 컴포넌트로 남는다.
정렬 버튼만 클라이언트로 만들어 router.push하면 된다.
상황 5 — 서버 전용 코드를 지키고 싶다
섹션 제목: “상황 5 — 서버 전용 코드를 지키고 싶다”가장 무서운 사고는 비밀 키가 브라우저 번들에 섞여 들어가는 것이다.
import 'server-only' // 클라이언트에서 import하면 빌드가 실패한다
export async function getSecretData() { const res = await fetch('https://api.internal', { headers: { Authorization: `Bearer ${process.env.API_SECRET}` }, }) return res.json()}// 반대 방향도 있다import 'client-only' // 서버에서 import하면 빌드가 실패한다export const getLocalDraft = () => localStorage.getItem('draft')결정 트리와 흔한 실수
섹션 제목: “결정 트리와 흔한 실수”flowchart TB
A{"이 컴포넌트에 상태·이벤트·<br/>브라우저 API 가 필요한가"} -->|아니오| B["서버 컴포넌트<br/>아무것도 안 붙인다 ✅"]
A -->|예| C{"그 부분만<br/>떼어낼 수 있는가"}
C -->|예| D["떼어낸 조각에만<br/>'use client'"]
C -->|아니오| E{"상태를 URL 로<br/>올릴 수 있는가"}
E -->|예| F["searchParams 로 옮기고<br/>서버 컴포넌트 유지 ✅"]
E -->|아니오| G["클라이언트 컴포넌트로 만들고<br/>데이터는 props 로 받는다"]
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class B,F ok
class D,G warn
class A,C,E mute
자주 하는 실수 모음
섹션 제목: “자주 하는 실수 모음”- 레이아웃 최상단에
use client— 앱 전체가 클라이언트가 된다. 가장 흔한 사고 - 함수를 props로 넘김 —
onSubmit={handleSubmit}은 경계를 못 넘는다 - ORM 객체를 그대로 전달 — 직렬화 실패. 평범한 객체로 변환할 것
use client파일에서async컴포넌트 — 지원되지 않는다error.tsx에use client를 안 붙임 — 반드시 클라이언트 컴포넌트여야 한다- 서버 컴포넌트에서
window접근 —typeof window분기는 냄새다. 경계를 잘못 그은 것
경계를 검토하는 순서
섹션 제목: “경계를 검토하는 순서”코드 리뷰나 성능 점검 때 이 순서로 본다.
-
use client가 붙은 파일을 전부 찾는다Terminal window grep -rl "use client" app components -
트리에서 얼마나 위에 있는지 본다
layout.tsx나 그에 가까운 곳에 있으면 1순위 검토 대상이다. -
왜 필요한지 한 줄로 말할 수 있는가
“상태가 있다”, “onClick이 있다”, “Provider다” — 셋 중 하나여야 한다. 말이 안 나오면 대부분 빼도 된다.
-
떼어낼 수 있는 조각이 있는가
토글 버튼 하나 때문에 화면 전체가 클라이언트인 경우가 흔하다.
-
상태가 사실 URL 상태는 아닌가
필터·정렬·페이지·탭이면 거의 항상 URL이 맞다.
-
번들 크기로 확인한다
Terminal window pnpm build # 라우트별 First Load JS를 본다 (8장)
4장 요약
섹션 제목: “4장 요약”- Provider는 도넛 패턴 —
children으로 받으면 안쪽은 서버로 남는다 use client없는 라이브러리는 한 줄 re-export로 감싼다- 클라이언트에 데이터가 필요하면 props 또는 Promise +
use() - Context를 쓰기 전에 URL(searchParams)로 올릴 수 있는지 먼저 본다
- **
server-only/client-only**로 경계 위반을 빌드 타임에 잡는다 - 원칙 하나: 경계를 아래로, 작게