
Tree Shaking: 번들에서 죽은 코드 제거하기
lodash 하나 import했을 뿐인데 번들에 전체 라이브러리가 들어갔다. Tree Shaking이 제대로 작동하게 만드는 법을 정리했다.

lodash 하나 import했을 뿐인데 번들에 전체 라이브러리가 들어갔다. Tree Shaking이 제대로 작동하게 만드는 법을 정리했다.
느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

프로젝트 빌드를 돌렸는데 번들 사이즈가 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이 작동하지 않았을 때의 현실이다. 나무를 흔들어서 죽은 잎사귀를 떨어뜨려야 하는데, 그냥 나무 전체를 가져온 격이었다.
Tree Shaking이 뭔지는 알았다. "사용하지 않는 코드를 번들에서 제거하는 기술"이라고. 근데 왜 작동하지 않는 거지?
문제는 내가 import한 방식에 있었다. 더 정확히는, lodash 라이브러리가 제공하는 모듈 형식에 있었다.
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에서만 제대로 작동한다.
그래서 해결책은 간단했다.
// ❌ CommonJS 방식 - Tree Shaking 안 됨
import { debounce } from 'lodash';
// ✅ ES Modules 방식 - Tree Shaking 됨
import { debounce } from 'lodash-es';
lodash-es는 lodash의 ES Modules 버전이다. 이걸로 바꾸고 빌드하니 번들 사이즈가 200KB에서 15KB로 떨어졌다.
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 같은 것만 명시할 수 있다.
이런 구조를 많이 본다.
// 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은 위험하다.
이 차이도 중요하다.
// ❌ 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다.
// 정적 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 둘 다 필요하구나"를 알 수 있다.
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 = () => {};
말로만 "Tree Shaking 잘 된다"고 하면 안 된다. 눈으로 봐야 한다.
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했다면? 아주 작은 박스만 보인다.
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과 Vite는 Tree Shaking 방식이 다르다.
optimization.usedExports: true 필요// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
minimize: true,
}
};
// vite.config.js
export default {
build: {
rollupOptions: {
treeshake: true, // 기본값
}
}
};
Rollup이 Tree Shaking을 더 잘한다는 평가가 많다. ES Modules를 기반으로 설계됐기 때문이다. Webpack은 CommonJS 시대부터 있었고, Tree Shaking이 나중에 추가됐다.
실제로 같은 코드를 Webpack과 Vite로 빌드하면 Vite 번들이 5-10% 정도 더 작게 나온다. 작은 차이지만, 대규모 앱에서는 체감된다.
이제 정리할 수 있다.
Tree Shaking이 작동하려면:import/export, CommonJS 아님import { fn } > import libmodule 필드)sideEffects: false 선언exports 필드로 entry points 명시Tree Shaking은 마법이 아니다. 빌드 도구가 정적 분석할 수 있게 코드를 작성하면, 알아서 죽은 코드를 떨어뜨린다. 나무를 제대로 흔들려면, 나무가 흔들릴 수 있게 심어야 한다는 것. 결국 이거였다.
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.
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 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.
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.
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.
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.
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.
// 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."
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 defaultmodule field: Path to ES Modules versionexports field: Explicitly specify entry pointssideEffects: false: Safe to tree shakeAnd when writing code:
// ❌ This makes tree shaking difficult
const utils = {
debounce: () => {},
throttle: () => {},
};
export default utils;
// ✅ Separate named exports
export const debounce = () => {};
export const throttle = () => {};
Don't just say "tree shaking works well." See it with your own eyes.
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.
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 and Vite approach tree shaking differently.
optimization.usedExports: true// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
minimize: true,
}
};
// 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.