Migrating to Vite

Alex O'Callaghan

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.jsonsideEffects도 추가했습니다.

{
  "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"; // after

Jest에서는 이 가져오기 방식을 제대로 처리하지 못했기 때문에, 빌드와 단위 테스트 간에 설정을 공유할 수 있다는 이점을 얻기 위해 테스트를 Vitest로 마이그레이션했습니다.

마이그레이션은 비교적 간단했으며, mock과 spy에서 jestvi로 교체했습니다. 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% 감소).

원문은 Alex O'Callaghan님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.