JavaScript 18분 읽기

모듈은 어떻게 번들이 되나 #3 — 왜 번들러가 이렇게 많은가

webpack·Rollup·esbuild·Vite·Rspack·Turbopack·Rolldown — 왜 이렇게 많을까. 이름을 외우는 대신 각 도구가 어떤 문제를 풀려고 태어났는지 계보를 따라가면 지형도가 저절로 그려진다. Browserify부터 Rust 세대까지, 그리고 2026년의 수렴 지점.

webpack, Rollup, esbuild, Vite, Rspack, Turbopack, Rolldown. 왜 이렇게 많고, 뭘 골라야 할까. 이름을 외우는 대신, 각자가 어떤 문제를 풀려고 태어났는지를 따라가면 하나의 이야기가 된다.


1. 번들러는 정확히 무슨 일을 하나

먼저 이 질문부터 정리해야 지형이 보인다. 번들러가 하는 일은 사실 두 가지다.

  1. 변환(transform) — 브라우저가 모르는 걸 아는 것으로 바꾼다. TypeScript → JS, JSX → JS, 최신 문법 → 구형 문법.
  2. 번들(bundle) — 흩어진 모듈 수백 개를 의존성 그래프로 엮어 몇 개 파일로 합친다. 이 과정에 tree-shaking(1·2편), 코드 분할, 압축이 붙는다.

번들러의 두 일 — 왼쪽 소스 모듈들(Button.tsx·utils.ts·style.css, 브라우저가 모름)이 ① 변환 단계(TS·JSX를 JS로, 담당: esbuild·SWC·Oxc)를 거쳐, ② 번들 단계(그래프로 엮고 tree-shake·청크 분할, 담당: Rollup·Rolldown·webpack)로 하나의 bundle.js·chunk-a.js가 되어 브라우저가 실행한다. 도구들의 차이는 이 둘 중 무엇을·무슨 언어로·언제 하느냐의 조합이다

지금 쏟아지는 도구들의 차이는 대부분 “이 둘 중 무엇을, 무슨 언어로, 언제(dev냐 prod냐) 하느냐” 의 조합이다. 이 프레임만 손에 쥐면 나머지는 계보로 풀린다.

애초에 왜 필요했나. 브라우저는 오랫동안 모듈을 몰랐다(1편 주제). 그런데 npm 생태계는 CommonJS(require)로 폭발했고, 브라우저는 require를 실행할 수 없었다. 모듈마다 <script> 태그를 다는 방식은 수백 개 단위에서 관리 불가능한 데다 HTTP 요청 폭포를 만들었다. “npm 코드를 브라우저에서 돌리자” 가 번들러의 출발점이다.

2. 계보 — 세대마다 푼 문제

번들러 계보 여섯 세대 — Browserify(2013, npm require를 브라우저로) → Rollup(2014~, ESM tree-shaking·flat output, 라이브러리용) → webpack(2015, 모든 자산을 그래프로·code split·HMR, 앱 표준이나 JS라 느림) → esbuild(2020, Go 네이티브로 10100배, 변환 엔진으로 자리잡음) → Vite(2020, dev는 번들 안 함·native ESM, 단 dev=esbuild/prod=Rollup 두 엔진) → Rust 세대(2023, Rspack=webpack 호환·Turbopack=Next 전용·Rolldown=Vite 통합). 각 세대의 한계가 다음 세대의 문제로 이어진다

① Browserify (2013) — “npm 코드를 브라우저로”

  • 문제: CommonJS require()가 브라우저에서 안 돎.
  • 해법: entry 파일에서 require 그래프를 순회해 하나의 .js로 합침.
  • 한계: JS/CJS만 다룸. CSS·이미지 같은 자산은 관심 밖.

② webpack (2015) — “모든 것을 하나의 그래프로”

  • 문제: 앱이 커지며 JS뿐 아니라 CSS·이미지·폰트도 의존성으로 다뤄야 했고, 코드 분할과 HMR(수정 즉시 반영)이 필요해졌다.
  • 해법: loader(변환) + plugin(확장) + 모든 자산의 의존성 그래프 + code splitting + HMR. React 생태계가 채택하며 표준이 됐다.
  • 공격 대상: “앱 전체를 하나의 그래프로 관리.”
  • 한계: 설정이 복잡하고, JS로 작성돼 대형 앱에서 느리다(cold start·HMR 지연). 이 느림이 다음 세대 전부의 출발점이 된다.

