npm run build 한 줄. 출력에 "1234 modules transformed, chunk 8 files, gzip 234 KB" 같은 메시지. bundler 가 무엇을 하는 걸까? 단순 "여러 .js 파일 합치기" 가 아니다 — module resolution, tree shaking, code splitting, source map, HMR 등. 이 가이드는 bundler 의 실제 작업, Webpack vs Vite vs esbuild 의 차이, dev 와 production 에서 다르게 동작하는 이유를 정리한다.
왜 bundler 가 필요한가
모던 JavaScript 는 import / export 의 ESM (ES2015+). 그러나 옛 브라우저 + 성능 위해 brower 에 직접 보내는 건 비효율:
Source:
src/
├── App.tsx import Button from './Button'
├── Button.tsx import {clsx} from 'clsx'
├── Header.tsx import logo from './logo.png'
└── ... ~500 modules
Browser:
- 500 개 HTTP request → HTTP/2 multiplex 라도 round-trip 부담
- import 의 path resolution 브라우저 spec 제한
- TypeScript / JSX / Sass 등 transpile 필요
- 모든 module 의존성 download 한 후 실행Bundler = 이 모든 module 을 분석해 최적화된 형태로 변환.
Bundler 의 5 단계 작업
1. Module Resolution — "import 'foo'" 의 실제 경로 찾기
2. Dependency Graph — 모든 module 의 import 추적
3. Transformation — TypeScript → JS, JSX → JS, Sass → CSS
4. Optimization — Tree shaking, minify, code splitting
5. Output — bundle (chunk) 생성, asset hash, source mapStep 1 — Module Resolution
import App from './App';
import {clsx} from 'clsx';
import logo from '@/assets/logo.png';
Bundler 의 해석:
1. './App' → ./App.tsx (확장자 찾기) 또는 ./App/index.tsx
2. 'clsx' → node_modules/clsx/package.json 의 "main" 또는 "exports"
3. '@/assets/...' → tsconfig.json 의 paths alias 적용 → src/assets/...npm package 의 resolution 우선 순위 (modern):
package.json의exportsfield (조건부 export)modulefield (ESM, tree-shake 친화)mainfield (CommonJS, fallback)- 없으면
index.js
Step 2 — Dependency Graph
Entry: src/App.tsx
├─ Button.tsx
│ └─ clsx (npm)
├─ Header.tsx
│ ├─ logo.png ← asset
│ └─ Nav.tsx
└─ utils.ts
└─ lodash-es (npm)
bundler 가 그래프 (directed graph) 완성 →
모든 module 의 의존성 추적
중복 제거
unreachable 코드 식별 (tree shaking 대상)Circular dependency 도 처리 — 이중 import 가 무한 루프 X. 그러나 의미가 불명확해질 수 있어 가능하면 회피.
Step 3 — Transformation
- TypeScript → JavaScript — Babel 또는 swc 또는 esbuild
- JSX → React.createElement 또는 jsx-runtime
- Sass / Less → CSS
- 이미지 / SVG → JS module — import 한 file path 또는 inline base64
- polyfill 주입 — target browser (browserslist) 에 맞춰 자동
실험 — JavaScript 포매터 로 bundle 결과 (production minified) 를 다시 들여쓰기. 익숙한 코드가 어떻게 변환·optimize 됐는지 즉시 확인.
Step 4 — Optimization
Tree Shaking — 안 쓰는 코드 제거
// utils.ts
export function used() { /* ... */ }
export function unused() { /* ... */ }
// App.tsx
import {used} from './utils';
used();
// Bundle 결과:
function used() { /* ... */ }
// unused 는 제거됨조건 — ESM (import/export) 사용. CommonJS (require) 는 tree-shake 불가 (runtime 결정).
함정 — side effects. 일부 module 이 import 만으로 부수 효과 발생 (polyfill, CSS injection 등):
{
"name": "my-lib",
"sideEffects": false ← 안전하게 tree-shake 가능
}
{
"name": "my-lib",
"sideEffects": ["./src/polyfill.js"] ← 이 파일만 보존
}Code Splitting — chunk 분리
// 정적 import → 같은 chunk
import {Heavy} from './Heavy';
// 동적 import → 별개 chunk
const Heavy = lazy(() => import('./Heavy'));
// Bundler 출력:
main.abc.js ← 초기 로드
Heavy.def.chunk.js ← lazy loadRoute-based splitting — 각 page 가 별개 chunk. 첫 페이지 로드 가벼움.
Vendor splitting — npm 의존성을 별개 chunk (lodash, react 등). app code 변경 시 vendor chunk 캐시 유효.
Minification
- 변수명 축약 (
userInput→a) - 공백·주석 제거
- dead code elimination
- constant folding (
1+1→2)
Terser (JavaScript), cssnano (CSS), html-minifier (HTML).
Step 5 — Output + asset hashing
dist/
├── index.html ← 진입점
├── assets/
│ ├── main-a1b2c3.js ← content hash
│ ├── main-a1b2c3.js.map ← source map
│ ├── chunk-Heavy-d4e5f6.js
│ ├── logo-7890ab.png
│ └── styles-cdef12.css
asset URL 의 hash:
- 파일 내용 hash → 변경 시 hash 변경
- 변경 X 면 같은 hash → CDN cache hit
- index.html 만 작아서 매번 새로 받음Source map — bundle 의 byte position 을 원본 source 와 매핑. production 의 minified code 디버깅을 원본으로.
Bundler 비교 — Webpack vs Vite vs esbuild
Webpack (2014)
- 역사상 standard. configurable 의 끝판왕
- JavaScript 로 작성 → 느림 (큰 project 30s+ build)
- plugin / loader ecosystem 거대
- dev server 가 모든 코드 bundle 후 serve → HMR 빠르지만 cold start 느림
esbuild (2020, Go)
- 10-100 배 빠름 — Go 로 작성, parallel
- compromise — plugin 적음, CSS 일부 미지원
- library / 단순 script 의 최강 build tool
- Vite 의 dev server 가 내부적으로 사용
Vite (2020, Evan You)
- dev = native ESM. browser 가 직접
importresolve. bundling 거의 안 함. - node_modules 만 esbuild 로 pre-bundle. project code 는 browser 가 on-demand fetch.
- 첫 페이지 로드만 — 이후 변경된 file 만 재처리. HMR 매우 빠름.
- production = Rollup (mature, stable, tree-shake 강).
Turbopack (2022, Vercel)
- Webpack 후속작. Next.js 의 새 default.
- Rust + incremental compilation
- 2024 기준 beta, production 진입
Rolldown (2024)
- Vite 의 Rust 기반 production bundler 후속
- Rollup 호환 + 10× 성능
왜 Vite 가 빠른가 — native ESM dev
Webpack dev server:
- 모든 코드 + node_modules bundle (수만 module)
- 첫 cold start: 10-30 초
- 코드 변경 → bundle 재계산 → HMR (보통 빠름)
Vite dev server:
- node_modules 만 esbuild 로 pre-bundle (~수초)
- project 코드는 browser 가 <script type="module"> 로 fetch
- 첫 페이지 — 필요한 module 만 fetch (lazy)
- 코드 변경 → 그 module 1 개만 재처리 + HMR
- 첫 cold start: 1 초 미만Trade-off — initial page load 가 module N 개 fetch (HTTP/2 multiplex 로 완화). production build 는 어차피 Rollup 으로 bundle — dev 만 native ESM.
HMR — Hot Module Replacement
편집한 file 만 새로고침 없이 교체:
// Button.tsx 변경:
1. file watcher 가 변경 감지
2. bundler 가 Button.tsx 만 재컴파일
3. WebSocket 으로 browser 에 push
4. browser 가 새 Button module 받음
5. React 의 HMR runtime 이 Button 컴포넌트만 swap
- state 보존
- 다른 컴포넌트 영향 XReact Fast Refresh (Next.js / Vite) 는 React-aware HMR — 컴포넌트 state 까지 보존. style 변경은 무조건 즉시 (re-render 없음).
흔한 함정
1. tree shaking 안 되는 import
// Bad — entire lodash bundle (~70 KB)
import _ from 'lodash';
_.debounce(fn);
// Good — named import (tree-shake 됨, ~2 KB)
import {debounce} from 'lodash-es';
debounce(fn);
// 또는 deep import
import debounce from 'lodash/debounce';2. dynamic import 가 아닌 경로
// Bad — dynamic path bundler 가 모름
const module = await import(`./pages/${page}.tsx`);
// → bundler 가 ./pages 전체를 chunk 생성
// Good — 명시적 entries
const pages = {
home: () => import('./pages/home'),
about: () => import('./pages/about'),
};
pages[page]();3. CSS-in-JS 의 bundle 크기
styled-components / emotion 의 runtime 이 30 KB+. CSS module 또는 Tailwind 가 작음. Linaria / vanilla-extract 는 zero-runtime.
4. source map 의 production 노출
.map file 이 public 폴더에 박히면 원본 코드 노출. sentry 같은 error tracking 에 upload + public 에서 제거.
5. monorepo 의 module duplication
같은 npm 라이브러리가 여러 package 에 박혀 중복 bundle. yarn / pnpm workspaces hoisting + bundler 의 resolve.dedupe.
참고 자료
- Webpack documentation — webpack.js.org
- Vite 의 "Why Vite" — vitejs.dev
- esbuild benchmark — esbuild.github.io
- Rollup tree-shaking — rollupjs.org
요약
- Bundler 의 5 단계 — module resolution / dependency graph / transformation / optimization / output.
- Tree shaking = ESM 의 dead code 제거. side effects 명시 필요.
- Code splitting — dynamic import + route-based + vendor.
- Asset hashing — content hash 로 CDN cache 영원히. index.html 만 매번 새로.
- Webpack = 표준, 느림. esbuild = Go 로 10-100 배 빠름. Vite = dev native ESM + esbuild pre-bundle + production Rollup. Turbopack = Next.js 의 Rust 후속.
- Vite 가 dev 빠른 이유 — 코드 bundle 안 함, browser 가 ESM 직접 fetch. node_modules 만 pre-bundle.
- HMR = file watcher → bundle 부분 → WebSocket → React Fast Refresh. state 보존.
- 실험 — JavaScript 포매터 로 production bundle 의 minified 코드 펴서 어떻게 optimize 됐는지 확인.