콘텐츠로 이동
Study Note프론트엔드

4. 경계 설계

원칙은 하나다 — 경계를 아래로, 작게

서버 컴포넌트가 기본값이라는 건 알겠다. 그런데 Provider는? 서드파티 라이브러리는? Context는? 이 장은 실무에서 반드시 부딪히는 다섯 가지 상황과 각각의 정석 패턴을 다룬다.

이 장의 내용은 12장(토큰)·16장(자산화)과 함께 이 덱에서 가장 중요한 세 장이다.

테마, 쿼리 클라이언트, 세션… Provider는 Context를 쓰므로 반드시 클라이언트 컴포넌트다. 그런데 앱 최상단에 필요하다. 그럼 앱 전체가 클라이언트가 되나?

아니다. children으로 받으면 그 내용은 여전히 서버에서 렌더된다.

app/providers.tsx
'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)이라고 부른다. 테두리만 클라이언트, 구멍은 서버.

클라이언트 Providers가 테두리가 되고 그 안에 서버 컴포넌트가 구멍처럼 들어가는 트리

상황 2 — 서드파티에 use client가 없다

섹션 제목: “상황 2 — 서드파티에 use client가 없다”
// ❌ 서버 컴포넌트에서 바로 쓰면 터진다
import { Carousel } from 'some-old-carousel'
export default function Page() {
return <Carousel /> // 내부에서 useState를 쓰는데 'use client'가 없음
}
components/carousel.tsx
// ✅ 내 쪽에서 감싸서 경계를 만들어 준다
'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} />
}
components/profile-editor.tsx
'use client'
export function ProfileEditor({ user }: { user: User }) {
const [name, setName] = useState(user.name)
// ...
}

가장 단순하고 대부분의 경우 이걸로 충분하다. 단점: 서버가 getUser()를 끝낼 때까지 아무것도 못 보낸다.

결론부터: 서버 컴포넌트 트리에서는 Context를 쓸 수 없다. 그리고 대안이 대부분 더 낫다.

쓰고 싶었던 것서버 우선 대안
현재 사용자 정보서버에서 getUser() 호출. React.cache()로 중복 제거
테마CSS 변수 + 쿠키. Provider는 토글 버튼만 감싼다
필터·정렬 상태URL의 searchParams. 공유·뒤로가기가 공짜로 따라온다
폼 상태폼 컴포넌트 안에 지역 상태로
장바구니클라이언트 Provider가 맞다 (진짜 전역 클라이언트 상태)

React.cache()로 감싼 함수는 한 요청 안에서 같은 인자에 대해 한 번만 실행된다. 서버 컴포넌트 여러 곳에서 getUser()를 불러도 DB 조회는 한 번이다. (5장)

의외로 많은 클라이언트 상태가 사실 URL에 있어야 할 것이다.

'use client'
const [sort, setSort] = useState('new')
const [page, setPage] = useState(1)
// 새로고침하면 날아감
// 링크 공유 불가
// 뒤로가기 동작 안 함
// 목록 컴포넌트까지 클라이언트가 된다

상황 5 — 서버 전용 코드를 지키고 싶다

섹션 제목: “상황 5 — 서버 전용 코드를 지키고 싶다”

가장 무서운 사고는 비밀 키가 브라우저 번들에 섞여 들어가는 것이다.

lib/data.ts
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()
}
lib/browser-utils.ts
// 반대 방향도 있다
import 'client-only' // 서버에서 import하면 빌드가 실패한다
export const getLocalDraft = () => localStorage.getItem('draft')
상태·이벤트가 필요한지부터 시작해 'use client'를 어디에 붙일지 정하는 결정 트리
  • 레이아웃 최상단에 use client — 앱 전체가 클라이언트가 된다. 가장 흔한 사고
  • 함수를 props로 넘김 — onSubmit={handleSubmit}은 경계를 못 넘는다
  • ORM 객체를 그대로 전달 — 직렬화 실패. 평범한 객체로 변환할 것
  • use client 파일에서 async 컴포넌트 — 지원되지 않는다
  • error.tsx에 use client를 안 붙임 — 반드시 클라이언트 컴포넌트여야 한다
  • 서버 컴포넌트에서 window 접근 — typeof window 분기는 냄새다. 경계를 잘못 그은 것

코드 리뷰나 성능 점검 때 이 순서로 본다.

  1. use client가 붙은 파일을 전부 찾는다

    터미널 창
    grep -rl "use client" app components
  2. 트리에서 얼마나 위에 있는지 본다

    layout.tsx나 그에 가까운 곳에 있으면 1순위 검토 대상이다.

  3. 왜 필요한지 한 줄로 말할 수 있는가

    “상태가 있다”, “onClick이 있다”, “Provider다” — 셋 중 하나여야 한다. 말이 안 나오면 대부분 빼도 된다.

  4. 떼어낼 수 있는 조각이 있는가

    토글 버튼 하나 때문에 화면 전체가 클라이언트인 경우가 흔하다.

  5. 상태가 사실 URL 상태는 아닌가

    필터·정렬·페이지·탭이면 거의 항상 URL이 맞다.

  6. 번들 크기로 확인한다

    터미널 창
    pnpm build # 라우트별 First Load JS를 본다 (8장)
  • Provider는 도넛 패턴 — children으로 받으면 안쪽은 서버로 남는다
  • use client 없는 라이브러리는 한 줄 re-export로 감싼다
  • 클라이언트에 데이터가 필요하면 props 또는 Promise + use()
  • Context를 쓰기 전에 URL(searchParams)로 올릴 수 있는지 먼저 본다
  • server-only / client-only로 경계 위반을 빌드 타임에 잡는다
  • 원칙 하나: 경계를 아래로, 작게