③ Rollup (2014~) — “라이브러리를 위한 깔끔한 번들”

webpack과 같은 시기, 다른 문제를 공격했다.

  • 문제(Rich Harris): 기존 번들러가 모듈마다 wrapper와 module loader boilerplate를 씌워 번들을 부풀리고, 모바일 로딩이 느렸다.
  • 해법: ESM 기반 tree-shaking(“죽은 코드를 빼는” 게 아니라 “살아있는 코드만 포함”이라는 관점의 전환) + flat output(사람이 손으로 쓴 것처럼 wrapper 없는 결과물). ESM의 정적 성질이 있어야 가능한 기법이라 Rollup이 ESM을 밀었다.
  • 공격 대상: webpack이 에 강하다면, Rollup은 라이브러리에 강하다.
  • 지금도 유효: 라이브러리 저자의 표준. 정밀한 tree-shaking, 다중 포맷 출력, 읽기 쉬운 산출물.

④ esbuild (2020) — “번들러를 JS로 짠 게 문제다”

  • 문제: webpack·Rollup이 느린 근본 원인은 번들러 자체가 JavaScript로 작성됐다는 것. 명령줄 도구는 JIT 언어에 최악의 무대다 — Node가 번들러 코드를 파싱하는 동안에도 시간이 샌다.
  • 해법: Go로 작성 → 네이티브 병렬 실행 + 스레드 간 공유 메모리 + single-pass(파싱·변환·최적화를 한 번에). 기존 대비 10~100배.
  • 공격 대상: “번들링을 JS 런타임 위에서 하는 것 자체가 병목.”
  • 한계: 고급 코드 분할·플러그인 생태계 성숙도가 낮다. 그래서 esbuild는 최종 번들러라기보다 “변환 엔진·하부 도구”로 자리잡았다 — 다른 도구 안에서 돌아간다.

여기까지가 “번들러란 무엇인가”의 1막이다. 2막은 dev와 prod를 분리해서 생각한 Vite에서 시작한다.

3. Vite — dev와 prod를 아예 다르게 대하다

Vite는 번들러라기보다 “dev를 어떻게 빠르게 할까”에 대한 답이다.

  • 문제: webpack류는 dev 서버를 켜기 전에 앱 전체를 미리 번들해야 첫 화면이 뜬다 → 앱이 클수록 시작이 느려진다.
  • Vite의 통찰:
    • dev에선 번들하지 않는다. 브라우저 native ESM으로 지금 이 페이지에 필요한 모듈만 on-demand로 서빙. 앱 크기와 무관하게 시작이 즉각적이다.
    • 단, node_modules 의존성은 esbuild로 한 번 pre-bundle해서 캐시한다(수백 개 요청을 줄이고 CJS를 ESM으로 변환하려고). 이 캐시가 node_modules/.vite/이고, 낡으면 그 유명한 does not provide an export 유령 에러가 난다.
    • prod에선 여전히 번들한다. 번들 안 한 ESM을 그대로 배포하면 nested import가 네트워크 요청 폭포를 만들기 때문이다.

