운영 중인 Next.js 15 (Pages Router) 프로젝트의 번들을 약 2일간 9개 PR로 다이어트했다.
| 항목 | Before | After | 차이 |
|---|---|---|---|
| First Load JS shared by all (모든 페이지 공통) | 213 kB | 176 kB | -37 kB (-17.4%) |
chunks/main-*.js | 125 kB | 86.8 kB | -38.2 kB (-30.5%) |
/order/general/detail/[id] (가장 무거웠던 페이지) | 702 kB | 286 kB | -416 kB (-59.3%) |
/main (메인 페이지) | 289 kB | 236 kB | -53 kB (-18.3%) |
핵심 메시지 하나만 가져간다면: 무거운 라이브러리는 거의 다
next/dynamic으로 분리할 수 있고, Sentry 자동 계측은 생각보다 비싸다.
운영 프로젝트(Next.js 15.1.9 + React 19 + Sentry 10, Pages Router)의 번들이 어딘가 부어 있다는 감은 있었지만 정확히 어디인지 몰랐다. 일단 .next/static/ 디스크 사이즈부터 떠봤다.
.next/static/ 전체 6.53 MB
static/chunks/ (공용 chunk) 4.94 MB
static/chunks/pages/ (페이지별) 1.33 MB
공용 chunk가 페이지 chunk의 약 3.7배. 즉 어떤 페이지를 열든 다운로드해야 하는 자산이 압도적으로 많았다. 다이어트 1순위가 명확해졌다.
공용 chunk 상위 6개를 까보니 결과는 더 노골적이었다.
| 파일 | 크기 (KB) | 정체 |
|---|---|---|
e77a1f1b-*.js | 805 | ag-grid-community + ag-grid-react |
6f31d389-*.js | 400 | xlsx (SheetJS) |
3ed48ce9-*.js | 332 | @toast-ui/editor (+ prismjs) |
643144ee-*.js | 322 | jspdf + jspdf-autotable |
1d2671aa-*.js | 194 | html2canvas |
framework-*.js | 178 | React DOM + React 19 runtime |
여기서 두 가지가 보였다.
@next/bundle-analyzer코드를 건드리기 전에 재현 가능한 측정 환경부터 만들었다. 이게 없으면 PR마다 "줄었다는 감"만 남고 정량 비교가 안 된다.
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer'
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
})
export default withSentryConfig(withBundleAnalyzer(nextConfig), { /* ... */ })
// package.json
{
"scripts": {
"build:analyze": "cross-env ANALYZE=true next build"
}
}
이걸 깔고 npm run build:analyze를 돌리면 .next/analyze/client.html이 생긴다. 모든 PR마다 이걸 떠서 docs/bundle-snapshots/YYYY-MM-DD-after-R-N/ 폴더에 보관하기로 정했다. 다음 PR의 베이스라인은 직전 PR의 after가 된다 — 체인 형태로 비교가 누적된다.
사소해 보이지만 이 결정이 시리즈 전체의 신뢰도를 만들었다. -416kB 같은 숫자가 "어느 시점 대비"인지 명확하게 추적된다.
next/dynamic가장 ROI가 명확한 케이스다. 단 하나의 페이지에서만 쓰는데 공용 chunk에 박혀 있는 라이브러리가 보이면 그건 그냥 빼면 된다.
대표 케이스가 FullCalendar였다. /etc/ims-plan 한 페이지에서만 쓰는데 107KB가 공용 chunk에 들어 있었다.
// Before — features/etc/ims/index.tsx
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
// 페이지 컴포넌트 안에서
<FullCalendar plugins={[dayGridPlugin]} /* ... */ />
// After — features/etc/ims/index.tsx
import dynamic from 'next/dynamic'
const Calendar = dynamic(() => import('./Calendar'), {
ssr: false,
loading: () => <div className="text-muted py-5 text-center">캘린더 불러오는 중...</div>,
})
// 페이지 컴포넌트 안에서
<Calendar />
FullCalendar 컴포넌트와 props는 ./Calendar.tsx라는 별도 파일로 옮겼다. webpack이 코드 스플릿 boundary를 명확히 인식하도록.
결과:
/etc/ims-plan Page Size: 61.6 kB → 3.56 kB (-94%)/etc/ims-plan First Load JS: 325 kB → 267 kB (-58 kB)같은 패턴을 react-slick(메인 페이지 캐러셀)에도 적용했다.
await import()PDF 생성 라이브러리(jspdf + jspdf-autotable + html2canvas)는 더 게으르게 로드해도 된다. PDF 다운로드 버튼을 누르기 전까지는 코드 자체가 필요 없으니까.
문제는 이게 매니저 클래스 안에 박혀 있었다는 점이다.
// Before — utils/PdfReportManager.ts
import jsPDF from 'jspdf'
import autoTable from 'jspdf-autotable'
import html2canvas from 'html2canvas'
export class PdfReportManager {
static async download(data, formatter) { /* ... */ }
}
이 매니저 클래스를 import만 해도 jspdf, autotable, html2canvas 청크가 따라온다. 그래서 매니저 클래스 자체는 손대지 않고, 호출하는 쪽에서 매니저를 dynamic import 했다.
// Before — 호출처
import { PdfReportManager } from '@/utils/PdfReportManager'
const handleDownload = async () => {
await PdfReportManager.download(data, formatter)
}
// After — 호출처
// 값 import 제거. 타입만 필요하면 `import type`은 유지 (TS가 컴파일 시 제거)
const handleDownload = async () => {
const { PdfReportManager } = await import('@/utils/PdfReportManager')
await PdfReportManager.download(data, formatter)
}
매니저 코드는 한 줄도 안 바꿨다. 시그니처가 동일하니 동작 회귀 위험도 0에 가깝다.
결과:
/settlement/user/detail/[id] 475 kB → 284 kB (-191 kB, -40%)같은 패턴을 ag-grid + xlsx에도 적용했다 (productDeliveryTable 컴포넌트를 호출 페이지에서 next/dynamic으로, 엑셀 import는 클릭 핸들러 안에서 await import('xlsx')). 단일 PR로 5개 페이지에서 -378 kB가 빠지면서 시리즈 중 단일 PR 최대 효과를 냈다.
여기가 의외로 큰 발견이었다.
@sentry/nextjs의 default integrations에는 BrowserTracing(Web Vitals/Performance), Replay(Session Replay), BrowserProfiling이 자동으로 들어간다. 이게 모든 chunk에 분산되어 박힌다 — 단일 chunk로 분리되지 않는다.
문제는 tracesSampleRate: 0으로 둬도 코드 자체는 번들에 그대로 남는다는 것. sampling rate는 실행 여부만 결정할 뿐 트리쉐이킹과는 무관하다.
해결은 integrations 함수형 옵션으로 default에서 명시적으로 빼는 것:
// src/instrumentation-client.ts
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
enabled: process.env.NODE_ENV === 'production',
ignoreErrors: ['routeChange aborted'],
integrations: defaults =>
defaults.filter(
integration =>
integration.name !== 'BrowserTracing' &&
integration.name !== 'Replay' &&
integration.name !== 'BrowserProfiling',
),
tracesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
})
결과:
chunks/main-*.js: 125 kB → 86.8 kB (-38.2 kB, -30.5%)이건 시리즈 중 가장 광범위한 효과였다. 특정 페이지가 아니라 전 사용자, 전 페이지에서 빠지니까.
Performance나 Replay가 정말 필요해지면 그때 BrowserTracing만 다시 켜면 된다. 필터에서 한 줄 빼는 것으로 충분하다. "에러 캡처는 유지, 무거운 통합은 명시적으로 끄기"가 합리적인 default라는 게 결론이었다.
no-restricted-imports여기까지 와서 가장 걱정된 건 다음 PR에서 누군가 무심코 import jsPDF from 'jspdf'를 다시 쓰는 것이었다. dynamic 분리 구조는 보이지 않게 깨질 수 있다.
그래서 마지막에 ESLint 룰로 못 박았다.
// eslint.config.mjs
{
rules: {
'no-restricted-imports': ['error', {
paths: [
{ name: 'ag-grid-react', message: 'Use the dynamic wrapper in features/order/components/productDeliveryTable.' },
{ name: 'xlsx', message: 'Import lazily inside the click handler that needs it.' },
{ name: 'jspdf', message: 'Import lazily inside the PDF manager method.' },
{ name: 'jspdf-autotable', message: 'Import lazily.' },
{ name: 'html2canvas', message: 'Import lazily.' },
{ name: '@fullcalendar/react', message: 'Use dynamic import.' },
{ name: 'react-slick', message: 'Use dynamic import.' },
{ name: 'lodash-es', message: 'Use native equivalents.' },
{ name: 'lodash' },
],
}],
},
}
dynamic chain 내부의 wrapper 파일들(PdfReportManager.ts, PdfStateManager.ts, Calendar.tsx, productDeliveryTable/index.tsx)은 정적 import가 의도된 설계라서 별도 객체로 룰을 OFF 했다.
이 PR은 코드 변경 없이 룰만 추가했고 측정 영향은 0kB다. 하지만 가치는 다음 누군가가 PDF 모달에서 import jsPDF from 'jspdf'를 적었을 때 CI가 그 자리에서 막아주는 것에 있다.
| 페이지 | Before | After | 절감 |
|---|---|---|---|
/order/general/detail/[id] (ag-grid + xlsx + Sentry) | 702 kB | 286 kB | -416 kB (-59.3%) |
/order/ssg/detail/[id] | 704 kB | 288 kB | -416 kB (-59.1%) |
/settlement/user/detail/[id] (PDF 매니저 + Sentry) | 475 kB | 246 kB | -229 kB (-48.2%) |
/etc/ims-plan (FullCalendar + Sentry) | 325 kB | 230 kB | -95 kB (-29.2%) |
/main (react-slick + Sentry) | 289 kB | 236 kB | -53 kB (-18.3%) |
| First Load shared by all (모든 페이지 공통) | 213 kB | 176 kB | -37 kB (-17.4%) |
대략 4G(~1.5 MB/s) 환경에서 무거운 페이지는 약 -280ms, 3G(~400 KB/s)에서는 약 -1.0s 진입 시간이 단축되는 셈이다 (다운로드 시간만 계산, 파싱/실행 단축은 별도).
1. 측정 인프라가 먼저다.
@next/bundle-analyzer 도입을 첫 PR로 박은 게 시리즈 전체의 신뢰도를 만들었다. 모든 PR이 "직전 머지 결과 대비 얼마"를 정확히 보여줄 수 있었다.
2. "절감 효과 0kB"인 PR도 가치가 있다.
lodash-es를 네이티브로 교체한 PR과 ESLint 룰 추가 PR은 측정상 변화가 0이었다. 하지만 (1) webpack tree-shaking이 이미 잘 하고 있다는 사실을 검증했고, (2) 회귀 방지망을 영구화했다. 가치 ≠ 즉시 KB 절감.
3. 매니저 코드는 손대지 말고 호출처에서 dynamic 하기.
PDF 매니저 클래스(PdfReportManager.ts)는 시리즈 내내 한 줄도 안 바꿨다. 호출처에서 await import()만 했다. 변경 영향 범위를 좁히는 것은 곧 회귀 위험을 좁히는 것이다.
4. Sentry 자동 계측은 명시적으로 끄지 않으면 안 빠진다.
tracesSampleRate: 0은 실행만 막을 뿐 번들에서는 안 빠진다. integrations: (defaults) => defaults.filter(...)로 명시 제거해야 한다. 이걸 모르면 영원히 -38kB를 두고 가는 셈이다.
5. dynamic import의 트레이드오프는 작다.
첫 클릭/진입 시 lazy chunk를 받는 짧은 지연이 생기지만, "불러오는 중..." placeholder 한 줄이면 충분히 자연스럽다. 그리고 두 번째부터는 캐시 hit이라 즉시다.
처음에는 막연하게 "번들이 무거운 것 같은데"였다. 그걸 @next/bundle-analyzer로 측정 가능하게 만들고, 가장 무거운 라이브러리 6개를 식별하고, 라이브러리별로 적절한 lazy 패턴(페이지 단위 / 클릭 단위 / 통합 제거)을 적용한 결과가 First Load -17%, 무거운 페이지 -59%였다.
번들 최적화가 막연하게 느껴진다면, 일단 .next/static/chunks에 들어가서 파일 크기 순으로 정렬해보길 권한다. 무엇이 비싼지 알면 그 다음은 패턴 매칭이다.