Claude Code로 Tree Shaking 개선하기: 실전 번들 정리
Claude Code로 tree shaking을 개선하는 방법. ESM, sideEffects, 측정, 실패 사례, 실행 예제를 다룹니다.
Tree Shaking을 먼저 쉽게 이해하기
Tree shaking은 프로덕션 빌드에서 실제로 쓰지 않는 JavaScript/TypeScript export를 최종 번들에서 제거하는 최적화입니다. 비유는 나무를 흔들어 마른 잎을 떨어뜨린다는 뜻이지만, 실무에서는 “현재 화면에 필요 없는 코드를 브라우저로 보내지 않는 작업”이라고 보면 됩니다.
문제는 번들러가 개발자의 의도를 읽을 수 없다는 점입니다.
번들러는 import와 export, package.json의 sideEffects, CommonJS 변환 여부, 모듈 최상위에서 실행되는 코드를 보고 제거 가능성을 판단합니다.
그래서 “안 쓰는 함수가 남아 있다”거나 “sideEffects: false를 넣었더니 CSS가 사라졌다” 같은 문제가 생깁니다.
Claude Code에는 막연히 “가볍게 해줘”라고 맡기면 위험합니다. 현재 번들 크기를 측정하고, ESM/CommonJS 경계를 찾고, 부작용이 있는 파일을 분류한 뒤, 작은 범위부터 수정하고 다시 측정하게 해야 합니다. 아래 예시는 Masa가 Vite, React, Astro 기반 프로젝트를 정리할 때 쓰는 실전 흐름입니다.
flowchart LR
A["source files"] --> B["ESM import/export graph"]
B --> C["bundler tree shaking"]
C --> D["minified production bundle"]
B --> E["side effects kept"]
E --> D
D --> F["measure bytes and gzip"]
공식 문서 기준부터 맞추기
Tree shaking은 번들러마다 동작이 조금씩 다릅니다. 프로덕션 코드를 바꾸기 전에는 공식 문서를 기준으로 삼아야 합니다.
| 항목 | 공식 링크 | 실무 체크포인트 |
|---|---|---|
| webpack | Tree Shaking | sideEffects, ESM, production build |
| webpack 설정 | optimization.sideEffects | package의 부작용 표시를 어떻게 읽는지 |
| Rollup/Vite | Rollup treeshake | moduleSideEffects를 전체에 무리하게 적용하지 않기 |
| Rollup 상세 | treeshake.moduleSideEffects | 초기화 모듈 보존 |
| esbuild | Tree shaking | ESM 정적 분석과 metafile 측정 |
핵심은 tree shaking이 문자열 삭제 마법이 아니라는 점입니다. ESM의 정적 의존 그래프를 따라가며, 제거해도 런타임 동작이 바뀌지 않는다고 판단될 때만 코드를 줄입니다. CommonJS, namespace import, 거대한 default object, 최상위 CSS/polyfill import는 기대보다 보수적으로 남을 수 있습니다.
Claude Code에 줄 프롬프트
처음에는 수정이 아니라 조사부터 요청합니다.
큰 프로젝트에 바로 sideEffects: false를 넣으면 스타일이나 초기화 로직이 조용히 깨질 수 있습니다.
이 저장소의 production bundle에서 tree shaking이 잘 되지 않는 원인을 조사해 주세요.
현재 빌드 크기, 주요 chunk, 무거운 의존성, CommonJS 의존성, barrel export를 표로 정리해 주세요.
각 수정안에는 위험, 예상 절감 효과, 검증 명령을 포함해 주세요.
CSS, polyfill, analytics, global setup 파일은 제거하면 안 된다는 전제로 봐 주세요.
수정 단계에서는 범위를 제한합니다.
이번에는 src/utils와 src/components/index.ts만 다뤄 주세요.
default object export를 named export로 바꾸고 import 사용처도 수정해 주세요.
변경 후 npm run build와 bundle 크기 측정을 실행해 주세요.
공개 API가 바뀌면 호환 re-export를 남겨 주세요.
이렇게 요청하면 Claude Code가 “얼마나 지웠는가”보다 “무엇을 유지하면서 줄였는가”에 집중하게 됩니다.
복사해서 실행할 수 있는 최소 측정 예제
다음 예제는 esbuild로 default object export와 named export의 차이를 비교합니다.
mkdir tree-shaking-lab
cd tree-shaking-lab
npm init -y
npm install --save-dev esbuild
mkdir src scripts
package.json을 다음처럼 둡니다.
{
"name": "tree-shaking-lab",
"version": "1.0.0",
"type": "module",
"private": true,
"sideEffects": false,
"scripts": {
"measure": "node scripts/measure-tree-shaking.mjs"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
}
좋지 않은 예시는 여러 함수를 하나의 객체에 담습니다.
// src/bad-utils.ts
const utils = {
formatKrw(amount: number): string {
return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW"
}).format(amount);
},
heavyReport(rows: number[]): string {
const body = rows.map((row) => `row:${row}`).join("\n");
return `report\n${body}\n${"=".repeat(4000)}`;
},
debugOnly(): string {
return "debug:" + "x".repeat(4000);
}
};
export default utils;
더 좋은 예시는 필요한 함수를 개별 export로 둡니다.
// src/good-utils.ts
export function formatKrw(amount: number): string {
return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW"
}).format(amount);
}
export function heavyReport(rows: number[]): string {
const body = rows.map((row) => `row:${row}`).join("\n");
return `report\n${body}\n${"=".repeat(4000)}`;
}
export function debugOnly(): string {
return "debug:" + "x".repeat(4000);
}
엔트리 파일 두 개를 만듭니다.
// src/bad-entry.ts
import utils from "./bad-utils";
console.log(utils.formatKrw(1200));
// src/good-entry.ts
import { formatKrw } from "./good-utils";
console.log(formatKrw(1200));
측정 스크립트입니다.
// scripts/measure-tree-shaking.mjs
import { gzipSync } from "node:zlib";
import { build } from "esbuild";
async function bundle(entryPoint) {
const result = await build({
entryPoints: [entryPoint],
bundle: true,
minify: true,
format: "esm",
treeShaking: true,
write: false,
metafile: true
});
const code = result.outputFiles[0].text;
return {
entryPoint,
bytes: Buffer.byteLength(code),
gzipBytes: gzipSync(code).byteLength,
inputs: Object.keys(result.metafile.inputs)
};
}
const rows = await Promise.all([
bundle("src/bad-entry.ts"),
bundle("src/good-entry.ts")
]);
console.table(rows);
실행합니다.
npm run measure
실제 프로젝트에서는 raw bytes만 보지 말고 chunk 이름, gzip, Brotli, Lighthouse의 Total Blocking Time까지 기록합니다. 어떤 의존성이 남았는지 추적하려면 bundle analysis 가이드와 함께 보세요.
사용 사례1: 유틸리티 모듈 정리
가장 빠른 성과는 보통 utils/index.ts나 helpers.ts에서 나옵니다.
날짜, 금액, 문자열, CSV, Markdown, 디버그 도구가 한 파일에 섞이면 한 함수만 써도 분석이 어려워집니다.
Claude Code에는 이렇게 요청합니다.
src/utils를 목적별 파일로 나눠 주세요.
사용처를 named import로 바꾸고, index.ts에는 공개해야 하는 함수만 re-export해 주세요.
최상위 Date.now, console, localStorage, fetch 호출이 있으면 함수 내부로 옮겨 주세요.
목표 형태는 다음과 같습니다.
// src/utils/formatDate.ts
export function formatDate(date: Date, locale = "ko-KR"): string {
return new Intl.DateTimeFormat(locale).format(date);
}
// src/utils/index.ts
export { formatDate } from "./formatDate";
export { formatKrw } from "./formatKrw";
// src/pages/invoice.ts
import { formatKrw } from "../utils/formatKrw";
export function invoiceLabel(total: number): string {
return `합계: ${formatKrw(total)}`;
}
barrel file 자체가 나쁜 것은 아닙니다.
문제는 초기화 코드를 실행하거나, 넓은 export * from 체인을 만들거나, 관련 없는 모듈을 한 번에 끌어오는 경우입니다.
애플리케이션 내부는 직접 import를 우선하고, 라이브러리 공개 API에는 얇은 barrel만 유지하는 방식이 현실적입니다.
사용 사례2: 사내 UI 컴포넌트 라이브러리
사내 UI 패키지에서는 import { Button } from "@acme/ui" 하나가 Modal, DatePicker, Chart, 아이콘 전체, CSS, 테마 초기화까지 평가하게 만들 수 있습니다.
모든 컴포넌트가 하나의 큰 엔트리를 공유하면 named export만으로는 충분하지 않습니다.
subpath entry를 나누는 편이 좋습니다.
{
"name": "@acme/ui",
"type": "module",
"sideEffects": [
"**/*.css",
"./src/setup-theme.ts"
],
"exports": {
".": "./dist/index.js",
"./button": "./dist/button.js",
"./modal": "./dist/modal.js"
}
}
사용처는 필요한 entry만 import합니다.
import { Button } from "@acme/ui/button";
여기서 sideEffects: false를 무작정 넣으면 CSS나 테마 등록이 사라질 수 있습니다.
webpack의 sideEffects는 React의 useEffect가 아니라, 모듈을 import하는 순간 실행되어야 하는 부작용을 뜻합니다.
사용 사례3: 관리자 기능의 무거운 의존성 지연 로드
Markdown 처리, PDF 생성, 차트, WYSIWYG 에디터는 공개 첫 화면에 필요하지 않은 경우가 많습니다. tree shaking으로 사용하지 않는 export를 줄이고, code splitting으로 관리자 기능을 별도 chunk로 분리합니다.
// src/features/admin/loadMarkdownPreview.ts
export async function renderMarkdown(markdown: string): Promise<string> {
const [{ unified }, remarkParse, remarkHtml] = await Promise.all([
import("unified"),
import("remark-parse"),
import("remark-html")
]);
const file = await unified()
.use(remarkParse.default)
.use(remarkHtml.default)
.process(markdown);
return String(file);
}
동적 import는 tree shaking의 대체제가 아닙니다. 초기 chunk에서 빠질 뿐, 지연 chunk 내부가 CommonJS 중심이면 그 chunk는 여전히 무거울 수 있습니다.
사용 사례4: npm 패키지 배포
라이브러리를 배포한다면 소비자 번들러가 분석하기 쉬운 ESM entry를 제공해야 합니다.
프런트엔드 사용자가 많은 패키지에서 CommonJS main만 노출하면 tree shaking 기대치가 낮아집니다.
{
"name": "@masa/formatters",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./currency": {
"types": "./dist/currency.d.ts",
"import": "./dist/currency.js"
}
}
}
단, CSS, polyfill, custom element 등록, analytics 초기화가 있다면 sideEffects 배열에 명시해야 합니다.
패키지 전체가 정말 import 부작용이 없을 때만 false를 사용하세요.
실패 사례와 함정
| 함정 | 증상 | 대응 |
|---|---|---|
| Babel/TypeScript가 너무 일찍 CommonJS 출력 | unused export가 남음 | 번들러 단계까지 ESM 유지 |
sideEffects: false가 과함 | CSS나 polyfill이 사라짐 | 부작용 파일을 배열로 명시 |
| default object export | 안 쓰는 helper가 함께 남음 | named export로 분리 |
| barrel file의 최상위 초기화 | 컴포넌트 하나 import도 무거움 | barrel은 re-export만 수행 |
| dev build로 측정 | 숫자가 맞지 않음 | production, minify, gzip으로 비교 |
전체 moduleSideEffects: false | setup 코드가 제거됨 | 패키지나 파일 단위로 검증 |
| namespace import | 분석이 보수적임 | 구체적인 named import 사용 |
특히 위험한 것은 CSS 누락처럼 미묘한 화면 깨짐입니다. DOM 존재만 확인하는 테스트는 통과할 수 있습니다. performance optimization처럼 빌드 결과, 주요 화면, 사용자에게 보이는 동작까지 확인해야 합니다.
CI에 번들 예산 넣기
한 번 줄여도 다음 의존성 추가 때 다시 커질 수 있습니다. gzip 기준 예산을 CI에 넣어 PR마다 확인하세요.
// scripts/check-bundle-budget.mjs
import { statSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { readFileSync } from "node:fs";
const file = "dist/assets/index.js";
const maxGzipBytes = 160 * 1024;
const raw = readFileSync(file);
const gzipBytes = gzipSync(raw).byteLength;
if (gzipBytes > maxGzipBytes) {
console.error(`Bundle budget exceeded: ${gzipBytes} > ${maxGzipBytes}`);
process.exit(1);
}
console.log({
file,
bytes: statSync(file).size,
gzipBytes
});
프로덕션 빌드 후 실행합니다.
npm run build
node scripts/check-bundle-budget.mjs
첫 예산은 이상적인 목표가 아니라 현재 gzip 크기에 약간의 여유를 둔 값으로 시작하세요. 크기가 늘어난 PR에는 이유를 남기게 하고, 아직 느리다면 speed optimization 관점에서 이미지, 폰트, API 지연도 함께 봅니다.
Claude Code 리뷰 체크리스트
이 tree-shaking PR을 리뷰해 주세요.
1. 사용하지 않는 export가 production bundle에서 실제로 사라졌나요?
2. CSS, polyfill, 등록 파일은 보존되었나요?
3. ESM이 번들러 분석 단계까지 유지되었나요?
4. 직접 import 변경이 공개 API 호환성을 깨지 않았나요?
5. build, test, 주요 화면 확인, bundle budget 결과는 무엇인가요?
각 답변에 파일명과 명령 증거를 붙여 주세요.
이 체크리스트는 단순 리팩터링을 공개 전 품질 점검으로 바꿉니다.
Masa의 작업에서도 sideEffects 수정 후에는 로그인, 결제, 관리자 화면을 열어 스타일과 초기화가 유지되는지 확인합니다.
수익화와 상담 관점
Tree shaking은 단순한 코드 정리가 아닙니다. 첫 로드가 가벼울수록 글 읽기, 상품 페이지, 가입 폼, 상담 폼까지의 이탈을 줄일 수 있습니다. ClaudeCodeLab 같은 기술 미디어에서는 코드 예제 페이지나 상담 랜딩 페이지가 무거우면 광고와 문의 전환 경로가 약해집니다.
ClaudeCodeLab은 Vite, Next.js, Astro, 사내 UI 라이브러리의 bundle 분석, tree shaking, code splitting, CI 예산화를 함께 진단할 수 있습니다.
상담 시 package.json, 빌드 설정, 주요 route, 최근 bundle report를 준비하면 빠르게 줄일 후보를 찾을 수 있습니다.
정리
Tree shaking은 ESM, 정확한 sideEffects, 통제된 부작용, 지속 측정이 함께 있을 때 효과가 납니다.
Claude Code에는 큰 목표를 한 번에 맡기기보다 조사, 분리, import 수정, 검증, 실패 사례 리뷰를 작은 단위로 맡기는 것이 안전합니다.
이 글의 최소 예제는 로컬에서 npm run measure까지 실행했고, bad entry와 good entry의 출력 크기가 다르게 나오는 것을 확인했습니다.
실제 프로젝트의 숫자는 의존성과 설정에 따라 달라지므로 반드시 자신의 production build에서 측정하고, 보존해야 할 부작용을 먼저 문서화하세요.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code Permission Receipt Pattern: 권한, 증거, 롤백을 남기는 운영
Claude Code 작업마다 허용 범위, 승인 경계, 검증 명령, 롤백 메모, Gumroad와 상담 CTA 확인을 남기는 permission receipt 패턴입니다.
Claude Code/Codex 안전 Agent Harness 설계: 권한, 검증, 롤백
Claude Code와 Codex를 안전하게 운영하기 위한 Agent Harness를 권한 정책, 실행 계획, 검증, 복구 계층으로 설계합니다.
Claude Code 서브에이전트 실전 가이드: 기사와 코드 작업을 안전하게 위임하기
Claude Code 서브에이전트로 기사와 코드 작업을 안전하게 나누는 방법. 위임 규칙, 프롬프트, 실패 사례를 정리합니다.