Vite의 dev와 prod — 왼쪽 개발 모드는 번들하지 않고 브라우저가 native ESM으로 필요한 모듈만 on-demand 요청, 소스는 변환만 하고 의존성만 esbuild로 pre-bundle해 .vite에 캐시(낡으면 유령 에러), 앱 크기와 무관하게 즉각 시작. 오른쪽 빌드 모드는 번들하지 않으면 nested import가 네트워크 요청 폭포가 되므로 모듈을 합쳐 tree-shake·청크 분할·압축. 두 모드가 다른 엔진이면 'dev OK, prod 깨짐'이 생기고, Vite 8은 둘 다 Rolldown 한 엔진으로 통합해 간극을 없앤다

  • 그래서 두 엔진: dev = esbuild(빠른 변환), prod = Rollup(성숙한 tree-shaking·코드 분할·플러그인 생태계). Vite가 성공한 큰 이유가 Rollup의 플러그인 API를 그대로 채택한 것이다.
  • 대가 = 2편에서 본 그 간극: dev와 prod가 다른 엔진 → 두 플러그인 시스템 → 둘을 동기화하는 glue code → “dev에선 되는데 prod에선 깨짐”. 대표적으로 CommonJS default import가 dev(esbuild)에선 잘 되다가 prod(Rollup) 번들에선 undefined로 나오던 것 — 두 도구의 interop 처리가 달라서 생긴 일이다.

4. Rust 세대 (2023~) — 속도를 다시, 그리고 “옮기는 비용”을 공격

esbuild가 “네이티브로 짜면 빠르다”를 증명하자, 다음 세대는 각자 다른 제약을 안고 Rust로 몰려왔다.

Rspack — “webpack을 버리지 않고 빠르게”

  • 문제: webpack은 느린데, 대규모 조직은 이미 webpack config·plugin·Module Federation에 깊이 묶여 있어 Vite로 갈아탈 수 없다.
  • 해법: webpack의 API를 Rust로 재구현 = drop-in 교체. 기존 설정을 약 95% 유지하고 로더·플러그인 대부분이 그대로 동작한다. 5~10배 빨라진다. 마이그레이션이 “며칠”이 아니라 “몇 시간.”
  • 공격 대상: 성능이 아니라 마이그레이션 비용.

Turbopack — Next.js 전용

  • 만든 사람이 webpack 창시자 본인이다.
  • 해법: Rust 기반, Next.js에 깊이 통합, HMR이 초고속(수십 ms).
  • 한계 두 가지: (1) standalone 번들러가 아니다 — Next.js 밖에선 못 쓴다. (2) prod 빌드가 아직 experimental — dev 서버만 안정적이고, prod는 여전히 webpack으로 fallback하는 경우가 많다.

Rolldown + Vite 8 — Vite의 두 엔진을 하나로

  • 문제: §3에서 본 Vite의 dev(esbuild) ≠ prod(Rollup) 간극이 5년짜리 버그의 원천이었다.
  • 해법: Rolldown(Rust, Rollup 호환 API) 단일 엔진이 dev와 prod를 모두 담당하고, Oxc(Rust 파서/트랜스포머)가 파싱·변환을 맡는다. 개발과 빌드가 같은 엔진을 쓰니 “dev OK, prod 깨짐” 부류가 구조적으로 사라진다.
  • 숫자(2026-03-12 Vite 8 stable): Rollup 대비 빌드 10~30배, Linear는 46s→6s, Ramp는 57% 감소를 보고했다.

곁가지지만 중요한 구분: 변환 엔진도 세대교체 중이다. Babel(JS) → SWC(Rust) → esbuild(Go) → Oxc(Rust, Vite 8이 채택). 번들러 지형과 나란히 “트랜스파일러 지형”이 따로 있고, 최신 번들러는 대부분 이 네이티브 트랜스포머를 안에 품고 있다.

5. 2026년 지형도 한 장

성능 전쟁은 사실상 끝났다 — “이제 다 충분히 빠르고, 선택은 벤치마크가 아니라 네 출발점이 결정한다”가 업계 합의다. cold start가 5초를 넘지 않으면 굳이 옮길 이유도 없다는 말까지 나온다.

2026 번들러 지형 표 — Vite 8(Rust, 신규 프로젝트 기본값, 새 앱 React·Vue·Svelte·Solid·Astro), Rspack(Rust, webpack drop-in 대체, 기존 webpack 앱 최소 변경 가속), Turbopack(Rust, Next.js 전용, prod 빌드는 아직 experimental), Rolldown(Rust, Rollup 후계, Vite의 미래 엔진·라이브러리), Rollup(JS, 라이브러리 번들러, npm 패키지·디자인 시스템), esbuild(Go, 초고속 변환 엔진, CI 변환 스텝·다른 도구의 하부), webpack(JS, 1세대 표준, 레거시 유지보수). 한 축으로는 앱이냐 라이브러리냐·신규냐 마이그레이션이냐·최종 번들러냐 하부 변환 엔진이냐로 갈린다

