- Published on
Next.js 14 RSC로 번들 커질 때 6가지 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, RSC)를 쓰면 “클라이언트 번들이 줄어든다”는 기대가 큽니다. 그런데 Next.js 14 App Router로 마이그레이션한 뒤에도 main.js/route chunk가 오히려 비대해지는 경우가 자주 있습니다. 이유는 단순합니다. **RSC 자체가 번들을 줄여주는 마법이 아니라, ‘클라이언트로 보내지 않아도 되는 코드를 서버로 밀어내는 구조’**이기 때문입니다. 그 구조를 깨는 순간(의도치 않은 use client, 공유 모듈 경계 붕괴, 무거운 라이브러리의 클라이언트 유입 등) 번들은 쉽게 커집니다.
이 글은 “RSC를 쓰는데도 번들이 커졌을 때” 실무에서 바로 적용 가능한 6가지 해결책을 원인-진단-처방 순서로 정리합니다.
> 참고로 성능은 번들 크기만의 문제가 아닙니다. 초기 응답(TTFB)도 함께 봐야 합니다. 이미지가 병목이라면 Next.js 14 App Router TTFB 줄이는 이미지 최적화도 같이 확인하세요.
0) 먼저 진단: “무엇이 클라이언트로 갔는지” 확인하기
해결책을 적용하기 전에, 현재 어떤 모듈이 클라이언트 번들에 포함되는지 확인해야 합니다.
번들 분석기 적용
npm i -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
experimental: {
// 상황에 따라 사용
},
});
ANALYZE=true npm run build
분석 화면에서 다음을 체크합니다.
- 특정 페이지(라우트) chunk에 UI 라이브러리/유틸이 통째로 들어왔는가?
use client가 붙은 컴포넌트가 상위 레이아웃까지 전염되었는가?@/lib/*같은 공유 모듈이 클라이언트 번들로 끌려 들어왔는가?
이제부터의 6가지 해결책은 이 진단 결과에 바로 대응합니다.
1) use client 전염 막기: 상위 경계를 서버로 유지
가장 흔한 원인은 상위 레이아웃/페이지에 use client가 붙어버리는 것입니다. 이렇게 되면 그 아래 모든 것이 클라이언트 컴포넌트로 취급되어 번들이 크게 증가합니다.
나쁜 예: 레이아웃을 통째로 클라이언트로
// app/(site)/layout.tsx
'use client'
import Header from '@/components/Header'
import Footer from '@/components/Footer'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
)
}
좋은 예: 인터랙션만 “섬(island)”으로 분리
// app/(site)/layout.tsx (Server Component)
import Header from '@/components/header/Header'
import Footer from '@/components/Footer'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
)
}
// components/header/Header.tsx (Server)
import HeaderClient from './HeaderClient'
export default async function Header() {
const user = await fetch('https://example.com/api/me', { cache: 'no-store' }).then(r => r.json())
return (
<header>
<div>My Site</div>
<HeaderClient user={user} />
</header>
)
}
// components/header/HeaderClient.tsx (Client)
'use client'
import { useState } from 'react'
export default function HeaderClient({ user }: { user: { name: string } }) {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen(v => !v)}>menu</button>
{open && <div>Hello {user.name}</div>}
</div>
)
}
핵심은 레이아웃/페이지는 서버로 유지하고, 상태/이벤트가 필요한 부분만 최소 단위로 use client를 붙이는 것입니다.
2) “공유 유틸”이 번들을 키운다: server-only / client-only 경계 고정
RSC 환경에서 제일 위험한 패턴은 lib/에 서버 전용 코드(예: fs, DB, 비밀키, 무거운 SDK)를 넣고, 이를 무심코 클라이언트 컴포넌트에서 import하는 것입니다. 이때 Next는 빌드 시점에 에러를 내기도 하지만, 구조에 따라 폴리필/대체 모듈이 들어오거나, 결국 번들이 커지는 결과로 이어지기도 합니다.
해결: server-only로 서버 모듈을 잠그기
npm i server-only
// lib/db.ts
import 'server-only'
import { PrismaClient } from '@prisma/client'
export const db = new PrismaClient()
이제 lib/db.ts를 클라이언트에서 import하면 즉시 오류가 나며, “실수로 클라이언트 유입”을 원천 차단합니다.
반대로 클라이언트 전용도 명시
// lib/client/analytics.ts
import 'client-only'
export function track(event: string) {
// window 사용
window.dispatchEvent(new CustomEvent('track', { detail: event }))
}
이렇게 경계를 고정하면, 번들 증가 원인의 상당수가 사라집니다.
3) 무거운 라이브러리의 클라이언트 유입 차단: “서버에서 처리하고 결과만 전달”
예를 들어 date-fns, lodash, zod, marked, highlight.js 같은 라이브러리를 클라이언트 컴포넌트에서 쓰면, 생각보다 큰 덩어리가 번들에 포함됩니다.
패턴: 파싱/변환은 서버에서, UI만 클라이언트에서
// app/posts/[id]/page.tsx (Server)
import PostBody from '@/components/PostBody'
export default async function Page({ params }: { params: { id: string } }) {
const post = await fetch(`https://example.com/api/posts/${params.id}`, { next: { revalidate: 60 } }).then(r => r.json())
// 서버에서 마크다운 -> HTML 변환(예시)
const { marked } = await import('marked')
const html = marked.parse(post.markdown)
return <PostBody html={html} />
}
// components/PostBody.tsx (Client or Server 모두 가능)
export default function PostBody({ html }: { html: string }) {
return <article dangerouslySetInnerHTML={{ __html: html }} />
}
이 접근의 장점은:
- 무거운 파서/유틸은 서버에서만 실행
- 클라이언트에는 “결과 데이터”만 전송
- 번들 크기 + 실행 비용 모두 감소
4) 동적 import로 “진짜 필요할 때만” 로딩: 인터랙션 컴포넌트 지연
모달, 에디터, 차트, 맵 같은 컴포넌트는 초기 렌더에 필요 없는 경우가 많습니다. 이런 UI는 next/dynamic으로 늦게 불러오면 초기 번들을 크게 줄일 수 있습니다.
// components/ChartSection.tsx
'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'
const HeavyChart = dynamic(() => import('./HeavyChart'), {
ssr: false,
loading: () => <div>Loading chart...</div>,
})
export default function ChartSection() {
const [open, setOpen] = useState(false)
return (
<section>
<button onClick={() => setOpen(true)}>Open chart</button>
{open && <HeavyChart />}
</section>
)
}
ssr: false는 브라우저 전용 API(캔버스, WebGL 등)가 필요한 경우에 특히 유용합니다.- 단, 남용하면 UX가 나빠질 수 있으니 “초기 화면에 꼭 필요한가?” 기준으로 적용합니다.
5) 배럴 파일(index.ts)로 인한 과도한 포함 방지: 직접 import + sideEffects 점검
components/index.ts 같은 배럴(export 모음)은 개발 경험은 좋지만, 번들 관점에서는 불리해질 수 있습니다. 특히 라이브러리/내부 모듈이 트리 셰이킹이 완벽하지 않거나 side effect가 있으면, “몇 개만 쓰려고 했는데 전부 들어오는” 일이 생깁니다.
나쁜 예
// components/index.ts
export * from './Button'
export * from './Modal'
export * from './Editor' // 무거움
import { Button } from '@/components' // Button만 쓰고 싶었는데...
좋은 예: 직접 경로 import
import Button from '@/components/Button'
라이브러리 sideEffects 확인
자체 패키지(모노레포 등)라면 package.json에 sideEffects를 명시하면 트리 셰이킹이 유리해집니다.
{
"name": "@acme/ui",
"sideEffects": false
}
단, CSS를 JS에서 import하는 구조라면 sideEffects: false가 오히려 스타일 누락을 만들 수 있으니, 실제 부작용 파일만 예외 처리합니다.
{
"sideEffects": ["**/*.css"]
}
6) 클라이언트 데이터 패칭 라이브러리의 기본 탑재 줄이기: RSC 패칭 + 최소 hydration
React Query/SWR 같은 도구는 훌륭하지만, “모든 페이지에서 기본으로 깔고 시작”하면 Provider와 런타임이 전역 번들에 들어가 커질 수 있습니다. RSC에서는 많은 경우 서버에서 fetch하고 결과를 props로 내려 클라이언트 상태 라이브러리 의존을 줄일 수 있습니다.
전역 Provider를 무조건 깔지 말고, 필요한 구간만 감싸기
// app/layout.tsx (Server)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
)
}
// app/dashboard/layout.tsx (Server)
import DashboardClientProviders from './providers'
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardClientProviders>{children}</DashboardClientProviders>
}
// app/dashboard/providers.tsx (Client)
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const qc = new QueryClient()
export default function DashboardClientProviders({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
이렇게 하면 대시보드에만 React Query 런타임이 로딩되고, 마케팅/콘텐츠 페이지는 가벼운 번들을 유지할 수 있습니다.
체크리스트: “RSC인데 번들이 큰” 상황을 빠르게 줄이는 순서
- 상위 레이아웃/페이지에
use client가 붙었는지 먼저 확인하고, 인터랙션만 분리한다. lib/공유 모듈에server-only를 붙여 클라이언트 유입을 강제 차단한다.- 무거운 파싱/검증/변환 로직은 서버에서 실행하고 결과만 전달한다.
- 모달/차트/에디터는 dynamic import로 늦게 로딩한다.
- 배럴 파일을 줄이고 직접 import,
sideEffects설정을 점검한다. - React Query/SWR 등 Provider는 필요한 라우트 그룹에만 설치한다.
마무리: 번들 감소는 “경계 설계”의 결과
Next.js 14 RSC에서 번들 최적화의 본질은 “서버/클라이언트 경계를 얼마나 얇고 명확하게 유지하느냐”입니다. use client를 최소화하고, 서버 전용 모듈을 잠그고, 무거운 로직을 서버로 옮기고, 필요한 순간에만 클라이언트 코드를 로딩하면 번들은 자연스럽게 줄어듭니다.
추가로 렌더링 성능 이슈(레이아웃 스래싱, forced reflow)가 의심된다면 프론트 병목 관점에서 Chrome Forced reflow 경고 원인·해결 7단계도 함께 보면 “번들 이후의 체감 성능” 개선에 도움이 됩니다.