Prologue: 200KB짜리 번들의 비밀
프로젝트 빌드를 돌렸는데 번들 사이즈가 200KB였다. 이상했다. 내가 쓴 코드는 겨우 몇 줄인데.
import { debounce } from 'lodash';
function SearchInput() {
const handleSearch = debounce((value) => {
console.log(value);
}, 300);
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
debounce 함수 하나만 썼을 뿐인데, 번들 분석기를 돌려보니 lodash 전체가 들어가 있었다. 70KB짜리 라이브러리가 통째로.
이게 바로 Tree Shaking이 작동하지 않았을 때의 현실이다. 나무를 흔들어서 죽은 잎사귀를 떨어뜨려야 하는데, 그냥 나무 전체를 가져온 격이었다.
The Aha Moment: ES Modules가 핵심이었다
Tree Shaking이 뭔지는 알았다. "사용하지 않는 코드를 번들에서 제거하는 기술"이라고. 근데 왜 작동하지 않는 거지?
문제는 내가 import한 방식에 있었다. 더 정확히는, lodash 라이브러리가 제공하는 모듈 형식에 있었다.
CommonJS vs ES Modules: 정적 vs 동적
CommonJS는 동적이다. 런타임에 뭐가 export되는지 알 수 있다.
// CommonJS - 동적 구조
const utils = require('./utils');
// 이런 것도 가능하다
if (condition) {
const something = require('./something');
}
빌드 타임에는 이 코드가 실제로 무엇을 import하는지 알 수 없다. 조건문 안에 있으면? 반복문 안에서 동적으로 require하면? 불가능하다.
ES Modules는 정적이다. 빌드 타임에 딱 확정된다.
// ES Modules - 정적 구조
import { debounce } from './utils';
// 이건 불가능하다
if (condition) {
import { something } from './something'; // Syntax Error
}
이 차이가 Tree Shaking을 가능하게 만든다. 빌드 도구가 파일을 읽기만 하면 "아, 이 파일에서는 debounce만 쓰는구나"를 알 수 있다. 그래서 나머지를 제거할 수 있다.
결국 이거였다. Tree Shaking은 정적 분석이 가능한 ES Modules에서만 제대로 작동한다.
lodash vs lodash-es
그래서 해결책은 간단했다.
// ❌ CommonJS 방식 - Tree Shaking 안 됨
import { debounce } from 'lodash';
// ✅ ES Modules 방식 - Tree Shaking 됨
import { debounce } from 'lodash-es';
lodash-es는 lodash의 ES Modules 버전이다. 이걸로 바꾸고 빌드하니 번들 사이즈가 200KB에서 15KB로 떨어졌다.
Deep Dive: Tree Shaking을 망치는 것들
1. sideEffects 필드: 순수성을 선언하기
package.json에 이런 필드가 있다.
{
"name": "my-library",
"sideEffects": false
}
이게 뭔가? "이 패키지의 모든 파일은 side effect가 없어요"라는 선언이다.
Side effect란? import만 해도 뭔가 실행되는 코드.
// side-effect.js
console.log('This runs on import!');
window.myGlobal = 'something';
export const myFunction = () => {};
이런 파일을 import하면, myFunction을 안 써도 console.log와 전역 변수 할당이 실행된다. 이게 side effect다.
Tree Shaking할 때 문제가 된다. "이 export는 안 쓰니까 제거해야지" 했다가 side effect까지 제거되면 앱이 깨진다.
그래서 sideEffects: false를 선언하면, 번들러에게 "맘껏 흔들어도 돼, 걱정 마"라고 알려주는 것이다.
특정 파일만 side effect가 있다면?
{
"sideEffects": ["*.css", "src/polyfills.js"]
}
이렇게 CSS 파일이나 polyfill 같은 것만 명시할 수 있다.
2. Barrel Files의 함정
이런 구조를 많이 본다.
// utils/index.ts (Barrel File)
export { debounce } from './debounce';
export { throttle } from './throttle';
export { deepClone } from './deepClone';
export { formatDate } from './formatDate';
// ... 20개 더
편하다. 한 곳에서 다 import할 수 있으니까.
import { debounce } from './utils';
근데 문제가 있다. utils/index.ts를 import하는 순간, 이 파일이 re-export하는 모든 파일을 읽어야 한다.
Webpack 같은 번들러는 똑똑하게 처리하지만, 모든 번들러가 그렇지는 않다. 특히 development mode에서는 성능을 위해 Tree Shaking을 안 하는 경우가 많다.
해결책은?
// ✅ 직접 import
import { debounce } from './utils/debounce';
// 또는 조건부 re-export
export type { DebounceOptions } from './debounce';
export { debounce } from './debounce';
작은 유틸리티 함수들은 barrel file이 괜찮다. 근데 큰 컴포넌트나 무거운 라이브러리를 re-export하는 barrel file은 위험하다.
3. Named Import vs Default Import
이 차이도 중요하다.
// ❌ Default import - 전체를 가져온다
import _ from 'lodash-es';
_.debounce(fn, 300);
// ✅ Named import - 필요한 것만 가져온다
import { debounce } from 'lodash-es';
debounce(fn, 300);
Default import는 전체 객체를 참조한다. Named import는 특정 export만 참조한다. 번들러가 분석하기 쉬운 건 당연히 named import다.
4. 동적 Import의 딜레마
// 정적 import - Tree Shaking 가능
import { analytics } from './analytics';
// 동적 import - Tree Shaking 불가능
const moduleName = getUserPreference();
const module = await import(`./modules/${moduleName}`);
동적 import는 런타임에 결정된다. 빌드 타임에는 뭘 import할지 모른다. Tree Shaking 불가능.
필요하다면 이렇게.
// 조건부지만 정적
const module = condition
? await import('./moduleA')
: await import('./moduleB');
이건 번들러가 "아, moduleA랑 moduleB 둘 다 필요하구나"를 알 수 있다.
5. 라이브러리를 만들 때의 체크리스트
Tree Shaking이 잘 되는 라이브러리를 만들려면?
// package.json
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"sideEffects": false
}
type: "module": ES Modules 기본module필드: ES Modules 버전 경로exports필드: 명시적으로 entry point 지정sideEffects: false: 안전하게 Tree Shaking 가능
그리고 코드 작성 시:
// ❌ 이렇게 하면 Tree Shaking 어려움
const utils = {
debounce: () => {},
throttle: () => {},
};
export default utils;
// ✅ Named export로 각각 분리
export const debounce = () => {};
export const throttle = () => {};
검증: Bundle Analyzer로 확인하기
말로만 "Tree Shaking 잘 된다"고 하면 안 된다. 눈으로 봐야 한다.
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()
]
};
빌드하면 브라우저에 시각화된 번들 맵이 뜬다. 각 파일이 차지하는 용량을 박스 크기로 보여준다.
lodash 전체가 들어갔다면? 큰 박스가 하나 뜬다. lodash-es로 바꾸고 debounce만 import했다면? 아주 작은 박스만 보인다.
Rollup Plugin Visualizer
Vite나 Rollup 쓴다면?
npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
open: true,
gzipSize: true,
})
]
};
똑같이 시각화해준다. gzip 압축된 크기까지 보여줘서 실제 네트워크 전송량을 알 수 있다.
Webpack vs Vite: Tree Shaking의 차이
Webpack과 Vite는 Tree Shaking 방식이 다르다.
Webpack
- Production mode에서만 기본으로 Tree Shaking
- Terser로 dead code elimination
optimization.usedExports: true필요
// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
minimize: true,
}
};
Vite (Rollup)
- 기본적으로 더 공격적인 Tree Shaking
- Development에서도 일부 적용
- ES Modules 네이티브 지원
// vite.config.js
export default {
build: {
rollupOptions: {
treeshake: true, // 기본값
}
}
};
Rollup이 Tree Shaking을 더 잘한다는 평가가 많다. ES Modules를 기반으로 설계됐기 때문이다. Webpack은 CommonJS 시대부터 있었고, Tree Shaking이 나중에 추가됐다.
실제로 같은 코드를 Webpack과 Vite로 빌드하면 Vite 번들이 5-10% 정도 더 작게 나온다. 작은 차이지만, 대규모 앱에서는 체감된다.
Summary: Tree Shaking 체크리스트
이제 정리할 수 있다.
Tree Shaking이 작동하려면:
- ES Modules 사용:
import/export, CommonJS 아님 - Named imports 선호:
import { fn }>import lib - sideEffects 선언: package.json에 명시
- Barrel files 주의: 필요할 때만, 가벼운 것만
- 정적 import: 동적 import 최소화
- Production build: dev mode는 Tree Shaking 안 함
- 검증: Bundle analyzer로 눈으로 확인
라이브러리 만들 때:
- ES Modules 버전 제공 (
module필드) - Named exports 위주로 설계
sideEffects: false선언exports필드로 entry points 명시
흔한 실수들:
- lodash 대신 lodash-es 안 쓰기
- default import로 전체 가져오기
- barrel file에서 무거운 모듈 re-export
- package.json에 sideEffects 명시 안 하기
- development mode에서 "왜 안 되지?" 하기
Tree Shaking은 마법이 아니다. 빌드 도구가 정적 분석할 수 있게 코드를 작성하면, 알아서 죽은 코드를 떨어뜨린다. 나무를 제대로 흔들려면, 나무가 흔들릴 수 있게 심어야 한다는 것. 결국 이거였다.
English Version
Prologue: The Mystery of the 200KB Bundle
I ran a production build and the bundle size was 200KB. Something felt off. I'd only written a few lines of code.
import { debounce } from 'lodash';
function SearchInput() {
const handleSearch = debounce((value) => {
console.log(value);
}, 300);
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
I only used the debounce function, but when I ran the bundle analyzer, the entire lodash library was included. All 70KB of it.
This is what happens when tree shaking doesn't work. You're supposed to shake the tree and let the dead leaves fall off, but instead you brought the whole tree home.
The Aha Moment: It Was All About ES Modules
I understood what tree shaking was. "A technique to remove unused code from your bundle." But why wasn't it working?
The problem was how I imported it. More precisely, the module format that lodash provides.
CommonJS vs ES Modules: Static vs Dynamic
CommonJS is dynamic. You only know what's exported at runtime.
// CommonJS - dynamic structure
const utils = require('./utils');
// This is even possible
if (condition) {
const something = require('./something');
}
At build time, you can't know what this code actually imports. Inside a condition? Dynamically required in a loop? Impossible to analyze.
ES Modules are static. Determined at build time.
// ES Modules - static structure
import { debounce } from './utils';
// This is impossible
if (condition) {
import { something } from './something'; // Syntax Error
}
This difference enables tree shaking. The build tool just reads the file and knows "ah, this file only uses debounce." So it can remove everything else.
That was it. Tree shaking only works properly with ES Modules, which allow static analysis.
lodash vs lodash-es
The solution was simple.
// ❌ CommonJS approach - no tree shaking
import { debounce } from 'lodash';
// ✅ ES Modules approach - tree shaking works
import { debounce } from 'lodash-es';
lodash-es is the ES Modules version of lodash. After switching to it, my bundle size dropped from 200KB to 15KB.
Deep Dive: Things That Break Tree Shaking
1. The sideEffects Field: Declaring Purity
There's a field in package.json like this:
{
"name": "my-library",
"sideEffects": false
}
What does this mean? It declares "all files in this package have no side effects."
What's a side effect? Code that executes just by importing it.
// side-effect.js
console.log('This runs on import!');
window.myGlobal = 'something';
export const myFunction = () => {};
When you import this file, even if you don't use myFunction, the console.log and global variable assignment execute. That's a side effect.
This causes problems with tree shaking. If the bundler thinks "this export isn't used, let's remove it" and removes the side effects too, your app breaks.
So declaring sideEffects: false tells the bundler "shake all you want, don't worry about it."
What if only specific files have side effects?
{
"sideEffects": ["*.css", "src/polyfills.js"]
}
You can specify just CSS files or polyfills like this.
2. The Barrel File Trap
You see this pattern everywhere:
// utils/index.ts (Barrel File)
export { debounce } from './debounce';
export { throttle } from './throttle';
export { deepClone } from './deepClone';
export { formatDate } from './formatDate';
// ... 20 more
It's convenient. You can import everything from one place.
import { debounce } from './utils';
But there's a problem. The moment you import utils/index.ts, the bundler needs to read all the files it re-exports.
Smart bundlers like Webpack handle this well, but not all bundlers do. Especially in development mode, many skip tree shaking for performance.
The solution?
// ✅ Direct import
import { debounce } from './utils/debounce';
// Or conditional re-export
export type { DebounceOptions } from './debounce';
export { debounce } from './debounce';
Barrel files are fine for small utility functions. But barrel files that re-export large components or heavy libraries are dangerous.
3. Named Import vs Default Import
This difference matters too.
// ❌ Default import - brings everything
import _ from 'lodash-es';
_.debounce(fn, 300);
// ✅ Named import - brings only what you need
import { debounce } from 'lodash-es';
debounce(fn, 300);
Default imports reference the entire object. Named imports reference specific exports. It's obvious which one is easier for bundlers to analyze.
4. The Dynamic Import Dilemma
// Static import - tree shaking possible
import { analytics } from './analytics';
// Dynamic import - tree shaking impossible
const moduleName = getUserPreference();
const module = await import(`./modules/${moduleName}`);
Dynamic imports are determined at runtime. At build time, you don't know what will be imported. No tree shaking possible.
If you need conditional imports:
// Conditional but static
const module = condition
? await import('./moduleA')
: await import('./moduleB');
The bundler can see "ah, both moduleA and moduleB are needed."
5. Checklist for Library Authors
Want to make a tree-shakeable library?
// package.json
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"sideEffects": false
}
type: "module": ES Modules by defaultmodulefield: Path to ES Modules versionexportsfield: Explicitly specify entry pointssideEffects: false: Safe to tree shake
And when writing code:
// ❌ This makes tree shaking difficult
const utils = {
debounce: () => {},
throttle: () => {},
};
export default utils;
// ✅ Separate named exports
export const debounce = () => {};
export const throttle = () => {};
Verification: Check with Bundle Analyzer
Don't just say "tree shaking works well." See it with your own eyes.
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()
]
};
After building, a visualized bundle map opens in your browser. Each file's size is shown as a box.
If the entire lodash is included? You see one big box. After switching to lodash-es and importing only debounce? Just a tiny box.
Rollup Plugin Visualizer
Using Vite or Rollup?
npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
open: true,
gzipSize: true,
})
]
};
Same visualization. Even shows gzipped size so you know the actual network transfer size.
Webpack vs Vite: Tree Shaking Differences
Webpack and Vite approach tree shaking differently.
Webpack
- Tree shaking only in production mode by default
- Dead code elimination via Terser
- Requires
optimization.usedExports: true
// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
minimize: true,
}
};
Vite (Rollup)
- More aggressive tree shaking by default
- Partially applied even in development
- Native ES Modules support
// vite.config.js
export default {
build: {
rollupOptions: {
treeshake: true, // default
}
}
};
Rollup is generally considered better at tree shaking. It was designed around ES Modules. Webpack existed since the CommonJS era, and tree shaking was added later.
In practice, building the same code with Webpack and Vite results in Vite bundles being 5-10% smaller. It's a small difference, but it adds up in large applications.