vite 를 쓰면 react spa 를 만들때 아주 편하다. 이전에 CRA 쓸때랑 비교하면 훨씬 가볍고 신경쓸 것들이 적다.
esbuild 랑 rollup 을 사용해서 엄청나게 빠른 개발경험과 빌드를 제공한다고 알고있다. 사실 대부분이 그정도로만 알고 쓸 것이다. 하지만 나는 그게 왜 빠른지 궁금했다.
근데 CRA가 느린 이유가 babel 이랑 webpack때문이라고 치면, (둘다 js 런타임 기반) esbuild (go) 는 그렇다 쳐도, rollup이 더 빠른건 쉽게 납득이 안간다.
- esbuild로 babel을 완전히 대체했을까?
- rollup은 왜 webpack보다 빠를까?
위 궁금증을 vite 에서 react 앱을 개발한다고 가정하고 해결한다.
esbuild로 babel을 완전히 대체했을까?
우선 첫번째 질문에 대한 답을 찾기 위해, 문서를 본다.

이거는 npm create vite 에서 react 템플릿을 선택하면 자동으로 설치되는 플러그인이다. HMR 기능과 babel 플러그인 구성을 가능하게 한다고 한다. 그리고 추가 babel 설정이 없으면 빌드시에는 esbuild 만을 사용한다고 한다. 개발과 빌드가 다를 이유가 뭘까? 더 자세한 내용을 보기 위해 @vitejs/plugin-react 의 코드를 참고한다.
// lazy load babel since it's not used during build if plugins are not used
let babel: typeof babelCore | undefined
async function loadBabel() {
if (!babel) {
babel = await import('@babel/core')
}
return babel
}
동적으로 babel 을 로드하기 위한 함수가 있다. 호출되는데를 찾아보면..
async transform(code, id, options) {
if (id.includes('/node_modules/')) return
const [filepath] = id.split('?')
if (!filter(filepath)) return
// 생략
const babel = await loadBabel()
const result = await babel.transformAsync(code, {
// 생략
config 의 transform 에서 호출되는걸 확인했다 그런데 위의 early return 들을 보면 느낌이 온다. 특히
const ssr = options?.ssr === true
const babelOptions = (() => {
if (staticBabelOptions) return staticBabelOptions
const newBabelOptions = createBabelOptions(
typeof opts.babel === 'function'
? opts.babel(id, { ssr })
: opts.babel,
)
runPluginOverrides?.(newBabelOptions, { id, ssr })
return newBabelOptions
})()
const plugins = [...babelOptions.plugins]
staticBabelOptions = createBabelOptions(opts.babel)
위 부분들을 보면 opts.babel이 없으면 plugins.length 가 0 인걸 알 수 있다.
if (
!plugins.length &&
!babelOptions.presets.length &&
!babelOptions.configFile &&
!babelOptions.babelrc
) {
return
}
그러면 여기서 transform 을 수행안하고 return 해서, 위의 loadBabel을 호출하지 않고 빌드과정이 완료될 수 있다! 개발과정에서는 다른데, hmr(react-refresh) 을 위한 babel 이 필수로 삽입되기 때문이다.
const isJSX = filepath.endsWith('x')
const useFastRefresh =
!skipFastRefresh && // skipFastRefresh = isProduction || ...
!ssr &&
(isJSX ||
(opts.jsxRuntime === 'classic'
? importReactRE.test(code)
: code.includes(jsxImportDevRuntime) ||
code.includes(jsxImportRuntime)))
if (useFastRefresh) {
plugins.push([
await loadPlugin('react-refresh/babel'),
{ skipEnvCheck: true },
])
}
내 package.json에 babel이 추가되지는 않지만, 속도가 느린 babel을 쓰는게 싫을 수 있다.
그래서 vite 에서는 babel 대신 swc 를 사용해서 hmr 스크립트를 추가한 플러그인도 제공한다. babel 플러그인을 써야되는게 아니라면 이거를 쓰는것도 좋아보인다. 하지만 오랫동안 해야되는 프로젝트면 생태계가 훨씬 큰 babel 쓰는게 나을 것이다. esbuild api 로 hmr 스크립트 추가하는게 더 간단하지 않았을까라는 생각도 들지만, 여기 를 보면 AST 를 직접 수정하는거는 지원하지 않는다고 한다. 그래서 babel과 swc라는 선택지를 제공한 것 같다.
암튼 첫번째 질문에 대한 답이 나왔다.
-> 개발과정에서는 HMR 을 위해서 사용되고, 빌드시에는 (추가로 babel 플러그인을 구성하지 않는 한) 사용하지 않는다.
rollup은 왜 webpack보다 빠를까?
이거는 사실 눈으로 확실하게 확인할 수 있는 방법은 없다. 개념적으로 설명을 하자면, es6기반 트리셰이킹, 모던한 플러그인 api, 코드 스플리팅 등 키워드를 설명하는데.. 사실 이는 쉽게 납득가능한 이유는 아니다. webpack에서 똑같이 아무것도 안하고 번들링 할 수 있기 때문이다.
힌트는 dependency graph와 scope hoisting 에서 얻을 수 있었다.
webpack의 문서에는 이런 내용이 있다.
https://webpack.js.org/concepts/dependency-graph/
When webpack processes your application, it starts from a list of modules defined on the command line or in its configuration file. Starting from these entry points, webpack recursively builds a dependency graph that includes every module your application needs, then bundles all of those modules into a small number of bundles - often, only one - to be loaded by the browser.
entry 파일에서부터 참조하는 다른 파일을 재귀적으로 참조하면서 그래프를 만든다는 거다. 그리고 각 파일을 하나의 함수로 만들어서, 부트스트랩 코드와 함께 객체로 만든다음에 호출하는 부분에서 서로 다른 객체를 참조하도록 해서 호출한다. 코드가 쓸데없이 길어지고, 무거워지고, 빌드과정에서 코드 스플리팅과 트리셰이킹을 기대할 수 없다.
es6 기반의 코드베이스를 번들링하는 rollup에서는 여기에 다르게 접근한다. scope hoisting 이라는 개념이 나오는데, 모든 모듈을 하나의 함수에 합치는 것이다. 아래에 쭉쭉 붙인다고 생각하면 편하다. 중복된 변수명 등을 피하기 위한 세심한 기술 등이 필요하다. 이렇게 하면 트리셰이킹을 하기 편하고, dead code elimination 을 하기에도 편하다. 그리고 표현식을 가능한 경우 상수로 바꿀수도 있다. runtime overhead도 줄어든다고 볼 수 있다.
rollup이 더 빠른게 충분히 납득가는 이유이다. 위에는 줄글로만 간단히 설명했는데, Adobe Medium에 사례와 예시코드로 설명한 좋은 글이 있다.
두번째 질문에 대한 답도 나왔다.
-> 모던한 모듈 시스템 기반의 컴팩트한 빌드 프로세스.
자 이제 vite 가 빠른 빌드와 개발서버를 자랑하는 이유를 알게되었다. vite 가 개발서버와 빌드에서 각 도구를 어떻게 사용하는지 이어서 설명한다.
vite가 빌드와 개발서버에서 사용하는 도구들
esbuild
golang 기반의 javascript/typescript 트랜스파일러. react-jsx 문법 transpiling도 제공한다. native-compile 언어 기반인만큼 빠른 속도를 보여준다.
rollup
javascript기반의 esm 모듈 번들러. typescript, jsx 트랜스파일링을 제공한다. 다만 그 속도는 esbuild에 비교해서 느리다. 그래서 vite 에서는 트랜스파일링은 esbuild로 하고, 번들러로만 사용한다.
개발 서버
개발 서버를 구성할때는 우선 소스를 두가지로 나눈다.
- node_modules 로 부터 import 되는 자주 변하지 않는 파일들. 얘네는 패키지단위로 esbuild 로 pre-bundle 되어서 하나의 모듈로 브라우저에 로드된다.
- 사용자가 작성한 자주 변하는 파일들. 얘네는 각 파일이 각각 esbuild 로 js 로 컴파일되어 브라우저에 로드된다.
이렇게 되면 구조가 아래와 같게 된다.

이러면 필요한 파일만 로드할 수 있고, 수정한 모듈만 다시 로드됨으로 저장시마다 눈 깜짝할 사이에 변화가 브라우저에 적용된다.
[이 글에서는 hmr에 대해서 더 자세히는 다루지 않는다.]
즉, 개발중에는 트랜스포밍이 필요할때 사용되는 babel을 제외하면, esbuild 만 사용된다.
빌드
빌드는 당연히 번들링하는게 상식적이다. 너무 많은 요청이 브라우저에 들어가면 좋을 게 없기 때문이다.
그래서 번들링을 해야하는데, 위에서 pre-bundle 도 esbuild 로 했으니, 개발서버와 같이 esbuild 로 수행하는것이 합리적으로 보인다. 하지만 vite 에서는 rollup 을 선택했다. 그 이유는 rollup 의 더 많은 기능을 제공하기 위해서라고 한다. 그래서, 컴파일 (트랜스 파일링?) 은 esbuild (마찬가지로 필요하다면 babel 등 추가) 로 하고, 번들링은 rollup 으로하는 다소 이상한 구조가 된것이다. 개발이랑 빌드 두개를 똑같이 동작하게 하는것도 까다로울 것 같은데, 그것이 바로 vite 의 블랙박스이다. 어쩔수 없다고 하지만, 다소 불안한 것이 사실이다.
희소식은 rollup 의 rust 포트인 rolldown 이 안정화되면, 개발과 빌드 모두에서 rolldown으로 통일할 수도 있다는 사실이다. 많은 사용자가 보장되있는만큼 rust 와 web, 트랜스파일링, 번들링 등에 관심이 있는 개발자라면 contribute하는것도 좋아보인다!
함께 읽으면 좋은 글