Frontend

[Frontend] Webpack, Vite, esbuild 뜯어보기

teddy bear 2026. 7. 8. 17:01
반응형

들어가며

npm run build 한 줄이면 끝나는 세상이다. 그래서 오히려 아무도 그 안에서 무슨 일이 일어나는지 궁금해하지 않는다. 나도 그랬다. Webpack 설정 파일에 loader와 plugin을 이름만 보고 복붙해 넣던 시절이 있었다.

전환점은 빌드 시간이 8분을 넘기던 프로젝트를 최적화해달라는 요청을 받았을 때였다. webpack-bundle-analyzer를 열어봐도, 뭘 어떻게 줄여야 할지 감이 안 왔다. 번들러가 내부적으로 무슨 작업을 어떤 순서로 하는지 모르니, 어디를 건드려야 시간이 줄어드는지도 알 수 없었던 거다.

이 글은 그때 파고들며 정리한 내용이다. Webpack, Vite, esbuild가 각각 "왜 그렇게 설계됐는지"를 중심으로, 번들러의 공통 동작 원리부터 셋의 근본적인 차이까지 다룬다.


1. 번들러가 존재하는 이유부터

번들러를 이해하려면 먼저 "왜 필요한가"를 짚어야 한다. 브라우저는 원래 <script> 태그를 여러 개 나열하면 각각 별도의 HTTP 요청으로 로드했다. 모듈이 수백 개인 요즘 애플리케이션에서 이 방식은 두 가지 문제를 만든다.

  1. 네트워크 요청 폭발 — 파일 수만큼 요청이 늘어나고, HTTP/1.1 환경에서는 이게 곧바로 로딩 속도 저하로 이어진다.
  2. 모듈 시스템 부재 — 브라우저 네이티브 환경에는 원래 import/export 개념이 없었다(ES Modules가 표준화되기 전까지는). CommonJS로 짠 코드를 브라우저에서 그대로 실행할 방법이 없었다.

번들러는 결국 "여러 개의 모듈 파일을, 브라우저가 이해할 수 있는 형태의 적은 수의 파일로 합쳐주는 도구"다. 이 목적 자체는 Webpack이든 Vite든 esbuild든 동일하다. 다른 건 그 목적을 달성하는 방식과 시점이다.


2. 번들러의 공통 파이프라인

세 도구 모두 겉모습은 다르지만, 내부적으로는 비슷한 단계를 거친다.

엔트리 포인트 지정
      ↓
[파싱] 코드를 AST(추상 구문 트리)로 변환
      ↓
[모듈 해석] import/require 구문을 분석해 의존성 그래프 생성
      ↓
[변환] Babel/SWC 등으로 트랜스파일, JSX 처리 등
      ↓
[번들링] 의존성 그래프를 하나(혹은 여러 개)의 파일로 병합
      ↓
[최적화] 트리 셰이킹, 코드 압축(minify), 코드 스플리팅
      ↓
결과물 출력

이 파이프라인을 이해하고 나면, "왜 Webpack 빌드가 느린가"라는 질문에 좀 더 구체적으로 접근할 수 있다. 파싱이 느린 건지, 의존성 그래프를 그리는 데 시간이 걸리는 건지, 변환(트랜스파일) 단계가 병목인지, 아니면 최적화 단계인지 — 단계를 쪼개서 봐야 원인이 보인다.


3. Webpack — 모든 것을 그래프로 만드는 방식

3-1. 핵심 개념: 모든 파일은 모듈이다

Webpack의 가장 근본적인 아이디어는 "JS뿐 아니라 CSS, 이미지, 폰트까지 전부 모듈로 취급한다"는 것이다. 이게 가능한 이유는 Loader 덕분이다. Loader는 특정 확장자의 파일을 Webpack이 이해할 수 있는 형태(결국은 JS 모듈)로 변환해주는 함수다.

// webpack.config.js
module.exports = {
  module: {
    rules: [
      { test: /\.css$/, use: ['style-loader', 'css-loader'] },
      { test: /\.png$/, use: ['file-loader'] },
    ],
  },
};

