Vite로 마이그레이션하기
저희 디자인 시스템 React 컴포넌트 라이브러리는 배포용 JS 파일을 빌드할 때 Webpack을 사용하고 있었습니다. 최근 Vite로 마이그레이션을 완료했으며, 이를 통해 개발 경험이 개선되고 트리 쉐이킹을 지원하게 되어 최종 애플리케이션의 번들 크기도 줄어들었습니다.
왜 Vite인가?
라이브러리 빌드에 Webpack을 사용한 주된 이유 중 하나는 Storybook 개발 환경과 최종 라이브러리 빌드 간의 일관성을 유지하기 위해서였습니다. 컴포넌트 변경 사항은 개발 과정에서 대부분 Storybook에서만 테스트되는데, Storybook 빌드와 라이브러리 빌드가 다르면 애플리케이션에서 컴포넌트를 사용할 때 예상치 못한 문제가 발생할 수 있습니다.
Storybook이 이제 Vite를 빌더로 지원합니다 덕분에 빌드 간에 동일한 일관성을 유지할 수 있게 되었습니다. 또한 Vitest를 사용하면 라이브러리 빌드와 Storybook, 단위 테스트 간에 설정을 공유할 수 있었습니다.
Vite는 Webpack보다 속도 면에서도 이점이 있고 ESM, TS, JSX를 기본 지원해 설정이 더 간단합니다. 특히 ESM이 가장 큰 장점이었는데, 기존에는 애플리케이션에서 컴포넌트를 몇 개만 사용하더라도 전체 컴포넌트 라이브러리가 번들에 포함되고 있었기 때문입니다. ESM과 트리 쉐이킹을 사용하면 최종 애플리케이션 번들에서 사용되지 않는 JS를 제거하여 이 문제를 해결할 수 있습니다.
마이그레이션
라이브러리 모드 & ESM
먼저 Vite의 라이브러리 모드를 활성화하기 위해 기본적인 vite.config.ts 파일을 추가했습니다. 모듈 구조를 그대로 유지하는 대신 각 index.ts 또는 index.js 파일을 진입점으로 처리했습니다(rollup 문서 참고). 이를 통해 라이브러리 내 각 패키지나 폴더마다 독립된 진입점을 빌드할 수 있었습니다. Vite가 CommonJS(cjs)와 ES 모듈(es) 두 형식으로 출력되도록 설정하고, 파일명을 ${filename}.${format}.js 형식으로 지정했습니다. 덕분에 일부 컨슈머 애플리케이션에서 .mjs 호환성 문제를 피할 수 있었습니다.
// vite.config.ts
import { defineConfig } from 'vite';
import { dirname, resolve, relative, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { globSync } from 'glob';
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
build: {
outDir: resolve(__dirname, 'dist/package'),
lib: {
entry: {
'our-package-name': resolve(__dirname, 'index.ts'),
...globSync('src/**/index.{ts,js}').reduce((acc, file) => {
acc[
relative('src', file.slice(0, file.length - extname(file).length))
] = fileURLToPath(new URL(file, import.meta.url));
return acc;
}, {}),
},
name: 'our-package-name',
fileName: (format, fileName) => `${fileName}.${format}.js`,
formats: ['cjs', 'es'],
},
},
...
});마지막으로 package.json의 main과 module 필드를 적절히 설정해 컨슈머가 올바른 형식을 해석할 수 있도록 했습니다.
{
"name": "our-package-name",
"main": "dist/package/our-package-name.cjs.js",
"module": "dist/package/our-package-name.es.js"
}React
JSX를 지원하기 위해 공식 Vite React 플러그인을 사용했으며, React 17 호환성을 위해 jsxRuntime: 'classic'으로 설정했습니다. 또한 React를 가져올 때 CommonJS와 ESM 간의 호환성을 보장하기 위해 interop: 'auto'를 설정했습니다. 이를 통해 아직 React 17을 사용 중인 많은 컨슈머와의 하위 호환성을 유지할 수 있었습니다.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
build: {
rollupOptions: {
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
interop: "auto",
},
},
},
plugins: [
react({
jsxRuntime: "classic",
}),
],
});외부 의존성
외부 의존성이 최종 결과물에 번들되는 것을 방지하기 위해 webpack-node-externals와 유사한 방식으로 동작하는 vite-plugin-externalize-deps를 사용했습니다. 하지만 이전 버전의 React를 사용할 때 radix-ui 패키지가 react/jsx-runtime 가져오기를 해석하는 과정에서 문제가 발생했습니다(React 이슈 참고). 이를 해결하기 위해 except 옵션을 사용해 해당 패키지들을 빌드된 라이브러리에 선택적으로 포함했습니다.
// vite.config.ts
import { defineConfig } from "vite";
import { externalizeDeps } from "vite-plugin-externalize-deps";
export default defineConfig({
plugins: [
externalizeDeps({
except: [/radix-ui/],
}),
],
});스타일
저희 라이브러리는 컴포넌트 스타일링에 SASS와 CSS 모듈을 사용합니다. 컨슈머 애플리케이션에서 별도로 스타일을 가져오지 않아도 컴포넌트와 함께 스타일이 포함되도록 vite-plugin-lib-inject-css를 사용해 각 컴포넌트 모듈에 import 구문을 추가했습니다.
// vite.config.ts
import { defineConfig } from "vite";
import { libInjectCss } from "vite-plugin-lib-inject-css";
export default defineConfig({
plugins: [libInjectCss()],
});트리 쉐이킹 과정에서 CSS 파일이 제거되는 것을 방지하기 위해 package.json에 sideEffects도 추가했습니다.
{
"sideEffects": ["**/*.css"]
}안타깝게도 이로 인해 사용하지 않는 컴포넌트의 CSS까지 최종 애플리케이션 번들에 포함되지만, 라이브러리에서 컴포넌트를 가져오는 방식의 간편함을 고려한 타협이었습니다. 이 문제의 복잡성에 대한 논의는 여기에서 확인할 수 있습니다.
또한 Vite가 CSS 모듈 사용 여부를 판단하는 방식에 맞춰 모든 SCSS 파일의 확장자를 .module.scss로 변경해야 했습니다.
find . -type f -name "*.scss" -exec sh -c 'mv "$0" "${0%.scss}.module.scss"' {} \;타입 정의
라이브러리용 타입 정의도 제공하고 있는데, 별도로 tsc를 실행하는 대신 vite build 명령어 실행 시 타입 정의가 생성되도록 vite-plugin-dts를 사용하기로 했습니다.
// vite.config.ts
import { defineConfig } from "vite";
import { libInjectCss } from "vite-plugin-lib-inject-css";
export default defineConfig({
plugins: [
dts({
tsconfigPath: "./tsconfig.json",
}),
],
});또한 라이브러리의 package.json 파일에 있는 types 필드가 진입점 .d.ts 파일을 가리키도록 했습니다.
{
"name": "our-package-name",
"types": "dist/package/our-package-name.d.ts"
}SVG 가져오기와 Vitest
이전에는 *.svg 가져오기를 raw 문자열로 변환하기 위해 Babel 플러그인을 사용했지만, Vite에서는 가져오기에 ?raw 접미사를 추가하는 방식으로 이 기능을 지원합니다(문서 참고).
import icon from "./icon.svg"; // before
import icon from "./icon.svg?raw"; // afterJest에서는 이 가져오기 방식을 제대로 처리하지 못했기 때문에, 빌드와 단위 테스트 간에 설정을 공유할 수 있다는 이점을 얻기 위해 테스트를 Vitest로 마이그레이션했습니다.
마이그레이션은 비교적 간단했으며, mock과 spy에서 jest를 vi로 교체했습니다. vite.config.ts에 globals 활성화, jsdom 사용, 라이브러리 빌드 때와 유사한 react/jsx-runtime 해석 오류를 피하기 위한 radix-ui 의존성 인라인 등 기본적인 테스트 설정을 추가했습니다.
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
deps: {
inline: [/radix-ui/],
},
},
});정리
마이그레이션 후 더 이상 필요하지 않은 여러 설정 파일과 의존성을 정리할 수 있었습니다.
- 직접 사용하던 Babel 의존성과 설정 파일 제거
- 다양한 window 속성과 전역을 모킹하던 Jest 설정 파일 제거
- Vite가 CSS와 에셋 가져오기를 기본으로 처리하므로 Storybook 설정 간소화
결과
이번 변경 사항은 컨슈머에게 브레이킹 체인지 없이 마이너 릴리스로 애플리케이션 전반에 배포할 수 있었습니다.
컴포넌트 라이브러리를 사용하는 애플리케이션 전반에서 평균 25% 번들 크기 감소를 확인했습니다.
Storybook의 시작 및 빌드 시간도 개선되었습니다. 이전에는 개발 환경에서 Storybook을 실행하려면 Webpack 빌드가 완료될 때까지 약 30초가 걸렸지만, 이제는 10초 미만으로 열립니다(66% 감소). 전체 Storybook 빌드 시간도 약 115초에서 약 50초로 단축되었습니다(약 57% 감소).
글을 무작위로 읽기