왜 번들링이 필요한가
ESM으로 모듈을 깔끔하게 분리했다고 해서 끝이 아닙니다. 브라우저에서 수백 개의 모듈 파일을 개별로 로드하면 심각한 성능 문제가 발생합니다.
// 모듈이 잘게 분리된 프로젝트
import { Header } from './components/Header.mjs';
import { Footer } from './components/Footer.mjs';
import { formatDate } from './utils/date.mjs';
import { validate } from './utils/validation.mjs';
// ... 수백 개의 import세 가지 이유로 번들링이 필요합니다.
- HTTP 요청 수 감소 , 파일마다 별도의 요청이 필요합니다. HTTP/2가 병렬 전송을 지원하지만 수백 개는 여전히 부담입니다
- 의존성 해결 , npm 패키지의 복잡한 의존성 트리를 브라우저가 직접 해결할 수 없습니다
- 호환성 변환 , TypeScript, JSX 등을 브라우저가 이해하는 JavaScript로 변환해야 합니다
번들러의 동작 원리
번들러 (Webpack, Rollup, esbuild 등) 는 세 단계로 동작합니다.
1단계: 엔트리 포인트
번들러는 하나의 진입점에서 시작합니다.
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js'
}
};2단계: 의존성 그래프
엔트리 파일의 import를 따라가며 전체 의존성 트리를 구축합니다.
index.js
+-- components/App.js
| +-- components/Header.js
| | +-- utils/format.js
| +-- components/Footer.js
+-- utils/api.js
| +-- utils/http.js
+-- styles/main.css3단계: 번들 생성
의존성 그래프를 기반으로 모든 모듈을 하나 (또는 여러 개) 의 파일로 합칩니다.
// bundle.js (단순화)
(function(modules) {
var cache = {};
function require(id) {
if (cache[id]) return cache[id].exports;
var module = cache[id] = { exports: {} };
modules[id](module, module.exports, require);
return module.exports;
}
require(0); // 엔트리 실행
})([
/* 0: index.js */ function(module, exports, require) { /* ... */ },
/* 1: App.js */ function(module, exports, require) { /* ... */ },
/* 2: Header.js */ function(module, exports, require) { /* ... */ },
// ...
]);Tree-shaking이란
Tree-shaking 은 사용하지 않는 코드 (dead code) 를 번들에서 제거하는 최적화 기법입니다. "나무를 흔들어 죽은 잎사귀를 떨어뜨린다"는 비유에서 이름이 왔습니다.
// math.mjs, 4개의 함수를 export
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }
// app.mjs, 2개만 사용
import { add, subtract } from './math.mjs';번들러는 multiply와 divide가 어디에서도 사용되지 않음을 감지하고, 번들에서 제거합니다.
import { add, subtract } from './math.mjs';Tree-shaking에 ESM이 필요한 이유
Tree-shaking의 핵심은 정적 분석 입니다. 코드를 실행하지 않고도 어떤 export가 사용되는지 파악할 수 있어야 합니다.
ESM: 정적 분석 가능
// import/export는 최상위에서만 사용 가능
// 빌드 타임에 어떤 것이 import되는지 확실히 알 수 있음
import { add } from './math.mjs';
// 이런 코드는 불가능, 정적 구조가 보장됨
if (condition) {
import { sub } from './math.mjs'; // SyntaxError
}CommonJS: 정적 분석 불가능
// require()는 어디서든, 어떤 값으로든 호출 가능
const name = condition ? 'math' : 'advanced-math';
const lib = require('./' + name);
// 어떤 모듈의 어떤 함수가 사용되는지 실행 전에는 알 수 없음
if (process.env.NODE_ENV === 'production') {
module.exports = require('./prod');
} else {
module.exports = require('./dev');
}require()는 동적이기 때문에 빌드 타임에 의존 관계를 확정할 수 없습니다. 결과적으로 CJS 모듈은 tree-shaking이 매우 어렵습니다.
Side Effects
tree-shaking에서 까다로운 부분이 side effect (부수 효과) 입니다. import만 해도 전역 상태를 변경하는 모듈이 있습니다.
// polyfill.mjs, import만 해도 전역을 수정
if (!Array.prototype.flat) {
Array.prototype.flat = function() { /* ... */ };
}
// analytics.mjs, import 시 이벤트 리스너 등록
window.addEventListener('load', trackPageView);이런 모듈은 export를 사용하지 않아도 제거하면 안 됩니다. 번들러는 기본적으로 side effect가 있을 수 있다고 가정하므로, tree-shaking이 보수적으로 동작합니다.
sideEffects 필드
package.json의 sideEffects 필드로 번들러에게 힌트를 줄 수 있습니다.
{
"name": "my-library",
"sideEffects": false
}"sideEffects": false는 "이 패키지의 모든 모듈은 부수 효과가 없으니, 사용하지 않는 export는 안전하게 제거해도 된다"는 의미입니다.
특정 파일만 side effect가 있다면 배열로 지정합니다.
{
"sideEffects": [
"./src/polyfills.js",
"*.css"
]
}Tree-shaking 효과를 높이는 방법
// 1. named export 사용 (default export보다 분석이 쉬움)
export function add(a, b) { return a + b; } // Good
export default { add, sub, mul, div }; // Bad, 개별 제거 불가
// 2. 배럴 파일 주의
// index.js (배럴 파일)
export { Button } from './Button';
export { Modal } from './Modal';
export { Tooltip } from './Tooltip';
// Button만 필요해도 Modal, Tooltip의 side effect가 실행될 수 있음
import { Button } from './components';
// 직접 경로가 더 안전
import { Button } from './components/Button';
// 3. 순수 함수 표시
export const result = /*#__PURE__*/ createSomething();
// /*#__PURE__*/ 주석은 이 호출이 부수 효과가 없음을 번들러에게 알림번들러별 특징
| 번들러 | Tree-shaking | 특징 |
|---|---|---|
| Rollup | 최초 도입 | ESM 중심 설계, 라이브러리에 적합 |
| Webpack | v2부터 지원 | 풍부한 생태계, 코드 스플리팅 |
| esbuild | 지원 | Go로 작성, 매우 빠름 |
| Vite | 지원 | 개발 시 번들 없이 ESM 직접 사용, 프로덕션은 Rollup (Vite 8 (2026년 3월) 부터 Rollup과 esbuild를 Rust 기반 Rolldown 으로 통합) |
실전에서 webpack은 import() 동적 import 구문을 만나면 해당 모듈을 별도의 청크 (chunk) 로 분리하여, 사용자가 실제로 필요한 시점에 네트워크로 로드합니다. 이것이 코드 스플리팅(code splitting) 이며, React의 React.lazy()와 함께 사용되어 라우트별 번들을 분리하는 패턴이 일반적입니다. Vite 는 개발 환경에서 번들 없이 브라우저의 네이티브 ESM을 직접 활용합니다. 브라우저가 import 문을 만나면 HTTP 요청으로 해당 모듈을 가져오고, Vite 개발 서버가 이를 중간에서 변환합니다, 번들링 단계가 없으므로 서버 시작이 거의 즉각적입니다.
다음 단계
모듈 시스템을 사용하다 보면 피할 수 없는 문제가 있습니다. 모듈 A가 B를 import하고, B가 다시 A를 import하면 어떻게 될까요? 다음 글에서는 순환 참조 의 동작 방식과 해결 방법을 살펴보겠습니다.