`'use client'`를 잘못 배치하면 Next.js 번들이 두 배로 불어난다 — App Router에서 자주 만나는 실수 4가지
Next.js App Router를 처음 도입하면 누구나 한 번쯤 비슷한 상황을 겪습니다. useState를 쓰려는데 "Server Component에서는 안 된다"는 에러가 뜨고, 당장 급하니까 파일 맨 위에 'use client' 한 줄 추가해서 넘어가는 거죠. 저도 초반에 정확히 그렇게 했습니다.
문제는 그 한 줄이 어디에 붙어 있느냐에 따라 결과가 극적으로 달라진다는 점입니다. layout.tsx에 'use client'를 잘못 선언했다가 경계를 올바르게 정리하고 나면 클라이언트 번들이 340kB에서 67kB로 줄어드는 식의 변화가 실제로 일어납니다. 하이드레이션 오류를 심어두거나, 심지어 서버 전용 코드를 클라이언트에 노출시키는 시한폭탄이 될 수도 있고요.
이 글은 Next.js App Router를 막 시작했거나, 쓰고는 있는데 'use client'가 정확히 어떻게 동작하는지 아직 좀 불안한 분들을 위해 씁니다. 실무에서 가장 자주 만나는 4가지 실수 패턴이 실제로 어떤 결과를 만들어 내는지, 각각이 왜 그 결과로 이어지는지 살펴보겠습니다.
핵심 개념
'use client'는 경계 선언이지 "클라이언트 컴포넌트 마커"가 아닙니다
많은 분들이 'use client'를 "이 컴포넌트는 클라이언트에서 실행된다"는 표식 정도로 이해합니다. 실제로는 조금 다릅니다. 이 지시문은 서버 컴포넌트 모듈 그래프와 클라이언트 컴포넌트 모듈 그래프 사이의 경계를 선언하는 역할을 합니다. 파일 하나에 선언하는 순간, 그 파일에서 import하는 모든 모듈이 클라이언트 번들에 통째로 딸려 들어옵니다.
핵심 원칙:
'use client'는 클라이언트 컴포넌트마다 붙이는 게 아니라, 서버 컴포넌트 트리에서 클라이언트 컴포넌트로 진입하는 최초 진입점(entry point) 에만 선언하면 됩니다. 그 아래로 import되는 컴포넌트들은 자동으로 클라이언트 컴포넌트가 됩니다.
아래 두 가지 규칙도 함께 기억해두시면 좋습니다.
- 서버 컴포넌트는 클라이언트 컴포넌트를 import해서 렌더링할 수 있습니다. 제약은 반대 방향입니다. 클라이언트 컴포넌트가 서버 컴포넌트를 직접 import해 렌더링하는 것은 불가능합니다. 다만
childrenprop 등 컴포지션 패턴으로 전달하는 건 가능합니다. - 서버→클라이언트 경계를 넘는 props는 반드시 직렬화(serialize) 가능해야 합니다. 일반 함수, 클래스 인스턴스,
Date객체 등은 넘길 수 없습니다.
경계가 잘못되면 어디가 아픈가
| 영역 | 결과 |
|---|---|
| 번들 크기 | 불필요한 정적 컨텐츠까지 클라이언트 JS에 포함 |
| 하이드레이션 | 서버/클라이언트 렌더 결과 불일치로 오류 발생 |
| Streaming | 서버가 준비된 부분부터 HTML을 스트림으로 전송하는 점진적 렌더링이 비활성화됨 |
| 보안 | DB 접근 코드·API 키 등이 클라이언트에 노출될 위험 |
| SEO | 서버 렌더링 이점 감소로 크롤러 접근성 저하 |
실전 적용
예시 1: layout.tsx 최상단에 'use client' 선언
가장 임팩트가 큰 실수입니다. 이 상황이 생기는 건 대개 이런 식입니다. 현재 경로를 감지해서 네비게이션에 active 스타일을 주고 싶은데, usePathname을 레이아웃 파일에서 바로 쓰고 싶으니까 'use client'를 붙여버리는 거죠. 에러는 사라지고, 동작도 됩니다. 하지만 앱 전체가 클라이언트 번들에 포함됩니다.
// ❌ 절대 하지 말아야 할 패턴
// app/layout.tsx
'use client'
import { usePathname } from 'next/navigation'
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<html>
<body>
<nav>
<a href="/" className={pathname === '/' ? 'active' : ''}>홈</a>
<a href="/about" className={pathname === '/about' ? 'active' : ''}>소개</a>
</nav>
{children}
</body>
</html>
)
}// ✅ 올바른 패턴 — 상호작용이 필요한 컴포넌트만 분리
// components/NavLinks.tsx
'use client'
import { usePathname } from 'next/navigation'
export function NavLinks() {
const pathname = usePathname()
return (
<nav>
<a href="/" className={pathname === '/' ? 'active' : ''}>홈</a>
<a href="/about" className={pathname === '/about' ? 'active' : ''}>소개</a>
</nav>
)
}
// app/layout.tsx — 'use client' 없이 서버 컴포넌트로 유지
import { NavLinks } from '@/components/NavLinks'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<NavLinks />
{children}
</body>
</html>
)
}| 항목 | ❌ layout에 선언 | ✅ 리프 컴포넌트에 선언 |
|---|---|---|
| 클라이언트 번들 크기 | 앱 전체 포함 | 해당 컴포넌트만 포함 |
| 서버 렌더링 범위 | 없음 | 대부분의 트리 |
| SEO | 크게 불리 | 유리 |
| TTFB (첫 바이트 응답 시간) | 느림 | 빠름 |
예시 2: Context Provider가 하이드레이션 불일치를 만드는 경우
이 패턴은 "잘 작동하는 것처럼 보이다가" 특정 조건에서 터집니다. Theme 기반 Context가 대표적인 사례입니다.
// providers.tsx
'use client'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider> {/* localStorage 기반 테마 읽기 */}
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}ThemeProvider 내부에서 localStorage를 읽어 테마를 결정한다고 가정하면, 서버는 localStorage에 접근할 수 없어 기본값(light)으로 렌더링합니다. 브라우저가 localStorage를 읽으면 dark 테마가 되고, 결과가 달라서 React가 하이드레이션 오류를 던집니다.
하이드레이션 불일치(Hydration Mismatch): 서버가 생성한 HTML과 클라이언트에서 React가 그린 결과가 다를 때 발생하는 오류입니다.
window,localStorage,document등 서버에서 접근 불가한 API를 초기 렌더링 시점에 사용하면 흔히 나타납니다.
// ✅ useEffect로 클라이언트 마운트 이후에 테마 적용
'use client'
import { useEffect, useState } from 'react'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light') // 서버와 동일한 기본값으로 시작
useEffect(() => {
const saved = localStorage.getItem('theme') ?? 'light'
setTheme(saved)
}, [])
return <div data-theme={theme}>{children}</div>
}이 방법으로 하이드레이션 오류는 해결됩니다. 다만 한 가지 트레이드오프가 있는데, 서버는 light로 렌더링하고 마운트 직후 dark로 전환되면 화면이 한 번 깜빡이는 FOUC(Flash of Unstyled Content) 현상이 나타날 수 있습니다. 깜빡임까지 잡아야 한다면 suppressHydrationWarning 속성과 HTML class 직접 조작 패턴을 함께 검토해보시면 좋습니다.
예시 3: 직렬화 불가능한 props를 경계 너머로 전달
서버 컴포넌트에서 클라이언트 컴포넌트로 함수를 prop으로 넘기려다 런타임 오류를 만나는 상황입니다. 처음엔 "왜 에러가 나지?" 싶어서 꽤 당황스럽습니다. 저도 이 오류 메시지를 처음 봤을 때 무슨 의미인지 찾는 데 한참 걸렸습니다.
// ❌ 런타임 오류 발생
// app/page.tsx (서버 컴포넌트)
import { ClientButton } from './ClientButton'
export default function ServerPage() {
const handleClick = () => console.log('clicked') // 함수는 직렬화 불가
return <ClientButton onClick={handleClick} />
}서버→클라이언트 경계를 넘는 props는 JSON으로 직렬화되어 전달됩니다. 일반 함수는 직렬화할 방법이 없으니 오류가 납니다. Date 객체가 실패하는 이유도 같은 맥락인데, plain object가 아닌 클래스 인스턴스이기 때문에 직렬화 과정에서 정보가 손실됩니다. Date는 string으로 변환해서 넘긴 뒤 클라이언트에서 new Date()로 복원하는 패턴을 써볼 수 있습니다.
함수를 넘겨야 할 때는 Server Actions('use server')를 활용합니다. Server Action은 빌드 타임에 실제 함수가 아닌 HTTP 엔드포인트 참조로 변환됩니다. 클라이언트 입장에서는 "이 주소로 요청하면 됩니다"라는 정보만 받는 셈이라 직렬화가 가능해집니다.
// ✅ Server Actions로 함수를 안전하게 전달
// app/actions.ts
'use server'
export async function handleClick() {
// 여기는 서버에서 실행됩니다 — DB 작업, 외부 API 호출 등 가능
console.log('서버에서 실행')
}
// app/page.tsx (서버 컴포넌트)
import { ClientButton } from './ClientButton'
import { handleClick } from './actions'
export default function ServerPage() {
return <ClientButton onClick={handleClick} /> // Server Action은 직렬화 가능
}| 타입 | 경계 통과 가능 여부 |
|---|---|
string, number, boolean |
✅ |
| 일반 객체, 배열 | ✅ |
Date (클래스 인스턴스) |
❌ → string으로 변환 후 전달 |
Map, Set |
❌ |
| 일반 함수 | ❌ |
Server Action ('use server') |
✅ |
| 클래스 인스턴스 | ❌ |
예시 4: 큰 컴포넌트 하나에 선언해서 하위 트리 전체를 클라이언트로 만들기
실무에서 가장 자주 만나는 패턴이고, 발견하기도 가장 어렵습니다. 솔직히 "버튼 하나 때문에 굳이 파일을 분리해야 해?"라는 생각이 드는 게 자연스럽습니다. 하지만 그 버튼과 함께 딸려 들어가는 ProductInfo, ProductImage, Reviews의 크기가 생각보다 크다는 게 문제입니다.
// ❌ AddToCartButton 하나 때문에 전체 페이지가 클라이언트화
// app/product/[id]/page.tsx
'use client'
export default function ProductPage({ product }) {
return (
<div>
<ProductInfo product={product} /> {/* 정적 — 클라이언트일 필요 없음 */}
<ProductImage src={product.image} /> {/* 정적 — 클라이언트일 필요 없음 */}
<AddToCartButton id={product.id} /> {/* 여기만 클라이언트가 필요 */}
<Reviews reviews={product.reviews} /> {/* 정적 — 클라이언트일 필요 없음 */}
</div>
)
}// ✅ 클라이언트가 필요한 부분만 분리
// components/AddToCartButton.tsx
'use client'
import { useState } from 'react'
export function AddToCartButton({ id }: { id: string }) {
const [added, setAdded] = useState(false)
return (
<button onClick={() => setAdded(true)}>
{added ? '담겼어요' : '장바구니 담기'}
</button>
)
}
// app/product/[id]/page.tsx (서버 컴포넌트 — 'use client' 없음)
import { AddToCartButton } from '@/components/AddToCartButton'
export default function ProductPage({ product }) {
return (
<div>
<ProductInfo product={product} />
<ProductImage src={product.image} />
<AddToCartButton id={product.id} /> {/* 클라이언트 아일랜드 */}
<Reviews reviews={product.reviews} />
</div>
)
}이 패턴을 "아일랜드 아키텍처(Islands Architecture)"라고 부르기도 합니다. 정적인 바다 위에 인터랙션이 필요한 부분만 클라이언트 아일랜드로 띄우는 개념입니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 번들 크기 최소화 | 정적 컨텐츠는 JS 없이 HTML로만 전달 |
| TTFB 개선 | 서버 렌더링 범위가 넓어질수록 첫 바이트 응답이 빨라짐 |
| Streaming 활성화 | <Suspense> 경계와 조합해 점진적 렌더링 가능 |
| SEO 유리 | 크롤러가 JS 실행 없이 컨텐츠 접근 가능 |
| 보안 강화 | 서버 전용 코드가 클라이언트로 유출되지 않음 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 컴포넌트 분리 작업 | 상호작용 요소마다 파일 분리 필요 | 팀 컨벤션으로 *Client.tsx 네이밍 규칙 정의 |
| 초기 설계 복잡도 | 트리 구조 설계에 더 많은 고민 필요 | 페이지 단위로 인터랙션 목록 먼저 정리 |
| 직렬화 제약 학습 | props 타입 제한에 익숙해지는 데 시간 필요 | TypeScript 플러그인 경고를 가이드로 활용 |
| FOUC 가능성 | 클라이언트 마운트 후 테마 전환 시 깜빡임 | suppressHydrationWarning + HTML class 직접 조작 패턴 검토 |
RSC Flight 페이로드: 서버 컴포넌트가 클라이언트로 전달하는 직렬화된 데이터 스트림입니다. 경계 설계가 잘못되면 이 페이로드가 비대해져 하이드레이션 지연으로 이어집니다.
실무에서 가장 흔한 실수
useState에러 나면 파일 상단에'use client'추가 — 에러는 해결되지만 해당 파일이 경계 진입점이 되어 하위 트리 전체가 클라이언트화됩니다. 인터랙션이 필요한 부분을 별도 컴포넌트로 분리하는 게 올바른 접근입니다.- Context Provider에서 클라이언트 API를 초기 렌더링에 사용 —
localStorage,window등 서버에서 접근 불가한 API를useEffect바깥에서 읽으면 하이드레이션 불일치가 발생합니다. - 페이지 컴포넌트에
'use client'선언 — 인터랙티브한 요소가 일부여도 페이지 전체가 클라이언트화됩니다. 인터랙션이 필요한 컴포넌트만 분리하는 것이 좋습니다.
마치며
'use client' 경계를 어디에 두느냐는 생각보다 훨씬 큰 차이를 만들어 냅니다. 인터랙션이 필요한 가장 작은 단위에 가깝게 선언할수록 번들 크기·하이드레이션·Streaming 세 가지를 동시에 잡을 수 있습니다.
지금 진행 중인 프로젝트라면 아래 순서로 확인해보시면 좋습니다.
@next/bundle-analyzer로 현재 클라이언트 번들 구성을 시각화합니다.pnpm add -D @next/bundle-analyzer후next.config.js에 연결하면 어떤 모듈이 번들에 포함됐는지 한눈에 확인할 수 있습니다. 예상치 못하게 큰 영역이 보인다면 다음 단계가 의미 있습니다.grep으로'use client'선언 위치를 목록화합니다.grep -r "'use client'" ./app --include="*.tsx"명령을 실행해서layout.tsx나 페이지 레벨 파일에 선언된 게 있는지 확인할 수 있습니다. 그런 파일이 있다면 예시 1과 예시 4처럼 분리할 후보입니다.- 서버 전용 모듈에
server-only패키지를 추가합니다.pnpm add server-only후 DB 접근이나 환경변수를 다루는 파일 상단에import 'server-only'를 추가하면, 해당 모듈이 클라이언트에 import될 경우 빌드 타임에 오류가 발생해 유출을 사전에 차단할 수 있습니다.
참고 자료
- Directives: use client | Next.js 공식 문서
- Getting Started: Server and Client Components | Next.js
- 'use client' directive – React 공식 문서
- 6 React Server Component performance pitfalls in Next.js – LogRocket Blog
- Server and Client Composition Patterns – Next.js 한글 문서
- Props must be serializable for components in "use client" file – GitHub Discussion
- Intro to Performance of React Server Components – Web Performance Calendar
- Mastering 'use client' in Next.js – Medium