JavaScript 번들 사이즈 최적화: Webpack과 Vite에서 프로덕션 용량 줄이기

목차

1. 4.2MB — 그 번들의 정체

프로덕션 빌드 결과물이 4.2MB였다. JavaScript 번들 사이즈 최적화를 한 번도 안 한 프로젝트의 현실치고는 꽤 참혹한 숫자다. Lighthouse Performance 점수 38점, 초기 로딩에 12초. 금요일 오후에 팀 슬랙으로 "빌드가 너무 느려요" 메시지가 날아왔을 때, webpack-bundle-analyzer를 돌려봤더니 moment.js의 locale 파일 전체(약 600KB)와 lodash(약 530KB)가 그대로 번들에 포함되어 있었다.

1년 넘게 이 상태로 배포하고 있었다는 게 허탈했다. 처음에는 compression-webpack-plugin으로 gzip만 걸면 될 줄 알았다. 적용해봤더니 전송 크기는 줄었지만, 브라우저의 JavaScript 파싱·컴파일 시간은 그대로라 체감 성능 차이가 거의 없더라. 압축은 전송 크기를 줄일 뿐이고, 진짜 문제는 번들에 들어가면 안 되는 코드가 들어가 있다는 거였다.

이 글에서는 4.2MB를 890KB까지 줄인 과정을 단계별로 정리한다. Webpack 5와 Vite 6 환경 모두 다루되, 각 기법의 효과를 수치로 비교하는 데 초점을 맞췄다.

2. 번들이 비대해지는 3가지 원인

번들 사이즈가 커지는 원인은 크게 세 가지로 나뉜다. 경험상 대부분의 프로젝트가 이 중 최소 두 가지에 해당한다.

2-1. 통째로 들어오는 라이브러리

가장 흔한 원인이다. import _ from 'lodash'라고 쓰면 lodash 전체(약 530KB minified)가 번들에 포함된다. 실제로 쓰는 함수가 _.debounce_.cloneDeep 두 개뿐이어도 마찬가지. moment.js는 더 심하다. 날짜 포맷팅 하나 쓰려고 import했을 뿐인데, 전 세계 locale 파일이 전부 딸려온다.

webpack-bundle-analyzer로 시각화하면 moment의 locale 폴더가 번들에서 차지하는 면적을 보고 놀라게 된다. 비슷한 경험이 있을 것이다.

라이브러리 전체 import 크기 (minified) 대안 대안 크기
moment.js ~290KB (locale 포함 ~600KB) day.js 1.11 ~7KB
lodash ~530KB lodash-es (cherry-pick) 사용 함수만 (~5–20KB)
axios ~40KB fetch API (내장) 0KB
chart.js 4.x ~200KB lightweight-charts ~45KB
numeral.js ~60KB Intl.NumberFormat (내장) 0KB

한 줄 요약: 대부분의 번들 비대화는 "안 쓰는 코드가 포함되는 것"이 원인이다.

2-2. Tree Shaking 실패

ES Module 기반이면 tree shaking이 자동으로 되리라 생각하기 쉽다. 실제로는 안 되는 경우가 많다. 가장 흔한 원인은 package.json"sideEffects" 필드가 없는 것이다.

Webpack 5는 sideEffects: false가 명시되지 않으면 모든 모듈에 부수 효과가 있다고 가정한다 (출처: Webpack 공식 문서 – Tree Shaking). 안 쓰는 export라도 "혹시 import 자체가 부수 효과를 일으킬 수 있으니까" 제거하지 않는 거다. sideEffects 한 줄 안 넣어서 수백 KB가 낭비되는 셈이다.

CommonJS(require)로 작성된 라이브러리는 아예 tree shaking 대상이 아니다. 모듈 시스템의 구조적 한계라 어쩔 수 없다.

2-3. 코드 스플리팅 부재

SPA에서 모든 페이지의 코드를 하나의 번들에 넣으면 초기 로딩이 느려진다. 사용자가 /settings 페이지를 방문할 일이 거의 없는데, 그 페이지의 무거운 에디터 라이브러리까지 초기 번들에 포함되는 식이다.

React.lazy + Suspense, Vue의 defineAsyncComponent, 또는 동적 import()를 쓰면 라우트별로 번들을 분리할 수 있다. 다만 코드 스플리팅은 전체 번들 크기를 줄이는 게 아니라 초기 로딩 크기를 줄이는 것이다. 이 차이를 혼동하면 안 된다.

3. Webpack 5에서의 번들 사이즈 최적화

