JavaScript 15분 읽기

모듈은 어떻게 번들이 되나 #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.jsformat 만 가져가면, 번들엔 format 만 남고 parse · legacy 는 잘려나간다.

tree-shaking — 왼쪽 utils.js는 format·parse·legacy 세 함수를 export하지만, main.js가 format만 import하면 오른쪽 최종 번들엔 format만 남고 parse·legacy는 제거된다. 가능한 이유는 ESM이 정적이라 무엇이 import되는지 실행 없이 알기 때문

이게 되려면 세 가지 전제가 모두 필요하다. 그리고 이 셋이 실무에서 자주 깨진다.

  • 정적 ESMimport/export 구문이어야 한다. 동적인 require 위에선 이 분석이 깨진다(2절).
  • 번들 단계에서만 — tree-shaking은 프로덕션 빌드에서 일어난다. 개발 서버는 보통 tree-shaking을 하지 않는다. (4절의 “dev에선 되는데 빌드는 다름”이 여기서 싹튼다.)
  • 부작용이 없어야 — 1편 5절에서 봤듯 import는 곧 모듈 실행이다. “안 쓰는 export”라도 그 모듈이 import되며 전역을 건드렸다면, 함부로 지우면 그 부작용까지 사라진다.

세 번째 때문에 번들러는 조심스럽다. 그래서 우리가 직접 “이 파일들은 부작용이 없으니 안 쓰면 통째로 지워도 된다”를 알려주는 장치가 있다 — package.jsonsideEffects 필드다.

// 전부 순수 — 안 쓰면 마음껏 제거
{ "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⚠️ 번들러가 재현만

CommonJS 동적성의 기능별 영향 — tree-shaking은 정적 사용 분석이 필요해 CJS에선 불가(❌), code splitting은 동적 import 경계 표시만 필요해 CJS여도 가능(✅), 순환 의존은 모듈 시스템 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절의 번들러 런타임을 떠올려보자. 순환일 때 ba 를 require하는데 a 가 아직 실행 중이면, 아직 안 채워진 빈 exports 객체를 돌려받는다. 번들러는 이 CJS 동작을 고쳐주지 않고 그대로 재현한다. ESM만이 live binding(연결을 값 채움 전에 끝냄)으로 순환에 구조적으로 안전하다.

정리하면, 동적이라 정말 못 하는 건 딱 tree-shaking 하나다.

3. Barrel 파일의 숨은 비용

Barrel 파일은 여러 모듈을 하나의 index.ts 에서 re-export해 “한 곳에서 다 가져오게” 해주는 편의 파일이다. 읽기엔 깔끔하다 — import { Button } from '@/components'. 하지만 대가가 있다.

barrel 파일의 비용 — 소비자가 Button 하나만 import해도 barrel(index.ts의 export *)이 Modal·Chart 등 모든 형제 모듈을 그래프에 끌어들인다. Chart가 무거운 라이브러리를 물고 있으면 그것까지 유입된다

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 도 같은 일).

dev ≠ prod — Vite 7은 개발에 esbuild(번들·tree-shake 안 함), 빌드에 Rollup(번들·tree-shake 함)이라는 두 엔진을 써서 dev/prod 동작이 달랐다. Vite 8은 둘을 모두 Rolldown(Rust, Rollup 호환)이라는 단일 엔진으로 통합해 그 간극을 없앤다

이 “두 엔진” 구조 자체가 5년간의 버그 원천이었다. 그래서 2026년, Vite 8은 esbuild와 Rollup을 둘 다 Rolldown(Rust 기반, Rollup 호환 API)으로 통합했다. 개발과 빌드가 같은 엔진을 쓰니, “pre-bundle된 의존성이 prod와 다르게 동작”하던 버그 부류가 사라진다. 첫 pre-bundle도 4.2s에서 1.8s로 빨라졌다.

번들러 지형 전체로 보면, 2023~2024의 속도 전쟁은 대체로 끝났고 이제 선택은 벤치마크가 아니라 아키텍처 적합성이다.

도구정체자리
Vite신규 프로젝트 기본값새 프로젝트의 표준
RolldownRust 번들러, Rollup 호환Vite가 베팅한 미래 엔진
Rspackwebpack 호환 드롭인 대체기존 webpack 이전
TurbopackNext.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';    // 값 → 런타임 그래프에 남음

import type — import type으로 가져온 타입(User)은 컴파일 후 JS에서 완전히 소거되고, 일반 import로 가져온 값(createUser)은 런타임 모듈 그래프에 남는다. single-file 트랜스파일러는 다른 파일을 안 보므로 이 구분을 명시하지 않으면 오판한다

그래서 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 트랜스파일러의 오판을 막는 번들 정확성 장치다.