여기서 중요한 건 적용 순서가 오른쪽에서 왼쪽이라는 점이다. css-loader가 먼저 CSS를 JS가 다룰 수 있는 형태(CommonJS 모듈)로 바꾸고, 그 결과를 style-loader가 받아서 실제로 <style> 태그를 DOM에 주입하는 코드로 감싼다. 이 순서를 반대로 적어서 빌드가 깨지는 경험은 Webpack을 써본 사람이라면 한 번쯤 겪었을 거다.

3-2. 의존성 그래프 구축과 번들링

Webpack은 엔트리 파일부터 시작해서 import/require 구문을 전부 추적하며 의존성 그래프를 만든다. 이 과정에서 각 모듈에는 고유 ID가 부여되고, 최종 번들은 대략 이런 구조로 만들어진다.

// Webpack 번들 결과물의 단순화된 형태
(function (modules) {
  var cache = {};
  function require(id) {
    if (cache[id]) return cache[id].exports;
    var module = (cache[id] = { exports: {} });
    modules[id](module, module.exports, require);
    return module.exports;
  }
  require(0); // 엔트리 모듈 실행
})({
  0: function (module, exports, require) {
    const util = require(1);
    // ... 엔트리 코드 ...
  },
  1: function (module, exports, require) {
    // ... util 모듈 코드 ...
  },
});

즉, 브라우저에는 원래 없는 require 함수를 번들 안에 직접 구현해서 넣어주는 셈이다. 이 구조를 이해하고 나면 "Webpack 번들이 왜 이렇게 무거운가"에 대한 답의 일부가 보인다 — 런타임 자체(모듈 시스템을 흉내 내는 코드)도 번들에 포함되기 때문이다.

3-3. Webpack이 느린 이유, 그리고 개선 시도들

Webpack은 완전 번들링(bundle-based) 방식이다. 개발 서버를 켤 때조차 전체 의존성 그래프를 분석하고 번들을 만들어야 한다. 프로젝트가 커질수록 이 초기 컴파일 시간은 선형적으로, 때로는 그 이상으로 늘어난다.

Webpack 진영도 이 문제를 알고 있었고, 몇 가지 개선을 시도했다.

  • 캐싱: cache: { type: 'filesystem' } 설정으로 이전 빌드 결과를 재사용해 재빌드 속도 개선
  • SWC/esbuild-loader 도입: 기본 트랜스파일러였던 Babel 대신, Rust/Go로 작성된 더 빠른 트랜스파일러를 loader로 끼워 넣는 방식
  • Module Federation: 여러 앱을 하나의 거대한 번들로 합치지 않고, 런타임에 서로 다른 번들이 모듈을 공유하도록 하는 아키텍처 (마이크로 프론트엔드에서 특히 유용)

이 지점에서 실제로 겪었던 문제를 하나 공유하자면, 위에서 언급한 8분짜리 빌드는 원인을 뜯어보니 babel-loader가 node_modules 내부의 일부 패키지까지 트랜스파일하고 있었다. exclude: /node_modules/ 설정이 정규식 하나 잘못 걸려서 제대로 동작하지 않고 있었던 것. 이 설정 하나 고치는 것만으로 빌드 시간이 8분에서 3분대로 줄었다. 번들러 자체보다 설정 실수가 더 흔한 병목이라는 걸 그때 체감했다.


4. esbuild — 왜 이렇게까지 빠른가

4-1. 언어 선택이 만든 근본적 차이

esbuild가 Webpack보다 수십 배 빠르다는 벤치마크는 유명하다. 이 속도 차이의 상당 부분은 사실 아키텍처보다 더 근본적인 데서 온다 — Go로 작성됐다는 점이다.

JavaScript는 싱글 스레드 언어다. Webpack이나 기존 Babel 기반 도구들은 멀티코어를 활용하려면 워커 프로세스를 별도로 띄우고 그 사이에 직렬화/역직렬화 오버헤드를 감수해야 한다. 반면 Go는 네이티브로 멀티스레딩을 지원하고, 컴파일된 바이너리로 실행되기 때문에 인터프리터/JIT 오버헤드 자체가 없다.