Webpack 환경에서 번들 사이즈를 줄이는 실전 방법을 순서대로 정리한다. 무작정 플러그인을 추가하기 전에, 먼저 현재 상태를 측정하는 게 맞다.

3-1. webpack-bundle-analyzer로 현황 파악

최적화 전에 반드시 현재 번들 구성을 시각화해야 한다. 뭐가 크고 뭐가 작은지 모르면서 최적화한다는 건 눈 감고 다이어트하는 거다.

# webpack-bundle-analyzer 설치
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  // ... 기존 설정
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',           // HTML 파일로 출력
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,               // 자동으로 브라우저 열지 않음
      defaultSizes: 'gzip',             // gzip 기준 크기 표시
    }),
  ],
};

npm run build 후 생성된 bundle-report.html을 열면 각 모듈이 차지하는 비율이 트리맵으로 보인다. 이걸 보는 순간 "이게 이렇게 컸어?" 싶은 모듈이 반드시 하나는 나온다.

3-2. sideEffects와 Tree Shaking 설정

앞서 말한 sideEffects 설정이다. 자기 프로젝트의 package.json에 아래를 추가하면 된다:

{
  "name": "my-project",
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js"
  ]
}

CSS import는 부수 효과로 봐야 하므로 배열에 포함시킨다. 순수 JS 모듈만 있는 프로젝트라면 "sideEffects": false로 해도 된다. 이 한 줄 추가로 사내 프로젝트에서 약 180KB가 빠졌다. 진짜 이 한 줄로 그랬다.

Webpack 설정에서도 optimization.usedExportstrue인지 확인이 필요하다. production 모드에서는 기본값이 true지만, 커스텀 설정을 쓰는 프로젝트에서는 빠져 있는 경우가 있더라.

// webpack.config.js
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true,         // 사용되지 않는 export 마킹
    minimize: true,             // 마킹된 코드 제거 (terser 기본 적용)
    concatenateModules: true,   // 모듈 합치기 (scope hoisting)
  },
};

3-3. 코드 스플리팅 실전 설정

Webpack 5의 splitChunks 설정으로 vendor 코드를 분리하고, 동적 import로 라우트별 청크를 만든다.

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: 20,
      minSize: 20000,              // 20KB 미만은 분리하지 않음
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name(module) {
            // node_modules 패키지명으로 청크 이름 생성
            const packageName = module.context.match(
              /[\\/]node_modules[\\/](.*?)([\\/]|$)/
            )[1];
            return `vendor.${packageName.replace('@', '')}`;
          },
        },
        common: {
          minChunks: 2,            // 2개 이상 모듈에서 쓰이면 공통 청크로
          priority: -10,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

라우트별 동적 import는 아래처럼 한다:

// React 라우트 코드 스플리팅 예시
import React, { Suspense, lazy } from 'react';

// 각 페이지를 별도 청크로 분리
const Dashboard = lazy(() => import(
  /* webpackChunkName: "dashboard" */
  './pages/Dashboard'
));
const Settings = lazy(() => import(
  /* webpackChunkName: "settings" */
  './pages/Settings'
));

function App() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

webpackChunkName 주석을 넣으면 빌드 결과물에서 어떤 청크가 어떤 페이지인지 식별하기 쉽다. 이거 안 넣으면 0.js, 1.js처럼 나와서 디버깅할 때 귀찮아진다.

3-4. moment.js → day.js 교체

이건 간단하다. API가 거의 호환되기 때문에 30분이면 끝난다.

// Before
import moment from 'moment';
const formatted = moment().format('YYYY-MM-DD');
const relative = moment('2026-01-01').fromNow();

// After
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);   // fromNow() 쓰려면 플러그인 추가

const formatted = dayjs().format('YYYY-MM-DD');
const relative = dayjs('2026-01-01').fromNow();

moment.js 고유 기능 대부분은 day.js 플러그인으로 대체 가능하다 (출처: day.js 공식 문서 – Plugin List). 교체 효과: 약 600KB → 7KB. 프로젝트에서 가장 큰 단일 감소 폭이었다.

4. Vite로 갈아타면 달라지는 것들

Webpack 5에서의 최적화가 "기존 접근"이라면, Vite로의 마이그레이션은 "접근 자체를 바꾸는 것"에 해당한다. Vite 6(2025년 11월 릴리즈, 출처: Vite 공식 블로그)은 내부적으로 Rollup 4를 프로덕션 번들러로 사용하며, 개발 서버에서는 esbuild로 의존성을 사전 번들링한다.

4-1. Vite의 기본 최적화

