왜 Vite는 Webpack 보다 빠를까?

2025. 4. 5.

최근CRA(Create React App)가 지원 종료됨에 따라, 회사에서 CRA로 만들어진 프로젝트를 Vite + React Router로 마이그레이션 했다.

마이그레이션을 하고 나니 HMR(Hot Module Replacement) 반응 속도와 빌드 시간이 개선된 것을 체감할 수 있었다. 그렇다면, ViteWebpack 보다 빠른걸까?

Webpack과 CommonJS의 관계

Webpack을 알기 전 CommonJS를 알아야 한다.

초기의 자바스크립트는 오직 브라우저에서만 작동하는 언어였기에, HTML의 <script /> 태그를 이용해 코드를 작성하거나, 자바스크립트 파일을 순차적으로 불러오는 방식이 일반적이었다. 이런 방식은 크기가 커질 수록 유지보수가 어렵고, 전역 변수의 오염과 충돌 같은 치명적인 단점이 있었다.

// app.js
var name = "minjong";
<script src="app.js"></script>
<script>
  name = "jongmin";
</script>
<script>
  console.log(name); //jongmin
</script>

이미 Java, Python 등의 언어는 import 와 같은 구문으로 명확하게 모듈을 분리하고 있었기 때문에, 커뮤니티에선 require, exports, module 과 같은 인터페이스를 제안하면서 자바스크립트 모듈 시스템을 표준화하려고 했고, Node.js가 이를 채택하면서 표준 모듈 시스템으로 자리잡게 되었다.

Node.jsCommonJS의 등장으로 모듈화된 자바스크립트 코드를 쉽게 만들 수 있게 되었고, 이를 공유하고 배포하기 위해 NPM(Node Package Manager)도 함께 생겨났다.

단순 유틸 함수부터 서버 프레임워크까지 다양한 모듈이 등장했지만, 자바스크립트 표준이 아니기 때문에 브라우저 엔진은 require, module.exports과 같은 구문을 해석하지 못하는 문제가 있었다.

이 문제를 해결하기 위해 CommonJS로 작성된 모듈을 하나의 파일로 번들링하기 위한 번들러가 등장했다. 그리고, 자바스크립트 뿐 아니라 CSS, 이미지, 폰트와 같은 정적 파일도 모듈처럼 번들링하는 Webpack의 등장으로 프론트엔드 생태계가 폭발적으로 성장하게 된다.

Webpack의 CommonJS 처리 과정

그렇다면 Webpack은 어떻게 CommonJS 코드를 브라우저가 실행 가능한 방식으로 번들링 할까?

// minjong.js
module.exports = {
  hello: () => "Hello! My name is minjong!",
};

// app.js
const hello = require("./minjong");
console.log(minjong.hello());

Webpack은 추상 구문 트리를 만들고 코드를 분석한다. require()가 있는 위치와 module.exports가 있는 객체 등을 파싱하고 의존성 그래프를 생성한다.

# minjong.js
Program
└── ExpressionStatement
    └── AssignmentExpression (=)
        ├── Left: MemberExpression
        │   ├── Object: Identifier (module)
        │   └── Property: Identifier (exports)
        └── Right: ObjectExpression
            └── Property
                ├── Key: Identifier (hello)
                └── Value: ArrowFunctionExpression
                    ├── Params: []
                    └── Body: Literal ("Hello! My name is minjong!")
# app.js
Program
├── VariableDeclaration (const)
│   └── VariableDeclarator
│       ├── id: Identifier (minjong)
│       └── init: CallExpression
│           ├── Callee: Identifier (require)
│           └── Arguments:
│               └── Literal ("./minjong")
└── ExpressionStatement
    └── CallExpression
        ├── Callee: MemberExpression
        │   ├── Object: Identifier (minjong)
        │   └── Property: Identifier (hello)
        └── Arguments: []

그리고, 각 모듈을 함수로 감싸고 모듈 ID로 관리하는 일종의 모듈 캐시 시스템을 만든다.

모든 모듈은 모듈 ID: 함수 형태로 modules 객체 안에 들어가고, Webpack이 만든 커스텀 require 함수인 __webpack_require__ 함수로 module.exports를 사용할 수 있게 제공한다.

installedModules 변수는 모듈 캐시 저장소로 활용되어 require()로 같은 모듈을 여러 번 호출하더라도, 한 번만 실행되도록 보장한다.

(function (modules) {
  // 모듈 캐시 저장소
  var installedModules = {};

  function __webpack_require__(moduleId) {
    // 이미 로드된 모듈이라면 캐시를 반환
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    // 새 모듈 객체 생성
    var module = (installedModules[moduleId] = {
      exports: {},
    });

    // 모듈 실행
    modules[moduleId](module, module.exports, __webpack_require__);

    // 모듈 내보내기
    return module.exports;
  }

  // 진입점 실행
  return __webpack_require__("./src/app.js");
})({
  "./src/app.js": function (module, exports, __webpack_require__) {
    const minjong = __webpack_require__("./src/minjong.js");
    console.log(minjong.hello());
  },
  "./src/minjong.js": function (module, exports) {
    module.exports = {
      hello: () => "Hello! My name is minjong!",
    };
  },
});

