내용 중 부정확한 부분이 있거나 궁금한 점이 있다면 댓글 달아주시면 감사하겠습니다 :)
문제 상황
유난히 번들 크기가 큰 패키지
모멘트 프로젝트에서 번들 분석을 하던 중, 아이콘 패키지 lucide-react의 비중이 유난히 크게 보였다.
프로젝트에서 실제로 사용하는 아이콘은 약 20개였는데, 번들에 아이콘이 무수히 많이 포함되어있고, node_modules 번들의 1/4 이상을 차지하는 게 이상하다고 판단했다.

트리셰이킹과 관련되었다고 판단하여, 관련 설정을 하나씩 점검했고, 그 결과 특정 옵션을 수정해 번들 크기를 줄일 수 있었다.
하지만 수정하고도 찝찝한 마음이 들었다. 그 옵션은 트리셰이킹을 돕기 위한 옵션으로 알고 있었기 때문이다.
이 글에서는 내가 무엇을 오해했는지, 실제 원인이 무엇이었는지, 그리고 올바른 해결 방법은 무엇이었는지 정리하고자 한다.
사전 지식
트리셰이킹

트리셰이킹은 프로젝트에서 실제로 사용되지 않는 코드를 제거하여 최종 번들 크기를 줄이는 최적화 기법이다.
트리셰이킹이 동작하려면 webpack이 빌드 타임에 어떤 코드가 실제로 쓰이는지, 모듈 간의 관계를 정적으로 파악할 수 있어야 한다. 이를 위해 모듈 시스템이 중요한데, JavaScript에는 두 가지 주요 모듈 시스템이 있다.
모듈 시스템 - CJS, ESM
모듈은 재사용 가능한 코드 단위(파일)로, 각 모듈은 자체 스코프를 가지며 export로 공개하고 import로 가져온다. JavaScript에서 모듈을 다루는 방식에는 두 가지 주요 시스템이 있다.
여기서 핵심은 모듈 관계를 파악하는 시점인데, 이 시점이 트리셰이킹 여부를 결정한다.
| 구분 | CommonJS (CJS) | ES Modules (ESM) |
| import 문법 | require() | import |
| export 문법 | module.exports, exports.x | export, export default |
| 모듈 관계 파악 시점 | 런타임 (코드 실행 중) | 컴파일 타임 (실행 전) |
| Tree Shaking | ❌ 불가 | ✅ 가능 |
CJS의 require()는 런타임에 동적으로 실행되기 때문에 webpack이 빌드 타임에 모듈 관계를 파악할 수 없다. 반면 ESM의 import는 컴파일 타임에 정적으로 분석되므로 webpack이 사용되지 않는 코드를 미리 파악하고 제거할 수 있다.
ESM은 .mjs 확장자를 쓰면 적용되는데, package.json의 type에 module로 지정하면 확장자를 .js로 써도 ESM으로 적용된다.
모멘트 프로젝트는 이를 지정했으므로 ESM 환경에서 동작한다.

해결 과정
webpack의 sideEffects 옵션 수정
optimization.sideEffects: false 설정을 제거하자 트리셰이킹이 다시 동작했고, 프로젝트에서 사용하고 있는 아이콘만 번들에 포함되어 있는 것을 확인했다.

나의 찝찝함 — 아니 이건 트리셰이킹 돕는 옵션 아닌가..?
하지만 나는 해결하고도 찝찝한 마음을 가졌다.
sideEffects: false는 말 그대로 '사이드 이펙트가 없다'라는 뜻을 가지며, 번들러에게 '이 패키지의 모든 파일은 사이드이펙트가 없으니 사용되지 않으면 파일 전체를 통째로 제거해도 된다'는 힌트를 주는 옵션이기 때문이다.
하지만 예상과 달리 오히려 트리셰이킹이 제대로 동작하지 않았고, 트리셰이킹을 돕는다고 알고 있던 옵션을 제거했더니 결과가 더 좋아져서 계속 의문으로 남았다.
일단 옵션을 지움으로써 1차 해결은 됐다. 다른 태스크를 해야 했기에 sideEffects 옵션이 왜 뜻대로 동작하지 않는지는 추후로 미뤘다.
나의 오해 — sideEffects 동명 옵션
추후 다시 보았을 때, 내가 오해했음을 알았다.
앞서 말한 트리셰이킹을 돕는 옵션은 package.json의 sideEffects 옵션이었고, 내가 잘못 설정한 옵션은 webpack의 optimization.sideEffects 옵션이다.

package.json 파일의 sideEffects 플래그를 webpack이 인식하도록 합니다. — webpack 공식문서 중

