모듈은 어떻게 번들이 되나 #2 — 안 쓰는 코드는 어떻게 사라지나
import한 함수만 번들에 남고 나머지는 사라진다. 누가 지웠나(tree-shaking), 왜 CommonJS에선 안 되나, 왜 개발 서버에선 안 지워지나(dev≠prod), 그리고 import type은 왜 필수인가 — 1편의 정적 그래프 위에서 번들러가 실제로 부리는 것들을 따라갔다.
import { format } from './utils'를 썼더니, 최종 번들엔format만 남고 나머지 함수는 사라졌다. 누가, 언제, 어떻게 지웠을까 — 그리고 왜 개발 서버에선 안 지워질까.
1. Tree-shaking — 안 쓰는 코드는 어떻게 사라지나
1편에서 ESM은 정적이라, 코드를 실행하지 않고도 무엇이 import되는지 안다고 했다. 그 성질이 있으면 번들러는 반대도 알 수 있다 — 무엇이 import되지 않는지. 아무도 가져가지 않는 export는 최종 번들에서 빼도 된다. 이 죽은 코드 제거를 tree-shaking(나무를 흔들어 죽은 잎을 떨군다)이라 부른다.
// utils.js
export function format() {…}
export function parse() {…}
export function legacy() {…} // 아무도 안 씀
// main.js — format만 import
import { format } from './utils';
main.js 가 format 만 가져가면, 번들엔 format 만 남고 parse · legacy 는 잘려나간다.
이게 되려면 세 가지 전제가 모두 필요하다. 그리고 이 셋이 실무에서 자주 깨진다.
- 정적 ESM —
import/export구문이어야 한다. 동적인require위에선 이 분석이 깨진다(2절). - 번들 단계에서만 — tree-shaking은 프로덕션 빌드에서 일어난다. 개발 서버는 보통 tree-shaking을 하지 않는다. (4절의 “dev에선 되는데 빌드는 다름”이 여기서 싹튼다.)
- 부작용이 없어야 — 1편 5절에서 봤듯 import는 곧 모듈 실행이다. “안 쓰는 export”라도 그 모듈이 import되며 전역을 건드렸다면, 함부로 지우면 그 부작용까지 사라진다.
세 번째 때문에 번들러는 조심스럽다. 그래서 우리가 직접 “이 파일들은 부작용이 없으니 안 쓰면 통째로 지워도 된다”를 알려주는 장치가 있다 — package.json 의 sideEffects 필드다.
// 전부 순수 — 안 쓰면 마음껏 제거
{ "sideEffects": false }
// 이 파일들만 부작용 있음 — 나머지는 제거 OK
{ "sideEffects": ["*.css", "./src/polyfills.js"] }
sideEffects: false 는 결국 “우리 코드는 순수(pure) 하다”는 선언이다. 같은 입력에 같은 출력, 외부 상태를 안 건드리는 함수일수록 번들러가 더 공격적으로 가지치기할 수 있다. 부작용을 정직하게 선언하는 것이 번들 크기의 지렛대다.
2. 그럼 CommonJS는? — “동적”이 기능마다 다르게 작동한다
여기서 내가 처음 넘겨짚은 게 있다. CommonJS는 동적이니, 번들러가 tree-shaking도 code splitting도 순환 의존도 다 못 하겠거니 했다. 절반만 맞다. 세 기능이 의존하는 성질이 서로 다르기 때문에, “동적”이 미치는 영향도 제각각이다.
| 기능 | 의존하는 성질 | CJS(동적)에서 |
|---|---|---|
| tree-shaking | 정적 사용 분석 (무엇이 안 쓰이나) | ❌ 거의 못 함 |
| code splitting | 동적 import 경계 표시 (어디서 나누나) | ✅ 문제없이 됨 |
| 순환 의존 | 모듈 시스템 semantics | ⚠️ 번들러가 재현만 |
tree-shaking — 유일하게 정말 못 하는 것. CJS에서 export 이름은 런타임 표현식일 수 있다.
module.exports[localStorage.getItem(Math.random())] = () => {…}; // 합법 — 이름을 실행 전엔 모름
const _ = require('lodash'); // 객체 '전체'를 가져옴
_.map(arr, fn); // map만 쓰지만 번들러는 못 가려냄 → lodash 통째로 포함
빌드 타임엔 무엇이 export되는지 확정조차 못 한다. 그래서 lodash(CJS) 대신 lodash-es(ESM)나 lodash/map 경로 import이 필요했던 것이다.
한 가지 구분. 미니파이어의 dead code elimination(한 파일·스코프 안에서 도달 불가능한 코드를 지우는 것)은 CJS도 받는다. 하지만 그건 모듈 내부 청소지, “이 export는 아무도 안 쓰니 빼자”는 모듈 경계를 넘는 tree-shaking과는 다르다. 후자가 CJS에선 안 된다.
code splitting — CJS여도 된다. 반전이다. code splitting은 “무엇이 안 쓰이나”(사용 분석)가 아니라 “어디서 나누나”(경계 표시) 만 필요하다. 동적 import() 지점이 그 자체로 명시적 경계라, 대상이 CJS든 ESM이든 별도 청크로 나뉜다. 역설적이게도 tree-shaking은 정적이라서 되고, code splitting은 (동적 import라는) 경계 표시만 있으면 된다.
순환 의존 — 번들러가 “해결”이 아니라 “재현”한다. 1편 7절의 번들러 런타임을 떠올려보자. 순환일 때 b 가 a 를 require하는데 a 가 아직 실행 중이면, 아직 안 채워진 빈 exports 객체를 돌려받는다. 번들러는 이 CJS 동작을 고쳐주지 않고 그대로 재현한다. ESM만이 live binding(연결을 값 채움 전에 끝냄)으로 순환에 구조적으로 안전하다.
정리하면, 동적이라 정말 못 하는 건 딱 tree-shaking 하나다.
3. Barrel 파일의 숨은 비용
Barrel 파일은 여러 모듈을 하나의 index.ts 에서 re-export해 “한 곳에서 다 가져오게” 해주는 편의 파일이다. 읽기엔 깔끔하다 — import { Button } from '@/components'. 하지만 대가가 있다.
Button 하나를 원했을 뿐인데, barrel이 export * 로 모든 형제 모듈을 그래프에 끌어들인다. 프로덕션 tree-shaking이 다시 떨궈줄 수도 있지만 — (a) 그 모듈이 부작용 라이브러리거나 (b) tree-shaking을 안 하는 개발 서버에서는 전부 들어온다.
세 가지 실제 비용이 따라온다.
- 개발 서버 지연 — dev는 tree-shaking을 안 하니 barrel이 끌어온 모듈을 전부 변환한다. Next.js는 barrel 우회로 개발 빌드가 15~70% 빨라진 사례를 보고했다.
- 순환 의존 유발 — 상위
index.ts를 서로 import하다 사이클이 생기기 쉽다. - 불확실한 가지치기 — 일부 도구는 barrel 너머의 미사용 export를 안정적으로 못 떨군다.
그래서 실무 규칙은 이렇다. barrel은 공개 패키지의 경계(public edge) 에서만 쓴다 — 라이브러리를 배포할 때 진입점 하나로 묶는 건 정당하다. 하지만 앱 내부에서는 필요한 파일에서 직접 import한다. (모노레포에서 흔히 보는 “상위 index import 금지, barrel 최소화” 컨벤션의 근거가 바로 이것이다.)
4. dev ≠ prod — “개발선 되는데 빌드는 깨짐”의 정체
앞에서 세 번 예고한 함정을 정면으로 본다. 오랫동안 Vite 는 개발과 빌드에 서로 다른 엔진을 썼다.
- 개발(dev server) — esbuild + 브라우저 native ESM. 번들을 안 하고(브라우저가 모듈을 직접 로드), tree-shaking도 안 한다. 목표는 즉각적인 시작과 HMR.
- 빌드(production) — Rollup. 전체 번들 + 청크 분할 + tree-shaking. 목표는 최적화된 산출물.
두 파이프라인이 다르면, 한쪽에서만 나타나는 버그가 생긴다. 대표적으로 barrel의 미사용 모듈이 dev에선 로드되는데 prod에선 사라지는 것 — “개발선 되는데 빌드는 다르게 동작”의 뿌리다.
여기에 하나가 더 있다. Vite는 node_modules 의존성을 미리 번들(pre-bundle) 해 node_modules/.vite/ 에 캐시한다(수백 개 파일 요청을 줄이고 CJS를 ESM으로 변환하기 위해). 그런데 패키지 내부 구조가 바뀌었는데 이 캐시가 갱신 안 되면 does not provide an export named ... 같은 유령 에러가 난다. 1편 도입부에서 “주문처럼 외우던” rm -rf node_modules/.vite 의 정체가 이것이다 — 캐시가 stale이면 지우고 다시 받으면 된다(vite --force 도 같은 일).
이 “두 엔진” 구조 자체가 5년간의 버그 원천이었다. 그래서 2026년, Vite 8은 esbuild와 Rollup을 둘 다 Rolldown(Rust 기반, Rollup 호환 API)으로 통합했다. 개발과 빌드가 같은 엔진을 쓰니, “pre-bundle된 의존성이 prod와 다르게 동작”하던 버그 부류가 사라진다. 첫 pre-bundle도 4.2s에서 1.8s로 빨라졌다.
번들러 지형 전체로 보면, 2023~2024의 속도 전쟁은 대체로 끝났고 이제 선택은 벤치마크가 아니라 아키텍처 적합성이다.
| 도구 | 정체 | 자리 |
|---|---|---|
| Vite | 신규 프로젝트 기본값 | 새 프로젝트의 표준 |
| Rolldown | Rust 번들러, Rollup 호환 | Vite가 베팅한 미래 엔진 |
| Rspack | webpack 호환 드롭인 대체 | 기존 webpack 이전 |
| Turbopack | Next.js 전용 | Next.js 위에서만 |
| esbuild | 초고속 Go 번들러 | 도구의 하부 엔진으로 광범위 |
5. TypeScript — import type 은 왜 필수인가
마지막 조각. TypeScript는 대개 타입 검사(tsc) 와 트랜스파일(esbuild/swc 등) 을 분리해서 쓴다. 속도 때문에 트랜스파일러는 타입을 무시하고 한 파일씩(single-file) 변환한다. 그런데 이게 문제를 낳는다.
트랜스파일러는 한 파일만 보므로, import { User } from './types' 의 User 가 타입인지 값인지 모른다(다른 파일을 안 보니까). 타입이면 컴파일 후 지워야 하고, 값이면 남겨야 하는데 구분을 못 한다.
import type { User } from './types'; // 타입 → 컴파일 후 반드시 사라짐
import { createUser } from './user'; // 값 → 런타임 그래프에 남음
그래서 type 수식어를 명시하면 규칙이 단순해진다 — type 붙은 건 항상 삭제, 안 붙은 건 항상 유지. 이걸 강제하는 옵션이 verbatimModuleSyntax(TS 5.0+)이고, 이는 isolatedModules(파일별 독립 컴파일 보장)를 함의한다. 과거의 importsNotUsedAsValues · preserveValueImports 는 이걸로 대체·폐기됐다.
즉 import type 은 문서용 장식이 아니라 번들 정확성의 문제다. 명시하지 않으면 single-file 트랜스파일러가 타입을 런타임 그래프에 잘못 남기거나, 반대로 값을 지워 is not defined 를 낸다. “타입은 import type” 은 대부분의 프로젝트에서 선택이 아니라 필수다.
정리
한 문장으로 줄이면 이렇다.
정적 그래프 위에서 번들러는 안 쓰는 코드를 지우고(tree-shaking), 코드를 나누고(splitting), 하나로 합친다. 그 자동화의 성립 조건(정적 ESM·순수성·번들 단계)을 알면, “왜 dev에선 되고 prod에선 다른가”까지 한 줄로 설명된다.
- tree-shaking = 정적 ESM + 번들 단계 + 부작용 없음.
sideEffects로 순수성을 선언한다. - CommonJS는 동적이라 tree-shaking만 못 한다. code splitting은 성질이 달라 되고, 순환은 번들러가 재현할 뿐이다.
- barrel은 앱 내부에선 피하고 패키지 경계에서만. 특히 dev 빌드를 무겁게 한다.
- dev와 prod는 다른 엔진이었고, 그 간극이 버그의 원천이었다. Vite 8의 Rolldown 통합이 이를 좁힌다.
import type은 single-file 트랜스파일러의 오판을 막는 번들 정확성 장치다.