목차
- 1. 4.2MB — 그 번들의 정체
- 2. 번들이 비대해지는 3가지 원인
- 3. Webpack 5에서의 번들 사이즈 최적화
- 4. Vite로 갈아타면 달라지는 것들
- 5. 라이브러리 교체 — JavaScript 번들 사이즈 최적화의 핵심
- 6. 측정과 자동화
- 7. 아직 남은 과제들
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.usedExports가 true인지 확인이 필요하다. 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+ 내장 메서드로 대체 가능하다. _.flatten → Array.flat(), _.includes → Array.includes(), _.assign → Object.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-env의 useBuiltIns: 'usage'로 필요한 것만 포함시키는 방법이 있고, build.target을 es2020으로 올려서 polyfill 자체를 줄이는 방법도 있다. IE 지원을 끊을 수 있는 프로젝트라면 후자가 깔끔하다.
CSS-in-JS 런타임 비용: styled-components나 Emotion 같은 CSS-in-JS 라이브러리는 런타임에 스타일을 생성한다. 번들 크기 자체도 20~40KB 추가되지만, 런타임 오버헤드가 더 문제라는 의견이 있다. 이 부분은 직접 벤치마크해본 적이 없어서 정확한 수치를 모르겠다. Tailwind CSS나 vanilla-extract 같은 제로 런타임 방식이 트렌드이긴 한데, 기존 프로젝트에서의 마이그레이션 비용이 상당하다.
JavaScript 번들 사이즈 최적화는 한 번 하고 끝나는 일이 아니라 CI에서 지속적으로 모니터링해야 유지된다. 다만 서드파티 스크립트나 CSS 런타임 비용처럼 번들러 레벨에서 손대기 어려운 영역은 아직 뚜렷한 정답이 없으니, 이쪽은 좀 더 지켜봐야 한다.