실제로 나는 webpack.config.js에서 해당 설정을 false로 설정해, webpack이 package.json파일의 sideEffects를 못읽도록 한 것이다. 그래서 해당 플래그를 활용한 파일 단위 최적화를 수행하지 않도록 만든 상태가 되었던 것이다.
옵션 올바르게 수정
webpack의 optimization.sideEffects: true 로 수정했다.
(참고로 기본값은 production 모드에서 true, development 모드에서는 'flag'다. 'flag'는 소스코드 분석 없이 package.json의 sideEffects 표시만 참고하는 모드다.)
// webpack.common.js
optimization: {
// ...
sideEffects: true, // false에서 true로 변경
}
그 후, package.json에 sideEffects를 명시했다. 이 설정은 “사용되지 않더라도 제거하면 안 되는 모듈(파일)”을 번들러에 알려주는 힌트다.
예를 들어 CSS import처럼 모듈을 실행하는 것 자체가 DOM 변경 등의 사이드 이펙트를 발생시키는 경우가 있다.
우리 프로젝트에서는 실제 사이드이펙트가 있는 파일들을 기입했다.
// package.json
{
"sideEffects": [
"*.css",
"@emotion/**/*",
"@sentry/**/*",
"react-ga4/**/*"
]
}
각 파일을 기입한 이유는 다음과 같다.
*.css: CSS import는 스타일 적용 자체가 목적이므로 side effect로 취급되어 제거되면 안 된다@emotion/**/*: Emotion은 렌더링 시 스타일 태그를 생성, 삽입하고 업데이트하는 DOM 조작을 수행하므로 side effect가 있다.@sentry/**/*: Sentry는 런타임에서 에러 추적을 위해 전역 상태와 핸들러를 등록/변경하므로 side effect가 있다.react-ga4/**/*: Google Analytics는 초기화시 전역 상태를 설정하고 네트워크 전송을 수행하므로 side effect가 있다.
근본 원인
node_modules 폴더를 통해 lucide-react 패키지를 까보니, package.json에 "sideEffects": false 설정이 있었다.

왜 이 설정이 중요한지 이해하려면 lucide-react의 구조를 봐야 한다. lucide-react의 진입점 파일은 모든 아이콘을 이렇게 export하고 있다.

프로젝트에서는 다음과 같이 import해서 가져온다.
import { Search } from 'lucide-react'
webpack은 기본적으로 모듈에 side effect가 있는지 확신할 수 없기 때문에 파일 단위 제거를 수행하지 않는다.
따라서 패키지에서 "sideEffects": false를 선언하면 webpack은 해당 패키지의 모듈이 안전하게 제거 가능하다고 판단한다.
앞서 본 것처럼 lucide-react는 이미 package.json에 "sideEffects": false를 선언해 두고 있었다. 즉, 패키지 입장에서는 “사용되지 않는 파일은 통째로 제거해도 안전하다”는 힌트를 제공하고 있었던 셈이다.
하지만 webpack에서 optimization.sideEffects: false로 해당 힌트를 꺼버린 상태가 되어버려, 파일 단위 트리셰이킹이 동작하지 않았고 사용하지 않는 수백 개의 아이콘 파일이 번들에 전부 포함됐던 것이었다.
정리하자면 다음과 같다.

번들 크기 변천사
지금까지 lucide-react 아이콘 전체가 번들에 포함됐던 근본 원인을 살펴봤다. 이제 해결 과정에서 번들 크기가 어떻게 변했는지 단계별로 확인해보자.

먼저 번들 지표에 대해 설명하고 넘어가겠다. 우리가 번들을 분석하면 다음과 같이 3가지 지표를 볼 수 있다.
- Stat: webpack 빌드 정보 기준 코드 크기 (최종 압축 전)
- Parsed: 최종 번들 파일에 실제 들어간 코드 크기 (브라우저가 파싱할 기준 크기)
- Gzipped: Parsed 번들을 gzip으로 압축했을 때의 크기 (네트워크 전송량)
나는 production 환경 & Parsed 기준 측정을 진행하였다.
1. optimization.sideEffects: false : 1.51MB

2. optimization.sideEffects: false 옵션 제거만 한 상태(1차 해결): 769.56 KB

3. optimization.sideEffects: true 및 package.json의 sideEffects 설정: 705.34 KB

번들크기 1.51MB → 705.34 KB (약 53.3% 감소)
확장하기
번들 크기가 성능에 미치는 것
번들 크기는 웹 애플리케이션의 로딩 성능에 영향을 줄 수 있다.
번들 크기가 커지면 네트워크 전송 시간이 증가해 초기 로딩이 느려질 수 있다.
또한 다운로드 이후 브라우저는 자바스크립트를 파싱하고 컴파일하고 실행해야 하는데, 코드 양이 많을수록 이 과정이 오래 걸린다.
이 작업은 메인 스레드에서 수행되기 때문에 자바스크립트가 많을수록 메인 스레드를 더 오래 점유하게 된다.
그 결과 렌더링이나 사용자 인터랙션이 지연될 수 있으며, 경우에 따라 LCP와 같은 사용자 경험 지표에도 영향을 줄 수 있다.
번들 최적화 이후 LCP 측정치도 1.6s → 1.3s로 개선되었다.
물론 LCP는 이미지나 네트워크 상태 등 다양한 요인의 영향을 받지만, 자바스크립트 번들 크기 감소도 메인 스레드 부담을 줄여 렌더링 지연을 완화하는 데 도움을 줄 수 있다.