한 축으로 요약하면: 앱이냐 라이브러리냐(Vite/Rspack vs Rollup), 신규냐 마이그레이션이냐(Vite vs Rspack), 최종 번들러냐 하부 변환 엔진이냐(Rolldown/Rollup vs esbuild/Oxc).

6. 이 지형이 실무에서 부딪히는 곳

지형도는 추상적이지 않다. 규모 있는 모노레포에서 곧장 세 가지로 나타난다.

한 저장소에 번들러가 둘 이상 공존한다. 앱마다 사정이 달라 어떤 앱은 Vite로, 어떤 앱은 Rspack(webpack 계열)으로 빌드하는 일이 흔하다. 문제는 여러 앱에 인라인되는 공용 라이브러리다. 이 코드는 두 빌드 도구 양쪽 모두와 호환돼야 한다. 예를 들어 worker나 정적 자산의 경로(base path)를 알아내려 할 때 —

// webpack/Rspack 계열에서만 정의되는 전역
const base = typeof __webpack_public_path__ !== 'undefined'
  ? __webpack_public_path__
  : '/';

이 한 줄은 Rspack 빌드에선 잘 돌지만 Vite 빌드에선 __webpack_public_path__가 없어 항상 '/'로 fallback한다. 그 결과 base가 /가 아닌 앱에서 worker URL이 어긋나 404 → worker가 조용히 죽고 → 화면은 무한 로딩. 해법은 양쪽 규약을 모두 만족시키는 것이다.

const base =
  // Vite 표준
  (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) ||
  // webpack/Rspack 전역
  (typeof __webpack_public_path__ !== 'undefined' ? __webpack_public_path__ : '/');

§1~2에서 본 “webpack 전역 vs Vite의 import.meta.env 규약 차이”가 그대로 버그가 된 사례다.

dev 서버는 prod와 다른 물건이다(§3). webpack 계열 dev 서버의 error overlay는 빌드 자체는 통과하는 무관한 타입 경고에도 화면 전체를 덮는 iframe을 띄운다. 이게 자동화 테스트(E2E)의 클릭을 가로채 엉뚱한 실패를 만든다 — prod 번들엔 아예 없는 코드가.

pre-bundle 캐시가 낡는다(§3). Vite의 node_modules/.vite 캐시가 의존성 구조 변경 후에도 갱신되지 않으면 유령 에러가 난다. --force나 캐시 삭제로 푼다.

세 증상 모두 “번들러가 뭘 하는가”가 아니라 “어떤 번들러가, dev냐 prod냐에서, 어떤 규약으로 하는가” 를 알아야 풀린다.

정리

한 문장으로 줄이면 이렇다.

각 번들러는 “느리다”는 같은 문제를 저마다 다른 제약(라이브러리냐 앱이냐, 새로 짜냐 옮기냐, JS냐 네이티브냐) 아래에서 풀었고, 2026년의 종착점은 “dev와 prod를 같은 네이티브 엔진 하나로”(Vite 8 / Rolldown)로 수렴 중이다.

  • 번들러는 변환번들, 두 일을 한다. 도구의 차이는 무엇을·무슨 언어로·언제 하느냐의 조합이다.
  • 계보: Browserify(npm을 브라우저로) → webpack(모든 자산을 그래프로) / Rollup(라이브러리·tree-shaking) → esbuild(네이티브로 빠르게) → Vite(dev는 번들 안 함) → Rust 세대.
  • Vite의 dev≠prod는 두 엔진 탓이었고, Rolldown 통합이 그 간극을 없앤다.
  • 도구 선택은 벤치마크가 아니라 출발점이 정한다 — 앱이냐 라이브러리냐, 신규냐 마이그레이션이냐.
  • 한 저장소에 번들러가 둘이면, 공용 코드는 양쪽 규약을 모두 만족해야 한다.

참고