Vite는 별도 설정 없이도 아래 최적화를 자동 수행한다:

  • 자동 코드 스플리팅: 동적 import()를 감지하면 별도 청크로 분리
  • CSS 코드 스플리팅: 비동기 청크 전용 CSS는 해당 청크와 함께 로드
  • ES Module 기반 tree shaking: Rollup의 tree shaking이 기본 적용
  • esbuild minification: Terser보다 체감 20배 이상 빠르다 (출처: esbuild 공식 벤치마크)

Webpack에서 splitChunks, TerserPlugin, MiniCssExtractPlugin을 각각 설정해야 했던 것들이 Vite에서는 기본값으로 처리된다. (개인적으로 이 점이 가장 마음에 들었다)

4-2. Vite 프로덕션 빌드 설정

기본값으로도 충분하지만, 프로덕션 번들을 더 줄이려면 rollupOptions를 직접 건드려야 한다.

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    target: 'es2020',                 // 최신 브라우저만 지원할 경우
    minify: 'esbuild',               // 기본값. terser보다 빠름
    cssMinify: 'esbuild',
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // react 관련은 하나로 묶기
            if (id.includes('react') || id.includes('react-dom')) {
              return 'vendor-react';
            }
            // 나머지는 패키지명 기준 분리
            const dirs = id.split('node_modules/');
            const name = dirs[dirs.length - 1].split('/')[0];
            return `vendor-${name}`;
          }
        },
      },
    },
    chunkSizeWarningLimit: 500,       // 500KB 넘으면 경고
  },
});

manualChunks에서 너무 세밀하게 쪼개면 HTTP 요청 수가 늘어나서 오히려 성능이 나빠질 수 있다. HTTP/2 환경이면 큰 문제 없지만, 청크 수가 30개를 넘어가면 브라우저 리소스 스케줄링에 영향을 준다. 체감상 10~15개 정도가 적당한 것 같다.

4-3. Webpack vs Vite 빌드 비교

같은 프로젝트(React 18.2 + TypeScript 5.3, 약 150개 컴포넌트)를 두 번들러로 빌드한 결과다. M1 MacBook Pro 16GB 환경에서 측정했다.

항목 Webpack 5.9x Vite 6 (Rollup 4)
빌드 시간 45초 8초
번들 크기 (gzip 전) 1.1MB* 920KB*
번들 크기 (gzip 후) 340KB 285KB
HMR 속도 1.2~2초 50~100ms
설정 파일 길이 약 120줄 약 35줄
Tree Shaking 수동 설정 필요 Rollup 기본 내장

*라이브러리 교체(moment→day.js, lodash→lodash-es) 적용 후 수치

한 줄 요약: Vite가 빌드 시간과 번들 크기 모두에서 우위다. 다만 Webpack 에코시스템(특정 loader, plugin)에 강하게 의존하는 레거시 프로젝트라면 마이그레이션 비용을 따져봐야 한다.

5. 라이브러리 교체 — JavaScript 번들 사이즈 최적화의 핵심

번들러 설정을 아무리 만져도 한계가 있다. 실질적으로 가장 큰 효과를 내는 건 무거운 라이브러리를 가벼운 대안으로 교체하는 것이다. 새 도구 나오면 일단 써보는 성격이라 대안 라이브러리를 꽤 비교해봤는데, 결론부터 말하면 교체 난이도는 생각보다 낮다.

5-1. lodash → lodash-es + cherry-pick

lodash를 통째로 import하면 약 530KB다. lodash-es로 바꾸고 필요한 함수만 가져오면 tree shaking이 적용된다.

// ❌ 전체 import — 530KB 통째로 번들에 포함
import _ from 'lodash';
_.debounce(fn, 300);

// ⭕ lodash-es에서 개별 import — tree shaking 가능
import { debounce } from 'lodash-es';
debounce(fn, 300);

// ⭕ 경로 직접 지정 — 가장 확실한 방법
import debounce from 'lodash/debounce';
debounce(fn, 300);

lodash-es의 개별 import 방식은 tree shaking에 의존하기 때문에, 번들러 설정이 제대로 되어 있어야 효과가 있다. 확실하게 가려면 경로 직접 지정 방식이 더 안전하다.

(여담이지만 lodash 함수 중 상당수는 ES2020+ 내장 메서드로 대체 가능하다. _.flattenArray.flat(), _.includesArray.includes(), _.assignObject.assign(). 이런 건 아예 lodash 의존을 제거하는 게 맞다.)

5-2. 최적화 기법별 효과 종합

프로젝트에서 실제로 적용한 각 기법의 효과를 정리했다.