트리셰이킹 시, 번들러(webpack)는 사용되지 않는 코드를 어떻게 판단하는가?
먼저 코드 예시를 보자
앞서 트리셰이킹은 정적 분석을 기반으로 작동한다 말했다.
이는 import와 export 관계를 분석하여 사용 여부를 판단하는 방식이다.
math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
index.js
import { add } from './math.js'; // add만 사용
console.log(add(1, 2));
번들링 결과 - 사용하지 않는 코드는 번들에 포함되지 않는다.
// subtract, multiply는 제거됨
function add(a, b) {
return a + b;
}
console.log(add(1, 2));
정적 분석의 동작 방식
앞서 코드 예시를 보았다. 실제로 정적분석이 어떻게 진행되는 지 보자.
번들러가 파일을 읽으면 코드를 AST(추상 구문 트리, Abstract Syntax Tree)로 파싱한다.
AST는 코드를 실행하지 않고 구조적으로 표현한 것이다.
원본 코드
export function add(a, b) {
return a + b;
}
추상 구문 트리
{
"type": "ExportNamedDeclaration",
"declaration": {
"type": "FunctionDeclaration",
"id": { "name": "add" }
}
}
번들러는 이 AST를 보고 “이 파일은 add라는 이름을 export한다" 는 걸 실행 없이 알 수 있다.
(AST explorer 에서 직접 파싱해서 확인해볼 수 있다.)
번들러의 실제 분석 과정
- 모든 모듈(파일)을 AST로 파싱한다.
- 각 모듈이 어떤 값을 export 하는지
ExportNamedDeclaration를 수집한다. - 다른 모듈에서 어떤 export를 import하는지
ImportDeclaration를 수집한다. - export 목록과 실제 사용된 import를 대조한다.
- 사용되지 않는 export는 제거 대상으로 표시된다.
트리셰이킹 — usedExports, sideEffects 각각 뭐가 다른가?
webpack은 트리셰이킹을 위해 여러 최적화 옵션을 제공하는데, 그 중 핵심적인 두 가지가 usedExports와 sideEffects다.
첫 번째는 usedExports다. 이는 코드 사용 여부를 분석해 표시하는 역할을 하고, 실제 코드 제거는 Terser 같은 minifier 단계에서 이루어진다. 앞서 살펴본 정적 분석(AST)이 바로 이 과정에서 쓰인다. 보통 트리셰이킹은 이걸로 더 잘 알려져있다고 한다.
두 번째는 sideEffects다. usedExports와 달리 코드를 분석하는 게 아니라, package.json에 개발자가 직접 선언한 힌트를 읽어 파일 자체를 번들에 넣을지 말지를 판단한다.
| 구분 | 판단 대상 | 역할 |
| usedExports | export 단위 | 어떤 export가 사용되는지 분석해 표시 |
| sideEffects | 모듈(파일) 전체 | 사용되지 않은 모듈을 통째로 제거해도 안전한지 판단 |
이번 문제는 usedExports(구문 단위)가 아니라 sideEffects(파일 단위)가 막혀있던 것이었다. lucide-react는 아이콘이 파일별로 분리되어 있어서, sideEffects: false가 제대로 작동했다면 사용하지 않는 아이콘 파일 자체가 통째로 제거됐을 것이다.
마무리
처음엔 1차 해결하고 다른 태스크로 넘어갔지만, 찝찝함을 안고 다시 돌아간 덕분에 더 올바른 설정과 함께 번들 크기를 더 줄일 수 있었다.
이름이 똑같은 옵션 하나 때문에 번들크기가 반절 이상 커졌었다. 이 경험으로 이 옵션이 누구를 위한 설정인지, 그리고 webpack이 이 정보를 어떻게 활용하는지 이해하는 계기가 되었다.
참고 링크
https://webpack.js.org/guides/tree-shaking/
https://webpack.js.org/guides/tree-shaking/#clarifying-tree-shaking-and-sideeffects
https://webpack.js.org/configuration/optimization/#optimizationusedexports
https://web.dev/articles/reduce-javascript-payloads-with-tree-shaking?hl=ko
https://frontend-fundamentals.com/bundling/deep-dive/optimization/tree-shaking.html
'문제 해결 & 구현 기록' 카테고리의 다른 글
| 이미지 최적화 파이프라인 구축기 (2) - S3 이벤트 트리거 기반 Lambda 구현 (0) | 2026.03.26 |
|---|---|
| 이미지 최적화 파이프라인 구축기 (1) - 문제 진단과 방법 선택 (0) | 2026.03.23 |
| 이미지 로딩 속도 개선기 (webp, squoosh) (1) | 2025.12.24 |
| firebase 디지털 지문 SHA 이미 다른 프로젝트에 등록된 키 문제 해결 방법 (2) | 2024.12.10 |
| 안드로이드 스튜디오 iOS 시뮬레이터 실행 에러 해결기 (1) | 2024.12.02 |