4-2. 파싱부터 압축까지 한 번에

esbuild의 또 다른 핵심은 파이프라인을 아예 재설계했다는 점이다. Babel + Terser 조합을 생각해보면, 코드는 이런 과정을 거친다.

소스 코드 → Babel이 AST로 파싱 → 변환 → 코드로 출력
       → (별도 프로세스) Terser가 다시 파싱 → 압축 → 코드로 출력

즉, 파싱을 두 번 한다. 각 도구가 독립적으로 만들어졌기 때문에 생기는 비효율이다. esbuild는 파싱, 변환, 압축, 번들링을 전부 하나의 파이프라인 안에서, 하나의 AST를 계속 재사용하며 처리한다. 파싱을 한 번만 하는 것만으로도 상당한 시간이 절약된다.

4-3. 트레이드오프: 빠른 대신 잃는 것

esbuild가 빠른 이유는 동시에 esbuild의 한계이기도 하다.

  • 플러그인 생태계가 상대적으로 얕다. Webpack의 수천 개 loader/plugin 생태계에 비하면, esbuild는 아직 커버 범위가 좁다. 특히 복잡한 코드 변환(예: 레거시 데코레이터 문법의 세밀한 처리)이 필요한 경우 한계에 부딪히는 경우가 있었다.
  • 타입 체킹을 하지 않는다. esbuild는 TypeScript 파일에서 타입을 "제거"만 할 뿐, 타입 에러를 검사하지 않는다. 즉 타입이 틀린 코드도 빌드는 성공한다. 그래서 실무에서는 tsc --noEmit을 별도 CI 스텝으로 반드시 함께 돌려야 한다. 이걸 모르고 esbuild만 믿고 있다가, 타입 에러가 있는 코드가 그대로 배포된 적이 실제로 있었다.

5. Vite — 개발 환경과 프로덕션 빌드를 분리한 발상의 전환

5-1. "번들링이 느린 게 문제라면, 개발 중엔 번들링을 하지 말자"

Vite의 핵심 아이디어는 단순하지만 파격적이다. Webpack 기반 개발 서버는 파일을 하나 고쳐도 전체(혹은 관련된) 의존성 그래프를 다시 계산하고 번들을 다시 만든다. 프로젝트가 커질수록 이 재번들링 시간이 길어져서, HMR(Hot Module Replacement) 속도가 느려진다.

Vite는 개발 환경에서 아예 번들링을 생략한다. 대신 **브라우저 네이티브 ES 모듈(ESM)**을 그대로 활용한다.

<!-- Vite 개발 서버가 브라우저에 제공하는 형태 (개념적으로) -->
<script type="module" src="/src/main.js"></script>

브라우저가 main.js를 읽다가 import App from './App.jsx'를 만나면, 브라우저는 이 모듈을 서버에 개별적으로 요청한다. Vite 서버는 요청이 들어온 파일 하나만 즉석에서 변환해서 응답한다. 즉, "필요한 파일만, 요청받은 순간에" 처리하는 방식이라 프로젝트 전체 크기와 무관하게 서버 시작 속도가 거의 일정하다.

5-2. Dependency Pre-bundling — 예외 처리가 필요했던 지점

여기서 한 가지 문제가 생긴다. node_modules에 있는 서드파티 라이브러리들, 특히 CommonJS로 작성된 것들은 브라우저가 ESM처럼 다룰 수 없다. 또한 lodash-es처럼 함수 수백 개가 개별 파일로 쪼개진 라이브러리를 import하면, 브라우저가 함수 하나 쓰겠다고 수백 개의 개별 요청을 보내는 상황이 벌어진다.

Vite는 이 문제를 esbuild로 사전 번들링(pre-bundling) 해서 해결한다. 개발 서버를 처음 켤 때, node_modules 안의 의존성들만 esbuild로 미리 하나의 ESM 번들로 합쳐두고, 이후 요청은 그 사전 번들을 서빙한다. Webpack의 완전 번들링 방식이 여기서만 국소적으로 쓰이는 셈인데, 흥미로운 건 "이걸 esbuild가 담당하기 때문에 매우 빠르다"는 점이다.