최적화 기법 감소량 (approx.) 적용 난이도 비고
moment.js → day.js -593KB 낮음 API 거의 호환, 30분 소요
lodash → lodash-es -480KB 낮음 import 경로만 변경
sideEffects 설정 -180KB 낮음 package.json 한 줄
코드 스플리팅 초기 로딩 -40% 중간 라우트 구조 변경 필요
Webpack → Vite 전환 빌드 시간 -82% 높음 설정 마이그레이션 필요
build target 최신화 -30~80KB 낮음 polyfill 제거 효과

감소량은 프로젝트마다 다르다. 위 수치는 사내 대시보드 프로젝트(React 18.2, TypeScript 5.3, 약 150개 컴포넌트) 기준이다.

한 줄 요약: 라이브러리 교체 두 가지만으로 전체 감소분의 70% 이상을 차지했다. 번들러 설정을 만지기 전에 무거운 의존성부터 점검하는 게 순서다.

6. 측정과 자동화

최적화를 적용했으면 반드시 전후 비교를 해야 한다. "체감상 빨라진 것 같다"는 보고서에 쓸 수 없다. 그리고 한 번 줄여놓은 번들이 다시 커지지 않도록 CI에서 자동으로 감시해야 한다. 이게 없으면 또 1년 동안 모르고 지나간다 — 이건 경험에서 우러나온 교훈이다.

6-1. bundlesize로 CI 자동 체크

bundlesize 패키지를 쓰면 PR마다 번들 크기가 임계값을 넘는지 자동 검사할 수 있다.

{
  "bundlesize": [
    {
      "path": "./dist/assets/*.js",
      "maxSize": "250 kB",
      "compression": "gzip"
    },
    {
      "path": "./dist/assets/*.css",
      "maxSize": "50 kB",
      "compression": "gzip"
    }
  ],
  "scripts": {
    "check-size": "bundlesize"
  }
}

GitHub Actions에 npm run check-size를 넣어두면 번들 크기가 서서히 늘어나는 걸 조기에 잡을 수 있다.

6-2. Lighthouse CI 연동

번들 크기뿐 아니라 실제 로딩 성능까지 추적하고 싶다면 Lighthouse CI를 붙이면 된다.

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci && npm run build
      - name: Lighthouse CI
        uses: treosh/lighthouse-ci-action@v12
        with:
          uploadArtifacts: true
          budgetPath: ./budget.json    # 성능 예산 파일 경로

budget.json"resourceSizes" 항목으로 JavaScript 총 용량 제한을 걸어두면 된다. 구체적인 설정 방법은 Chrome 공식 문서 – Performance Budgets에 잘 정리되어 있다.

7. 아직 남은 과제들

여기까지 적용하면 대부분의 프로젝트에서 의미 있는 개선을 볼 수 있다. 다만 완전한 해결이라고 보기엔 어려운 영역이 몇 가지 남아 있다.

서드파티 스크립트: Google Analytics, Facebook Pixel, Sentry 같은 서드파티 스크립트는 번들 최적화의 범위 밖이다. 합쳐서 200~400KB를 차지하는 경우가 흔한데, 제거할 수도 없고 직접 최적화할 수도 없다. async/defer 로딩으로 초기 렌더링을 막지 않게 하는 정도가 최선이다.

동적 polyfill 문제: core-js를 통째로 넣으면 150KB가 넘는다. @babel/preset-envuseBuiltIns: 'usage'로 필요한 것만 포함시키는 방법이 있고, build.targetes2020으로 올려서 polyfill 자체를 줄이는 방법도 있다. IE 지원을 끊을 수 있는 프로젝트라면 후자가 깔끔하다.

CSS-in-JS 런타임 비용: styled-components나 Emotion 같은 CSS-in-JS 라이브러리는 런타임에 스타일을 생성한다. 번들 크기 자체도 20~40KB 추가되지만, 런타임 오버헤드가 더 문제라는 의견이 있다. 이 부분은 직접 벤치마크해본 적이 없어서 정확한 수치를 모르겠다. Tailwind CSS나 vanilla-extract 같은 제로 런타임 방식이 트렌드이긴 한데, 기존 프로젝트에서의 마이그레이션 비용이 상당하다.

JavaScript 번들 사이즈 최적화는 한 번 하고 끝나는 일이 아니라 CI에서 지속적으로 모니터링해야 유지된다. 다만 서드파티 스크립트나 CSS 런타임 비용처럼 번들러 레벨에서 손대기 어려운 영역은 아직 뚜렷한 정답이 없으니, 이쪽은 좀 더 지켜봐야 한다.

Chiko IT
Chiko IT

Platform Engineer. Python, AI, Infra에 관심이 많습니다.