CommonJS의 한계와 ES Modules의 등장

CommonJSWebpack의 등장으로 웹 프론트엔드 생태계는 폭발적인 성장을 할 수 있었다. 그러나, CommonJS의 런타임에 모듈을 동기적으로 불러오는 방식 때문에 트리 셰이킹과 같은 최적화 작업에 한계가 있었다. 예를들어 lodash 에서 일부 함수만 사용하는 경우 Webpack은 어떤 함수를 사용할지 알지 못하기 때문에 lodash 전체를 번들에 포함시켜버렸다.

문제를 해결하기 위해 커뮤니티에선 AMD(Asynchronous Module Definition)나, UMD(Universal Module Definition) 같은 새로운 대안을 제시하기도 했다. 이런 해결책들은 문법이 복잡해서 개발자 경험이 나쁘거나, 브러우저에 친화적이지 않은 문제가 있었다.

앞서 말한 CommonJS의 단점과 표준 모듈시스템의 부재로 인해 생기는 문제들을 해결하기 위해 ES6에서 ESM(ES Modules)가 등장한다.

import { hello } from "./minjong.js";
console.log(hello());

그렇다면 ESM은 어떻게 CommonJS의 단점을 해결했을까?

import, export

ES6에서 ESM과 함께 추가된 import, export 문법으로 파일을 파싱하기 전에 분석할 수 있게 되었다.

정적 분석이 가능해진 덕에 트리 셰이킹, 코드 스플리팅 같은 애플리케이션 최적화 작업에 더욱 유리해졌고, 더 나아가 IDE에서 자동 완성 기능과 타입 추론 기능이 더욱 강화될 수 있었다.

비동기 로딩 지원

ESM은 비동기적으로 모듈을 로딩하기 때문에 HTML 파싱을 방해하지 않게 되어 더욱 빠른 렌더링을 지원할 수 있게 됐다.

또한, import 구문을 통해 Promise 기반의 동적 로딩이 가능해져서 필요한 시점에만 모듈을 로딩할 수 있게 되었다.

button.addEventListener("click", async () => {
  const module = await import("./modal.js");
  module.openModal();
});

Webpack의 한계와 Vite의 등장

WebpackESM을 지원한다. 그렇지만 WebpackCommonJS 시대에 탄생했기 때문에 ESM을 사용하기에 최적화된 구조는 아니었다.

WebpackESM 코드도 번들링을 했고, Webpack Dev Server는 변경사항을 감지하면 변경사항을 포함하는 모든 모듈을 다시 번들링하는 방식이었기 때문에 애플리케이션이 무거워질수록 개발자 경험이 좋지 못했다.

그래서, 브라우저가 ESM을 직접 해석할 수 있으니까 개발 중에는 번들링을 하지 말자는 철학을 바탕으로 Vite가 등장하게 됐다.

Vite는 개발 중엔 번들링을 아예 하지 않고, CommonJSUMD등 외부 의존성을 감지하면 최초 서버 시작 시 esbuild를 이용해 ESM으로 변환하는 방식을 택했다.

esbuild?

esbuild는 자바스크립트와 타입스크립트 코드를 매우 빠르게 트랜스파일링하고 번들링하는 빌드 도구다. Vite와 독립적인 프로젝트지만, Node.jsCommonJS가 그랬듯, Viteesbuild를 적극적으로 도입하게 되면서 함께 유명해졌다.

esbuildGo 언어로 작성됐다. 인터프리터인 자바스크립트 기반으로 작성된 다른 도구들보다 기계 수준에서 빠르게 실행되고, 자바스크립트로 작성되어 싱글 스레드로 처리됐던 작업을 멀티 스레드로 처리할 수 있다. 공식 홈페이지의 벤치마크를 보면 큰 성능 차이를 확인할 수 있다.

Next.js는 왜 Webpack을 사용할까?

Next.js로 개발할 때 Webpack 관련 설정을 헀던 것 같은데, Next.jsVite를 사용하지 않는걸까?

문득 궁금증이 생겼다. 글을 작성하는 지금 시점에서 프론트엔드 개발에 많이 사용되는 프레임워크인데 왜 훨씬 빠른 Vite를 사용하지 않고 Webpack을 사용하고 있을까?

찾아보니, Next.js는 자체적으로 설계한 Webpack 기반의 번들러 시스템을 사용하고 있다고 한다. Vite는 브라우저 중심의 빠른 개발환경에 집중했기 때문에, SSR(Server Side Rendering)을 위한 Node.js 기반 서버 실행에 구조적인 어려움이 있기 때문이라고 한다.

참고로, Vercel에선 Webpack을 대체할 차세대 번들러로 Turbopack을 준비하고 있다.

결론

CRA 프로젝트를 Vite로 마이그레이션했을 때 HMR 속도가 빨라진 건 개발 서버에서 번들링을 하지 않는 ESM 기반 설계 덕분이었고, 빌드 속도는 Go로 작성된 esbuild 덕분이었다.

마이그레이션 경험은 단순 레거시 코드 개선을 넘어, ESM 기반의 개발환경의 장점을 몸으로 직접 체감할 수 있어서 좋은 경험이었다고 생각한다.