본문으로 건너뛰기
yutils

Webpack · Vite · esbuild — bundler 는 무엇을 할까?

bundler 가 단순 "파일 합치기" 외에 하는 일 — module resolution · dependency graph · tree shaking · code splitting · source map · HMR · 그리고 Vite 가 dev 에서 Webpack 보다 100 배 빠른 이유.

약 9분 읽기

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 map

Step 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):

  1. package.jsonexports field (조건부 export)
  2. module field (ESM, tree-shake 친화)
  3. main field (CommonJS, fallback)
  4. 없으면 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 load

Route-based splitting — 각 page 가 별개 chunk. 첫 페이지 로드 가벼움.

Vendor splitting — npm 의존성을 별개 chunk (lodash, react 등). app code 변경 시 vendor chunk 캐시 유효.

Minification

  • 변수명 축약 (userInputa)
  • 공백·주석 제거
  • dead code elimination
  • constant folding (1+12)

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 가 직접 import resolve. 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 보존
   - 다른 컴포넌트 영향 X

React 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.

참고 자료

요약

  • 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 됐는지 확인.
가이드 목록으로