Next.js 번들 최적화하기

기성·5일 전

TIL

목록 보기
97/97

TL;DR

운영 중인 Next.js 15 (Pages Router) 프로젝트의 번들을 약 2일간 9개 PR로 다이어트했다.

항목BeforeAfter차이
First Load JS shared by all (모든 페이지 공통)213 kB176 kB-37 kB (-17.4%)
chunks/main-*.js125 kB86.8 kB-38.2 kB (-30.5%)
/order/general/detail/[id] (가장 무거웠던 페이지)702 kB286 kB-416 kB (-59.3%)
/main (메인 페이지)289 kB236 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-*.js805ag-grid-community + ag-grid-react
6f31d389-*.js400xlsx (SheetJS)
3ed48ce9-*.js332@toast-ui/editor (+ prismjs)
643144ee-*.js322jspdf + jspdf-autotable
1d2671aa-*.js194html2canvas
framework-*.js178React DOM + React 19 runtime

여기서 두 가지가 보였다.

  1. 표 컴포넌트와 PDF/엑셀 라이브러리가 공용 chunk에 박혀 있다. 이걸 쓰지 않는 페이지의 사용자도 받고 있다는 뜻이다.
  2. @toast-ui/editor는 이미 dynamic으로 분리돼 있었다. 즉 같은 패턴을 다른 라이브러리에도 적용하면 된다.

측정 인프라부터 — @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 같은 숫자가 "어느 시점 대비"인지 명확하게 추적된다.


핵심 기법 1: 단일 페이지 전용 라이브러리 — 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)
  • 다른 페이지: 영향 0

같은 패턴을 react-slick(메인 페이지 캐러셀)에도 적용했다.


핵심 기법 2: 클릭 시점에만 필요한 라이브러리 — 핸들러 내부 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에 가깝다.

결과:

  • PDF 사용 8개 페이지에서 페이지당 -141~-191 kB First Load 일괄 절감
  • 가장 효과 큰 페이지: /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 최대 효과를 냈다.


핵심 기법 3: Sentry 자동 계측 슬림화

여기가 의외로 큰 발견이었다.

@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%)
  • 모든 페이지의 First Load가 일괄 -38 kB

이건 시리즈 중 가장 광범위한 효과였다. 특정 페이지가 아니라 전 사용자, 전 페이지에서 빠지니까.

Performance나 Replay가 정말 필요해지면 그때 BrowserTracing만 다시 켜면 된다. 필터에서 한 줄 빼는 것으로 충분하다. "에러 캡처는 유지, 무거운 통합은 명시적으로 끄기"가 합리적인 default라는 게 결론이었다.


핵심 기법 4: 회귀 방지망 — ESLint 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

페이지BeforeAfter절감
/order/general/detail/[id] (ag-grid + xlsx + Sentry)702 kB286 kB-416 kB (-59.3%)
/order/ssg/detail/[id]704 kB288 kB-416 kB (-59.1%)
/settlement/user/detail/[id] (PDF 매니저 + Sentry)475 kB246 kB-229 kB (-48.2%)
/etc/ims-plan (FullCalendar + Sentry)325 kB230 kB-95 kB (-29.2%)
/main (react-slick + Sentry)289 kB236 kB-53 kB (-18.3%)
First Load shared by all (모든 페이지 공통)213 kB176 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에 들어가서 파일 크기 순으로 정렬해보길 권한다. 무엇이 비싼지 알면 그 다음은 패턴 매칭이다.

profile
프론트가 하고싶어요

0개의 댓글