← Blog
빌드 & 번들러

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의 개발 모드 동작 (전통적인 방식)

  1. 서버 기동 & 설정 로드 — vite.config 로드, 플러그인 정렬, dev server + WebSocket 가동. 번들링은 안 함.
  2. 의존성 사전 번들링(pre-bundling)node_modules만 esbuild로 사전 번들. (a) CJS/UMD를 ESM으로 변환, (b) 내부 모듈 수백 개짜리 라이브러리를 하나로 합쳐 요청 폭증 방지. 결과는 node_modules/.vite/deps에 캐시.
  3. index.html이 진입점 — HTML의 <script type="module">을 만나면 브라우저가 해당 JS를 직접 GET 요청.
  4. 요청 가로채기 & on-demand 변환 — 요청받은 파일만 변환(JSX/TS→JS는 esbuild, CSS는 JS로 감쌈, bare import는 .vite/deps 경로로 재작성). import 그래프 따라 다음 모듈 요청 시 반복.
  5. HMR — 저장 시 watcher 감지 → WebSocket으로 알림 → 변경 모듈만 다시 fetch해 교체.

4. Vite의 프로덕션 빌드 동작 (Rollup 기반)

빌드 페이즈는 options로 시작해 항상 buildEnd로 끝나고, 이어서 출력 생성 페이즈가 돈다.

  • 빌드 페이즈: options/buildStartresolveId(경로→모듈 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의 개발 모드 동작

  1. 진입점부터 전체 그래프 크롤링 — dev 서버 시작 시 먼저 끝내야 함.
  2. 로더(loader)로 변환babel-loader, css-loader 등. babel은 esbuild보다 느림.
  3. 하나의 번들로 합침 — 모든 모듈을 함수로 감싸 __webpack_modules__ 객체에 넣고, import__webpack_require__() 호출로 변환. 브라우저 네이티브 ESM을 안 쓰고 자체 런타임으로 모듈 관리.
  4. 메모리에서 서빙 & HMR — 번들을 메모리에 두고 서빙. 변경 시 manifest + update chunk를 보내 해당 모듈만 교체하되, 영향 부분을 다시 번들링해야 해서 클수록 느려짐.

7. webpack의 배포 모드 동작

  1. 트리셰이킹 — "표시(mark) 후 제거(remove)" 2단계. 안 쓰는 export에 주석 마킹 후 Terser가 삭제.
  2. 압축 & 스코프 호이스팅 — Terser로 압축, 여러 모듈을 한 스코프로 합쳐 래핑 오버헤드 감소.
  3. 코드 분할 & 콘텐츠 해시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. 네 도구 비교 정리

항목webpackViteRspackTurbopack
작성 언어JavaScript(오케스트레이터)RustRust
내부 번들러자체Rolldown(v8~)자체(SWC)자체(Turbo Engine)
호환 대상Rollup 플러그인webpack(드롭인)webpack 일부(새 설계)
dev 전략eager 전체 번들번들 안 함(ESM)전체 번들(고속)on-demand 번들
만든 곳OpenJS재단VoidZero(Evan You)ByteDanceVercel
주 사용처범용(레거시 多)범용/새 프로젝트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가 재평가되며 폰트 재요청될 수 있으나, 보통은 실제 다운로드가 아니라 캐시 히트(Status 304 / 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(부수효과 있는 코드)를 어떻게 다루는지

🔗 참고

⚠️ 번들러 분야는 매우 빠르게 변함(Vite 8·Rolldown 1.0 출시, Next.js 16의 Turbopack 기본화 등). 실제 도입 시 각 프로젝트 공식 문서에서 최신 버전·기능 상태를 반드시 확인할 것.