빌드 & 번들러
Vite는 왜 빠른가 — 번들러 세 가지의 설계 철학 비교
한 줄 요약
번들러는 "여러 모듈을 묶어 브라우저가 실행할 형태로 만드는 도구"이며, webpack은 dev/prod 모두 번들링하는 전통 방식, Vite는 dev에서 번들링을 건너뛰고 브라우저의 네이티브 ESM을 활용하는 방식, Rspack은 webpack을 Rust로 재구현한 고속 드롭인 대체재, Turbopack은 Vercel이 webpack을 새로 설계해 Next.js에 내장한 on-demand 번들러다.
왜 알아야 했나
- 개발 서버가 느리게 켜지거나(콜드 스타트) 저장 후 반영(HMR)이 느릴 때, 그 원인이 번들러 구조에서 나온다는 걸 이해해야 도구를 제대로 고를 수 있다.
- "dev에서는 되는데 build 후엔 다르게 동작한다"는 부류의 버그가 왜 생기는지(엔진 불일치) 알아야 디버깅이 된다.
- 네트워크 탭에서 파일이 어떻게 요청·재요청되는지 관찰할 때, 그 동작 원리를 알아야 정상/이상을 구분할 수 있다.
- 새 프로젝트 도구 선택, 레거시 webpack 프로젝트의 빌드 속도 개선 같은 실무 의사결정에 직접 영향을 준다.
핵심 개념
1. 번들러의 두 가지 철학
- webpack 방식: dev든 prod든 일단 전부 하나로 묶는다. 진입점부터 모든 import를 따라가 의존성 그래프를 만들고, 자체 모듈 시스템(
__webpack_require__)으로 감싼 하나의 번들을 만든다. - Vite 방식: dev에서는 번들링하지 않는다. 브라우저가 네이티브 ESM으로 모듈을 직접 요청하면, 그때그때(on-demand) 그 파일만 변환해서 응답한다. 프로덕션 빌드에서만 번들링한다.
2. Dev Cold Start (개발 서버 콜드 스타트)
- 개발 서버를 처음 켤 때 화면이 뜨기까지 걸리는 시간.
- webpack: 전체 그래프를 크롤링·번들링한 뒤에야 서빙 가능 → 프로젝트가 커질수록 선형적으로 느려짐.
- Vite: 시작 시 무거운 번들링을 건너뛰므로 프로젝트 크기와 거의 무관하게 즉각적.
3. Vite의 개발 모드 동작 (전통적인 방식)
- 서버 기동 & 설정 로드 — vite.config 로드, 플러그인 정렬, dev server + WebSocket 가동. 번들링은 안 함.
- 의존성 사전 번들링(pre-bundling) —
node_modules만 esbuild로 사전 번들. (a) CJS/UMD를 ESM으로 변환, (b) 내부 모듈 수백 개짜리 라이브러리를 하나로 합쳐 요청 폭증 방지. 결과는node_modules/.vite/deps에 캐시. - index.html이 진입점 — HTML의
<script type="module">을 만나면 브라우저가 해당 JS를 직접 GET 요청. - 요청 가로채기 & on-demand 변환 — 요청받은 파일만 변환(JSX/TS→JS는 esbuild, CSS는 JS로 감쌈, bare import는
.vite/deps경로로 재작성). import 그래프 따라 다음 모듈 요청 시 반복. - HMR — 저장 시 watcher 감지 → WebSocket으로 알림 → 변경 모듈만 다시 fetch해 교체.
4. Vite의 프로덕션 빌드 동작 (Rollup 기반)
빌드 페이즈는 options로 시작해 항상 buildEnd로 끝나고, 이어서 출력 생성 페이즈가 돈다.
- 빌드 페이즈:
options/buildStart→resolveId(경로→모듈 id) →load(코드 로드) →transform(TS·JSX·CSS 변환) →moduleParsed(파싱 완료) → 모든 import에 반복 →buildEnd(트리셰이킹). - 출력 생성 페이즈:
renderChunk(압축·코드분할) →generateBundle(파일명 해싱) →writeBundle(디스크 기록) →dist/산출물.
5. 왜 dev와 prod를 나눴나
- dev의 "번들 안 함" 전략을 prod에 그대로 쓰면 모듈마다 HTTP 요청이 폭증해 오히려 느려지고, 트리셰이킹·압축·코드분할 같은 최적화도 안 된다.
- 그래서 prod에서는 반드시 번들링이 필요하고, Vite는 이를 Rollup에 맡겼다.
- 단점: dev 엔진(esbuild)과 build 엔진(Rollup)이 달라 미묘한 동작 차이가 생긴다 → 이걸 없애려고 Rolldown으로 통합(2부 노트 참고).
6. webpack의 개발 모드 동작
- 진입점부터 전체 그래프 크롤링 — dev 서버 시작 시 먼저 끝내야 함.
- 로더(loader)로 변환 —
babel-loader,css-loader등. babel은 esbuild보다 느림. - 하나의 번들로 합침 — 모든 모듈을 함수로 감싸
__webpack_modules__객체에 넣고,import를__webpack_require__()호출로 변환. 브라우저 네이티브 ESM을 안 쓰고 자체 런타임으로 모듈 관리. - 메모리에서 서빙 & HMR — 번들을 메모리에 두고 서빙. 변경 시 manifest + update chunk를 보내 해당 모듈만 교체하되, 영향 부분을 다시 번들링해야 해서 클수록 느려짐.
7. webpack의 배포 모드 동작
- 트리셰이킹 — "표시(mark) 후 제거(remove)" 2단계. 안 쓰는 export에 주석 마킹 후 Terser가 삭제.
- 압축 & 스코프 호이스팅 — Terser로 압축, 여러 모듈을 한 스코프로 합쳐 래핑 오버헤드 감소.
- 코드 분할 & 콘텐츠 해시 —
vendors(라이브러리),main(앱),runtime청크 분리, 파일명에 해시.
8. 왜 webpack과 Vite가 갈렸나 (근본 원인)
- webpack(2014): 당시 브라우저가 ESM을 지원 안 해서, 자체 모듈 시스템을 JS로 구현해 번들링하는 것 외에 선택지가 없었다.
- Vite(2020): 모던 브라우저가 네이티브 ESM을 지원하게 되자, dev에서 모듈 해석을 브라우저에 맡기는 전략이 가능해졌다.
- 즉 webpack이 틀린 게 아니라, 시대적으로 가능해진 접근을 Vite가 채택한 것.
9. Rspack
- ByteDance가 자사 거대 모노레포 빌드를 위해 만든 webpack의 Rust 재구현. webpack API를 현대화한 드롭인 대체재.
- 존재 이유는 속도보다 호환성:
webpack.config.js가 거의 그대로rspack.config.js가 됨. babel-loader, html-webpack-plugin 등 주요 도구 작동(플러그인 호환 약 80%). - 내부: SWC(파싱·변환·압축), 증분 리빌드. Module Federation 일급 지원.
- webpack보다 5~10배 빠른 빌드.
10. Turbopack
- Vercel이 만든 **Rust 기반 번들러로, "webpack의 후계자"**를 표방. Next.js에 내장되어 있음(현재는 Next.js 전용, 추후 프레임워크 독립을 계획).
- Rspack과 결정적 차이: Rspack은 webpack의 **드롭인 대체(설정 거의 동일)**를 노리지만, Turbopack은 webpack을 그대로 베끼지 않고 처음부터 새로 설계한 아키텍처다. 그래서 webpack 설정을 그대로 못 쓰고 마이그레이션에 차이가 있다.
- 독특한 절충점 — "최적화된 dev 번들링": Vite는 dev에서 번들을 안 하고(네이티브 ESM), webpack은 eager하게 다 번들한다. Turbopack은 그 중간으로, dev에서 번들하되 on-demand로(요청된 라우트만) 번들한다. 방문 안 한 라우트는 아예 컴파일 안 함 → 큰 앱에서 세션 내내 빠른 느낌.
- 핵심 엔진 — Turbo Engine: 함수 호출 결과를 전부 캐싱하는 메모이제이션·증분 계산 시스템. "button.tsx가 바뀌면 그 파일 관련 작업만 다시 계산"하도록 설계 → 증분 빌드가 변경 크기에만 비례(앱 전체 크기와 무관)하게 만드는 것이 목표. Rust 컴파일러의 쿼리 시스템, Salsa, Parcel 등에서 영감.
- 통합 그래프(Unified Graph): 클라이언트/서버 등 여러 출력 환경을 별도 컴파일러로 따로 처리하지 않고 하나의 그래프로 처리 → Server/Client Component를 효율적으로 번들. webpack은 큰 JS 객체에 의존해 스레드 간 공유가 어려워 CPU 병렬화가 SWC 변환 단계에만 한정되지만, Turbopack은 작업을 여러 CPU에 걸쳐 병렬화.
- 상태(중요): Next.js 15에서 dev 안정화, Next.js 16(2025.10)부터 dev·프로덕션 모두 기본 번들러가 됨. 16.1(2025.12)에서 파일시스템 캐싱 안정화. 단, 트리셰이킹이 webpack과 달라(더 공격적/덜 공격적인 경우가 섞임)
sideEffects설정이 부정확한 패키지에서 코드가 잘못 제거될 수 있고, 일부 webpack 로더만 지원(JS를 반환하는 로더 위주)되는 제약이 있다.
11. 네 도구 비교 정리
| 항목 | webpack | Vite | Rspack | Turbopack |
|---|---|---|---|---|
| 작성 언어 | JavaScript | (오케스트레이터) | Rust | Rust |
| 내부 번들러 | 자체 | Rolldown(v8~) | 자체(SWC) | 자체(Turbo Engine) |
| 호환 대상 | — | Rollup 플러그인 | webpack(드롭인) | webpack 일부(새 설계) |
| dev 전략 | eager 전체 번들 | 번들 안 함(ESM) | 전체 번들(고속) | on-demand 번들 |
| 만든 곳 | OpenJS재단 | VoidZero(Evan You) | ByteDance | Vercel |
| 주 사용처 | 범용(레거시 多) | 범용/새 프로젝트 | webpack 대체 | Next.js 내장 |
| 마이그레이션 | — | — | 설정 거의 그대로 | Next.js면 자동 |
세 가지 dev 전략 한눈에: webpack은 "시작 전 다 묶음"(느린 콜드 스타트), Vite는 "안 묶고 브라우저에 맡김"(빠르나 큰 앱에서 요청 폭증), Turbopack은 "묶되 방문한 라우트만 묶음"(둘의 절충). 셋의 위치가 dev 성능 특성을 결정한다.
예시 / 코드
같은 원본 코드가 dev와 build에서 어떻게 변하는지:
// 원본 src/main.jsx
import { greet } from './utils.js'
import React from 'react'
console.log(greet('Vite'))
// 원본 src/utils.js
export function greet(name) { return `Hello, ${name}!` }
export function unused() { return 'never called' } // 아무도 안 씀
Vite 개발 모드 — main.jsx 요청 시 브라우저가 받는 것:
import { greet } from '/src/utils.js' // 상대경로만 절대경로화
import React from '/node_modules/.vite/deps/react.js?v=8a3f1c' // bare import 재작성!
console.log(greet('Vite'))
// → utils.js는 아직 안 건드림. 브라우저가 import 만나야 그때 요청
Vite 프로덕션 빌드 — 최종 출력:
// 트리셰이킹(unused 제거) + 압축(greet→g, name→n) 적용
function g(n){return`Hello, ${n}!`}console.log(g("Vite"));
// dist/assets/index-a1b2c3.js (콘텐츠 해시 파일명)
webpack 개발 모드 번들 구조:
(() => {
var __webpack_modules__ = {
"./src/utils.js": ((module, exports) => {
exports.greet = function greet(name) { return `Hello, ${name}!` }
exports.unused = function unused() { return 'never called' } // dev라 남음
}),
"./src/index.js": ((module, exports, __webpack_require__) => {
const { greet } = __webpack_require__("./src/utils.js") // import → 함수 호출
console.log(greet('Webpack'))
}),
};
function __webpack_require__(id) { /* 자체 모듈 런타임 */ }
__webpack_require__("./src/index.js");
})();
Rspack 마이그레이션 (설정 거의 안 바꿈):
npm remove webpack webpack-cli
npm install --save-dev @rspack/core @rspack/cli
# "build": "webpack" → "rspack build"
# webpack.config.js → rspack.config.js
Turbopack (Next.js 내장, 기본값):
// package.json — Next.js 16부터 Turbopack이 기본. 별도 설정 불필요
{
"scripts": {
"dev": "next dev", // Turbopack 사용 (기본)
"build": "next build", // Turbopack 사용 (기본)
// webpack으로 되돌리려면 --webpack 플래그
// "dev": "next dev --webpack"
}
}
// next.config.js — webpack 로더를 Turbopack에서 쓰는 예 (svg)
module.exports = {
turbopack: {
rules: {
'*.svg': { loaders: ['@svgr/webpack'], as: '*.js' },
},
},
}
헷갈렸던 점
- 그림만으로는 이해가 안 됨 → 코드로 보면 명확해짐: 핵심은 "dev 코드는 원본에 최소한의 손질만(사람이 읽을 수 있음), prod 코드는 기계가 읽게 압축·정리(작고 빠름)"라는 것.
- dev에서
unused()가 안 지워지는 이유: 개발 모드는 최적화를 안 함. 어차피 내 컴퓨터에서만 돌고, 지우는 작업 자체가 시간을 잡아먹기 때문. 트리셰이킹은 build에서만. - Vite가 빠른 진짜 이유: 전체를 미리 안 묶고, 브라우저가 실제로 import할 때가 되어서야 그 파일만 변환하는 "필요할 때 그 파일만" 방식.
- Svelte 파일 수정 시 .svelte + layout.css + font가 같이 재요청된 사례:
.svelte→ HMR 대상. dev에선 원본이 아니라 컴파일된 JS가?t=타임스탬프붙어서 옴 (URL이 바뀌어 캐시 무시).layout.css→ 수정한 컴포넌트가 의존하는 CSS 모듈이 함께 무효화되어 재요청. dev에서 CSS는 JS로 감싸져<style>로 주입됨.font→ 의심 지점. CSS 재주입 시@font-face가 재평가되며 폰트 재요청될 수 있으나, 보통은 실제 다운로드가 아니라 캐시 히트(Status304/ Size(memory cache)). 네트워크 탭의 Status·Size 열로 확인 가능.- 인과: 컴포넌트 수정 → 모듈 무효화 → 의존 CSS 무효화 → 새 CSS 주입 → @font-face 재평가 → 폰트 재요청(대개 캐시).
- Rspack vs Turbopack 헷갈림: 둘 다 Rust + webpack 진영이지만, Rspack은 "webpack을 거의 그대로 Rust로(설정 호환)", Turbopack은 "webpack을 버리고 새로 설계(Next.js 전용, 설정 비호환)". 그래서 Rspack은 webpack 레거시 마이그레이션용, Turbopack은 Next.js를 쓸 때 자동으로 따라오는 것.
- dev 전략이 Vite/Turbopack 둘 다 "빠르다"는데 방식이 다름: Vite=번들 안 함, Turbopack=on-demand 번들. 큰 앱에서 요청 수가 많을 땐 Turbopack식 번들이 유리, 작은 앱에선 Vite식이 단순하고 빠름.
실무 적용
- 새 프로젝트 / Vite·Rollup 기반: Vite를 쓰면 됨(현재 8버전은 내부적으로 Rolldown 사용 → 2부 노트).
- webpack 레거시가 크고 빌드가 느림: Rspack 평가. 설정 거의 안 바꾸고 속도만 얻음. 단, 이미 빌드가 충분히 빠르면 그냥 머무는 게 나음.
- Next.js 프로젝트: Turbopack이 기본(Next.js 16+). 따로 고를 필요 없이 따라옴. 단, 복잡한 webpack 로더·플러그인에 의존하는 레거시는 점진적 도입(dev만 먼저, prod는 테스트 후) 권장.
sideEffects미설정 패키지의 트리셰이킹 오제거, dev/prod CSS 클래스 해싱 차이 같은 엣지 케이스 주의. - 네트워크 탭 디버깅: 파일 재요청 시 Status 코드(200 vs 304)와 Size 열(실제 용량 vs
(cache))을 보고 진짜 재다운로드인지 캐시 확인인지 구분. - dev/prod 동작 차이 버그: 엔진 불일치(esbuild vs Rollup)가 원인일 수 있음을 의심.
- 관련 작업: [[Rust vs JavaScript & Rollup/Rolldown]]
더 공부할 것
- Vite 8의 Full Bundle Mode를 직접 켜보고 dev 동작이 어떻게 바뀌는지 관찰
- webpack과 Vite 프로젝트를 각각 만들어
bundle.js와 네트워크 탭을 눈으로 비교 - Module Federation(마이크로프론트엔드)의 동작 원리
- Turbopack의 Turbo Engine(메모이제이션·증분 계산)이 어떻게 변경 크기에만 비례하는 리빌드를 만드는지
- Turbopack의 독립(프레임워크 비종속) 번들러화 로드맵 추적
- Farm 등 다른 차세대 번들러와의 비교
- 트리셰이킹이
moduleSideEffects/sideEffects(부수효과 있는 코드)를 어떻게 다루는지
🔗 참고
- Rollup 플러그인 개발 문서 (빌드 훅): https://rollupjs.org/plugin-development/#build-hooks
- Vite 공식 문서: https://vite.dev
- Rspack 공식: https://rspack.rs
- Turbopack 공식 문서(Next.js): https://nextjs.org/docs/app/api-reference/turbopack
- webpack 공식: https://webpack.js.org
⚠️ 번들러 분야는 매우 빠르게 변함(Vite 8·Rolldown 1.0 출시, Next.js 16의 Turbopack 기본화 등). 실제 도입 시 각 프로젝트 공식 문서에서 최신 버전·기능 상태를 반드시 확인할 것.