개요
여러 제품에서 공통적으로 사용하는 코드를 별도로 분리해서 storybook으로 개발하고 rollup으로 빌드 후 사내 npm에 배포하고 각 프로덕트 앱에서 npm 패키지를 다운받아서 사용하는 구조로 개발하고 있었는데요,
특정 라이브러리를 사용하는 기능을 개발하고 rollup으로 번들링한 후, vite를 사용하는 제품에서 사용하려고 시도했을 때 아래 에러가 발생했습니다.
vite는 속도를 최적화하기 위해 디펜던시들을 사전에 esbuild로 번들링 해두는데요, 이 과정에서 https 모듈을 참조하지 못해 에러가 발생했습니다.
문제가 발생했던 코드는var https = typeof require !== 'undefined' && typeof window === 'undefined' ? require('https') : null;
로 Nodejs 환경이라면 https 모듈을 가져오고 아니라면 null을 반환하는 코드입니다.
공통 모듈을 개발할 때 문제가 없었던 이유는, 추측해보면 storybook으로 개발할 때는 webpack 기반이라 위 코드에서 typeof require !== 'undefined' && typeof window === 'undefined'
이 false로 평가되었지만, vite의 사전 번들링 단계에서는 node 환경에서 실행되어 true로 평가되어 https 모듈을 참조하는 시도를 하게 되고, 이 과정에서 참조 실패가 발생했던 것 같습니다.
문제가 발생한 라이브러리 직접 vite에 설치해보기
그렇다면 vite에서는 nodejs와 브라우저 환경을 공통적으로 지원하는 라이브러리를 사용하면 사전 번들링이 불가능한지가 궁금해져서, 문제가 되었던 라이브러리를 직접 설치해봤습니다.
하지만 직접 해당 라이브러리를 설치했을 때는 문제가 발생하지 않았습니다. 사전 번들링된 코드를 열어보니 require_https
라는 이름으로 빈 객체가 넣어져 있었고, 실행되면 (get 되면) 이는 빈 깡통이라고 안내해주는 코드가 들어가 있었습니다.
vite에서는 node와 browser 환경을 둘 다 지원하는 라이브러리의 경우, 사전 번들링 단계에서 서버 모듈 참조 하는 코드를 실행하고 에러가 발생하지 않도록 서버 모듈을 참조하는 코드에 빈 깡통을 넣어주고 있습니다.
그렇다면 공통 모듈에서는 왜 해당 기능이 동작하지 않았는지, 이는 어떤 차이에서 기인한건지를 찾아봤습니다.
vite의 사전 번들링 단계에서, 서버 모듈 참조 시 빈 객체를 넣어주는 로직은 어떻게 동작하나?
대략적으로 esbuild를 사용해서 디펜던시를 사전에 번들링 해 빠르게 참조할 수 있도록 한다는 컨셉은 알고 있었지만 해당 단계를 깊게 살펴본적은 없어서, vite의 사전 번들링 코드를 읽어 봤습니다.
vite 레포를 다운받아서 원본 코드를 확인했고, 프로젝트에서 vite 코드를 수정해서 출력해보고 싶어서 터미널 모듈을 설치해서 디버깅 했습니다.
이제 실제 코드로 들어가 보면,
/vite/packages/vite/src/node/optimizer/esbuildDepPlugin.ts:237 esbuildDepPlugin
함수 내부에서, namespace가 browser-external 이라면 빈 깡통을 넣어주는 코드가 있습니다.
namespace를 browser-external로 분류하는 코드를 vite를 디버깅하면서 찾아보았는데요
resolveResult
라는 함수에서 resolved 라는 파라미터를 가지고 분류하고 있습니다.
resolved는 resolve 함수의 실행 결과가 들어가게 되는데요, resolve 함수는 packages/vite/src/node/optimizer/esbuildDepPlugin.ts:86
에서 확인할 수 있습니다.
상황에 따라 ESM 또는 CommonJS 방식에 맞춰 적절한 모듈 경로를 동적으로 로드할 수 있도록 _resolveRequire
이나 _resolve
함수를 사용합니다
/vite/packages/vite/src/node/idResolver.ts
에서 볼 수 있듯, 이 두 함수는 createBackCompatIdResolver
라는 함수로 만들어지고, 이후 createIdResolver
를 호출합니다.
resolvePlugin
에서는 tryResolveBrowserMapping
함수를 호출하는데요, 이는 참조할 라이브러리의 가까운 package.json을 가져오고, package.json의 browser 객체를 조회 하는 함수입니다.
그리고 mapWithBrowserField
함수를 호출해서 browser 객체에서 참조하려는 라이브러리가 명시되어 있는지 조회합니다.
참조하는 모듈과 찾은 browser 객체를 로깅하면 아래처럼 보여집니다.
이후 mapWithBrowserField
함수의 반환값이 false라면 browserExternalId를 반환합니다.
이는 package.json의 browser 필드에 대해 알아야 하는데요,
- browser 필드는 브라우저 환경에서 대체할 라이브러리를 지정하기 위해 사용됩니다.
- 예를 들어 위 경우는 브라우저 환경에서
./src/server-module.js
를 참조하려고 시도하면 대신./src/browser-module.js
를 참조하라고 지정합니다.
- 예를 들어 위 경우는 브라우저 환경에서
- 만약
path
나fs
처럼 브라우저 환경에서는 무시해야 한다면 false로 지정합니다.
vite 사전 번들링 과정 정리
위 과정이 코드의 나열이라 헷갈릴 수도 있을 것 같은데요, 이해하기 쉽게 도식을 그려보면 다음과 같습니다.
- vite는 esbuild로 디펜던시를 사전 번들링 합니다.
- 이 과정에서 서버와 브라우저에서 공통으로 사용되는 라이브러리를 사용할 때 서버 모듈을 참조하는 문제가 생기지 않게 하기 위해서, 서버 모듈인 경우 참조 시 에러가 발생하는 것을 방지하기 위한 빈 깡통을 넣어줍니다.
- 서버 모듈을 판단하는 과정은, 참조하려는 라이브러리에서 제일 가까운 package.json의 browser 필드에서 조회 해 값이
false
인지 확인합니다 (브라우저에서는 사용하지 않는다고 명시되었는지 확인)
결론
결국 문제가 발생했던 라이브러리를 직접 import 했을 때 에러가 발생하지 않은건, 해당 라이브러리의 package.json의 browser 필드에 fs
, https
같은 서버 모듈을 false로 지정했기 때문이었고
공통 모듈을 import 했을 때 사전 번들링 단계에서 에러가 발생했던건 browser 필드를 지정하지 않았기 때문이었습니다.
=> 공통 모듈의 package.json
파일의 browser
에 서버 모듈을 false
로 지정하여 해결할 수 있었습니다.
번들링 관련한 이슈를 겪을 때 마다 느끼는건 디버깅하는건 조금 시간이 걸리는데, 해결은 간단한 방법으로 되는 경우가 많은 것 같습니다.