[최초 실행 시 1회]
node_modules 의존성들 → esbuild가 사전 번들링 → .vite/deps/ 캐시

[이후 모든 요청]
소스 코드: 브라우저 ESM 요청 → 개별 변환 후 응답
서드파티 의존성: 캐시된 사전 번들 그대로 응답

5-3. 프로덕션 빌드는 왜 다시 Rollup을 쓰는가

여기서 흥미로운 지점이 하나 더 있다. Vite는 개발 환경에서는 번들링을 생략하지만, 프로덕션 빌드에서는 Rollup을 사용해서 제대로 번들링한다.

이유는 명확하다. 개발 환경에서야 브라우저가 파일 수백 개를 개별 요청해도 로컬 네트워크라 체감이 안 되지만, 실제 사용자가 프로덕션 환경에서 파일 수백 개를 개별 요청하면 로딩 속도가 심각하게 느려진다. 또한 Rollup은 Webpack보다 트리 셰이킹 결과물이 더 작고 깔끔하다는 평가를 받는데, 이건 Rollup이 애초에 ESM의 정적 구조(정적으로 분석 가능한 import/export)를 전제로 설계됐기 때문이다.

즉 Vite는 "개발 중엔 esbuild의 속도를, 프로덕션에선 Rollup의 최적화된 번들링 결과를" 각각 상황에 맞게 조합한 도구다. 하나의 번들러가 아니라, 목적에 맞는 도구를 상황별로 조립한 것에 가깝다.


6. 셋을 나란히 놓고 정리하면

Webpack esbuild Vite

핵심 철학 모든 것을 모듈 그래프로 속도를 위해 처음부터 다시 설계 개발/프로덕션 분리 전략
구현 언어 JavaScript Go JavaScript (내부적으로 esbuild/Rollup 활용)
개발 서버 방식 완전 번들링 완전 번들링(단, 매우 빠름) 브라우저 네이티브 ESM + 온디맨드 변환
프로덕션 번들러 자체 자체 Rollup
생태계/플러그인 매우 풍부 상대적으로 제한적 Rollup 플러그인 상당수 호환
적합한 상황 복잡한 레거시, Module Federation 필요 시 라이브러리 빌드, 단순 변환 작업 대부분의 신규 프로젝트

7. 실무에 적용하며 느낀 것

이론을 파고든 뒤 실제로 팀 프로젝트의 개발 서버를 Webpack에서 Vite로 옮겨본 적이 있다. 콜드 스타트 시간이 체감상 크게 줄었고(정확히는 프로젝트 규모에 따라 다르지만, 우리 프로젝트 기준으로는 확연히 차이가 났다), 특히 파일 하나 고쳤을 때 브라우저에 반영되는 속도(HMR)가 눈에 띄게 빨라졌다.

다만 이전 과정이 순탄하지만은 않았다. Webpack 전용으로 작성된 일부 플러그인 설정(raw-loader로 텍스트 파일을 문자열로 불러오던 부분 등)을 Vite의 ?raw 쿼리 방식으로 다시 작성해야 했고, process.env 접근 방식도 Vite의 import.meta.env 체계에 맞게 코드 전반을 수정해야 했다. 이런 세부 사항들은 결국 "번들러가 어떤 전제로 설계됐는지"를 이해하고 있어야 왜 이런 차이가 나는지 납득하고 대응할 수 있었다.

번들러 설정 파일을 단순히 "동작하게만" 만드는 것과, 왜 그렇게 동작해야 하는지 이해하고 만드는 것 사이에는 생각보다 큰 차이가 있다. 후자여야 빌드가 느려졌을 때, 혹은 새 도구로 옮겨야 할 때 근거 있는 판단을 내릴 수 있다.


참고하면 좋을 자료

  • Webpack 공식 문서의 Concepts 섹션 — Loader/Plugin의 개념적 차이를 이해하는 데 도움이 된다
  • esbuild 공식 문서의 Why is esbuild fast? — 저자가 직접 설명하는 설계 근거
  • Vite 공식 문서의 Why Vite — 이 글에서 다룬 개발/프로덕션 분리 전략의 공식 설명