- Published on
Next.js App Router use client 남발 번들 진단·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트가 기본인 Next.js App Router에서 "use client" 는 강력하지만, 남발하면 클라이언트 번들이 눈덩이처럼 커집니다. 문제는 단순히 JS 용량만이 아닙니다. "use client" 가 붙은 파일이 import 하는 트리 전체가 클라이언트로 전파되면서, 원래 서버에서만 처리해도 되던 데이터 접근, 유틸, UI 라이브러리까지 브라우저로 내려가고 hydration 비용이 증가합니다.
이 글은 (1) 번들이 커졌는지 빠르게 확인하고 (2) 어떤 파일/의존성이 원인인지 찾고 (3) "use client" 경계를 최소화하는 구조로 되돌리는 방법을 다룹니다.
참고로 번들 증가로 인해 렌더링 지표가 흔들리면 CLS 같은 사용자 체감 문제로도 이어질 수 있으니, 프런트 성능 지표도 함께 보길 권합니다. 관련해서는 Chrome CLS 급증? Layout Shift 원인 7가지도 같이 보면 좋습니다.
왜 "use client" 남발이 번들을 키우나
1) 클라이언트 전파 규칙을 과소평가하기
App Router에서 컴포넌트는 기본이 Server Component입니다. 그런데 어떤 파일 상단에 "use client" 를 붙이면 그 파일은 Client Component가 되고, 그 파일이 import 하는 하위 컴포넌트는 원칙적으로 클라이언트에서 실행 가능해야 합니다. 즉, 한 번 클라이언트 경계 안으로 들어오면 그 아래는 서버 전용 API를 못 쓰고(예: headers(), cookies() 등), 대신 번들에 포함되기 쉬워집니다.
다음처럼 “상위 레이아웃이 편해서” "use client" 를 붙이는 순간, 사실상 페이지 대부분이 클라이언트로 내려갈 수 있습니다.
// app/(shop)/layout.tsx
"use client";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import ProductList from "@/components/ProductList";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<Header />
<main>{children}</main>
<Footer />
<ProductList />
</div>
);
}
여기서 Header 만 상호작용이 필요해도, layout.tsx 를 클라이언트로 만들면 Footer, ProductList 까지 클라이언트 번들 후보가 됩니다.
2) “조금만 인터랙션” 때문에 전체를 클라이언트로 만드는 실수
대표적인 패턴이 “토글/검색 입력/모달” 같은 작은 상태 하나 때문에 페이지 전체를 Client Component로 바꾸는 경우입니다. 해결은 보통 간단합니다. 인터랙션이 필요한 부분만 작은 Client Component로 분리하고, 데이터 패칭/마크업은 Server Component로 유지합니다.
3) 무거운 의존성(차트, 에디터, 날짜 라이브러리)이 함께 딸려오는 문제
"use client" 가 붙은 컴포넌트가 차트 라이브러리, 에디터, i18n 런타임, UI 프레임워크의 특정 모듈 등을 import 하면, 그 순간 번들이 급증합니다. 특히 “서버에서 렌더링해도 되는 정적 마크업”까지 클라이언트로 내려가면 낭비가 커집니다.
번들 커짐을 진단하는 방법
진단은 “지표 확인”과 “원인 추적” 두 단계로 나누면 빠릅니다.
1) Next.js 번들 분석기 적용
가장 먼저 번들 분석을 켜서 어떤 chunk가 커졌는지 봅니다.
npm i -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({
experimental: {
appDir: true,
},
});
ANALYZE=true npm run build
여기서 확인할 포인트는 다음입니다.
- 특정 route의 client chunk가 갑자기 커졌는지
- 공통 vendor chunk에 무거운 라이브러리가 들어갔는지
- 생각보다 많은 컴포넌트가 client 번들로 묶였는지
2) 빌드 출력과 route별 JS 크기 확인
next build 출력에서 route별 “First Load JS” 를 비교하세요. PR 전후로 수치가 튀면 대개 "use client" 전파 또는 의존성 유입입니다.
추가로, CI에서 번들 크기 회귀를 막고 싶다면 빌드 로그를 파싱하거나 번들 분석 결과를 아티팩트로 저장해 diff 하는 방식도 유효합니다.
3) “어떤 파일이 client 경계를 만들었나” 역추적
원인 추적은 보통 아래 순서가 가장 빠릅니다.
- 문제가 된 route에서 가장 상위 컴포넌트(페이지/레이아웃/템플릿)부터
"use client"유무 확인 "use client"가 붙은 파일이 import 하는 목록을 따라가며 무거운 의존성 탐색- “정말 클라이언트여야 하는가”를 각 import 단위로 판정
경험적으로는 레이아웃, 페이지, 큰 섹션 컴포넌트에 붙은 "use client" 가 1순위 용의자입니다.
해결 전략: Client 경계를 최소화하는 설계 패턴
1) 상호작용을 작은 Client Island로 격리
서버에서 데이터 패칭과 마크업을 만들고, 버튼/입력 등 상호작용만 별도 Client Component로 분리합니다.
// app/products/page.tsx (Server Component)
import ProductGrid from "@/components/ProductGrid";
import FiltersClient from "@/components/FiltersClient";
export default async function Page() {
const products = await fetch("https://example.com/api/products", {
cache: "no-store",
}).then((r) => r.json());
return (
<section>
<h1>Products</h1>
<FiltersClient />
<ProductGrid products={products} />
</section>
);
}
// components/FiltersClient.tsx (Client Component)
"use client";
import { useState } from "react";
export default function FiltersClient() {
const [q, setQ] = useState("");
return (
<div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" />
<button type="button" onClick={() => setQ("")}>Reset</button>
</div>
);
}
핵심은 ProductGrid 같은 “표시 전용” 컴포넌트를 Server Component로 유지하는 것입니다.
2) 레이아웃을 클라이언트로 만들지 말고, 필요한 부분만 클라이언트로
레이아웃은 공통으로 재사용되며 하위 트리를 크게 품습니다. 레이아웃에 "use client" 를 붙이면 영향 반경이 너무 커집니다.
대신 레이아웃은 서버로 두고, 헤더의 메뉴 토글 같은 부분만 클라이언트로 분리합니다.
// app/layout.tsx (Server Component)
import Header from "@/components/Header";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
{children}
</body>
</html>
);
}
// components/Header.tsx (Server Component)
import HeaderMenuClient from "@/components/HeaderMenuClient";
export default function Header() {
return (
<header>
<div>Logo</div>
<HeaderMenuClient />
</header>
);
}
// components/HeaderMenuClient.tsx (Client Component)
"use client";
import { useState } from "react";
export default function HeaderMenuClient() {
const [open, setOpen] = useState(false);
return (
<nav>
<button type="button" onClick={() => setOpen((v) => !v)}>
Menu
</button>
{open ? <ul><li>Item</li></ul> : null}
</nav>
);
}
3) 무거운 라이브러리는 동적 import로 늦게 로드
차트/에디터처럼 초기 렌더에 꼭 필요 없는 기능은 동적 import로 분리합니다.
// components/ChartClient.tsx
"use client";
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("@/components/HeavyChart"), {
ssr: false,
loading: () => <div>Loading chart...</div>,
});
export default function ChartClient() {
return <HeavyChart />;
}
주의할 점은 “동적 import를 썼으니 해결”이 아니라, 여전히 "use client" 경계가 상위로 번지지 않게 구성해야 한다는 것입니다.
4) 서버 전용 코드와 클라이언트 전용 코드를 물리적으로 분리
유틸 파일 하나에 서버/클라이언트 로직이 섞이면, 클라이언트 번들러가 안전하게 트리 셰이킹하기 어려워지고 의도치 않은 포함이 발생할 수 있습니다.
권장 패턴:
lib/server/*: DB, 비밀키, 내부 API 호출lib/client/*: 브라우저 API, UI 상태lib/shared/*: 순수 함수(부작용 없음)
그리고 서버 전용 파일에는 server-only 를 넣어 실수로 클라이언트에서 import 하는 것을 막습니다.
// lib/server/db.ts
import "server-only";
export async function queryProducts() {
// DB access
}
이렇게 해두면 "use client" 컴포넌트에서 서버 유틸을 import 하는 순간 빌드/런타임에서 빠르게 문제를 드러내도록 만들 수 있습니다.
5) Context Provider를 루트에 두지 말고 범위를 줄이기
전역 Provider를 루트 레이아웃에 두면 레이아웃이 클라이언트가 되거나, Provider 아래가 전부 클라이언트가 되는 형태로 번질 수 있습니다.
해결은 “정말 전역인가?”를 재검토하고, 필요한 route group 또는 특정 섹션 아래로 내리는 것입니다.
예를 들어 결제 플로우에서만 필요한 상태라면 app/(checkout)/layout.tsx 아래에만 Provider를 두는 식입니다.
6) 이벤트 핸들러 때문에 서버 컴포넌트를 클라이언트로 바꾸지 않기
Server Component에서는 onClick 같은 핸들러를 직접 달 수 없습니다. 이때 흔히 페이지 전체에 "use client" 를 붙이는데, 대신 버튼만 Client Component로 분리하세요.
// components/AddToCartButton.tsx
"use client";
export default function AddToCartButton({ productId }: { productId: string }) {
return (
<button
type="button"
onClick={() => {
// client-side action
console.log("add", productId);
}}
>
Add to cart
</button>
);
}
// components/ProductCard.tsx (Server Component)
import AddToCartButton from "@/components/AddToCartButton";
export default function ProductCard({ id, name }: { id: string; name: string }) {
return (
<article>
<h3>{name}</h3>
<AddToCartButton productId={id} />
</article>
);
}
실전 체크리스트: 번들 증가를 막는 리뷰 기준
팀에서 "use client" 를 도입할 때 아래 기준을 PR 체크리스트로 걸어두면 회귀를 크게 줄일 수 있습니다.
"use client"는 가능한 한 leaf 컴포넌트(말단)로 제한했는가- 레이아웃/페이지에
"use client"가 붙었다면 정말 불가피한가 - 해당 컴포넌트가 import 하는 라이브러리 중 무거운 것이 있는가(차트, 에디터, 날짜, i18n 런타임)
- Provider가 전역으로 깔려 있지는 않은가(필요 범위로 내릴 수 없는가)
- 서버 전용 유틸이 클라이언트로 새어 들어오지 않게
server-only를 적용했는가 - 번들 분석 결과에서 특정 route의 client chunk가 비정상적으로 커지지 않았는가
추가로, 번들 증가가 단순히 JS만의 문제가 아니라 “빌드/캐시”에도 영향을 주는 경우가 많습니다. 의존성이 커지고 import 그래프가 복잡해지면 캐시 히트율이 떨어져 빌드가 느려질 수 있는데, 이 관점은 Docker BuildKit 캐시 무효화 원인·해결 8가지에서 설명한 “캐시가 깨지는 구조”와도 결이 비슷합니다.
자주 나오는 함정과 대응
함정 1) UI 라이브러리의 Provider 때문에 루트가 클라이언트가 됨
대부분의 UI 테마 Provider, 스타일 엔진 Provider는 클라이언트 전용인 경우가 있습니다. 가능하면 Provider 범위를 줄이거나, 서버에서 가능한 스타일링 방식(예: CSS 변수 기반 테마)을 우선 고려하세요.
함정 2) "use client" 파일에서 서버 액션/서버 유틸을 억지로 호출
서버 액션 자체는 클라이언트에서 트리거할 수 있지만, 서버 전용 모듈을 클라이언트 번들로 끌고 오면 안 됩니다. 서버 액션은 별도 파일로 분리하고, 클라이언트는 “호출만” 하도록 경계를 분명히 하세요.
함정 3) 공용 유틸에 무거운 polyfill 또는 side effect import가 있음
공용 유틸에서 불필요한 side effect import가 있으면 트리 셰이킹이 깨질 수 있습니다. TypeScript 환경이라면 side effect import 관리가 특히 중요한데, 관련해서는 TS 5.5+ - noUncheckedSideEffectImports 해결 가이드도 참고할 만합니다.
마무리: "use client" 는 “최소 범위”가 성능의 시작
App Router의 장점은 서버에서 더 많은 일을 하고, 클라이언트에는 꼭 필요한 상호작용만 남길 수 있다는 점입니다. 번들이 커졌다면 대부분 "use client" 경계가 너무 위에 있거나, 그 경계 안으로 무거운 의존성이 유입된 경우입니다.
해결은 어렵지 않습니다.
- 레이아웃/페이지를 서버로 되돌리고
- 인터랙션만 작은 Client Island로 분리하고
- 무거운 기능은 동적 import로 늦게 로드하고
- 서버 전용 코드는
server-only로 강제 분리
이 네 가지를 기준으로 리팩터링하면, 번들 크기와 hydration 비용을 동시에 줄이면서도 App Router의 개발 경험을 유지할 수 있습니다.