vite 사전 번들링 실패 트러블 슈팅
개요
여러 제품에서 공통적으로 사용하는 UI, 훅, 유틸 함수 등 코드를 별도로 분리해서 storybook으로 개발하고 rollup으로 빌드 후 사내 npm에 배포하고 각 프로덕트 앱에서 npm 패키지를 다운받아서 사용하는 구조로 개발하고 있었습니다.
공통 라이브러리에서 특정 라이브러리를 사용하는 기능을 개발하고 rollup으로 번들링한 후, vite를 사용하는 제품에서 사용하려고 시도했을 때 아래 에러가 발생했습니다.
✘ [ERROR] Failed to resolve entry for package "https". The package may have incorrect main/module/exports specified in its package.json. [plugin vite:dep-pre-bundle]
...
The plugin "vite:dep-pre-bundle" was triggered by this import
node_modules/.pnpm/<공통 모듈>_@storybook+addons@6.5.16_@storybook+api@6.5.16_aeaxtcy52dxqwwdjad5dkq4guy/node_modules/<공통 모듈>/dist/index.js:192207:90:
192207 │ ...quire !== 'undefined' && typeof window === 'undefined' ? require('https') : null; // NodeJS
사전 번들링 단계에서 발생한 참조 오류
문제가 발생했던 코드는 var https = typeof require !== 'undefined' && typeof window === 'undefined' ? require('https') : null;
로 Nodejs 환경이라면 https 모듈을 가져오고 아니라면 null을 반환하는 코드입니다.
vite는 속도를 최적화하기 위해 디펜던시들을 사전에 esbuild로 번들링 해두는데요, 이 과정에서 서버 모듈(https)을 참조하지 못해 발생한 에러였습니다.
공통 모듈을 storybook으로 개발하거나, 웹팩 에서 사용할 때 문제가 없었던 이유는 webpack은 위 코드에서 typeof require !== 'undefined' && typeof window === 'undefined'
이 false로 평가되었지만, vite의 사전 번들링 단계에서는 node 환경에서 실행되어 true로 평가되어 https 모듈을 참조하는 시도를 하게 되고, 이 과정에서 참조 실패가 발생했다고 추측했습니다.
문제가 발생한 라이브러리 직접 vite에 설치해보기
정리하자면 오류가 발생하는 라이브러리를 공통 라이브러리에서 참조하고, 공통 라이브러리를 vite앱에서 사용할 때 vite의 사전 번들링 단계에서 서버에서 실행되어야 하는 코드가 브라우저에서 실행되며 발생하는 오류입니다.
vite에서는 nodejs와 브라우저 환경을 공통적으로 지원하는 라이브러리를 사용하면 사전 번들링이 안된다는건 너무 이상해서, 문제를 찾기 위해 오류가 발생했던 라이브러리를 직접 설치해봤습니다.
하지만 문제가 발생했던 라이브러리를 직접 설치했을 때는 문제가 발생하지 않았습니다. 사전 번들링된 코드를 열어보니 require_https
라는 이름으로 빈 객체가 넣어져 있었고, 실행되면 (get 되면) 이는 빈 깡통이라고 안내해주는 코드가 들어가 있었습니다.
즉, vite에서는 node와 browser 환경을 둘 다 지원하는 라이브러리의 경우, 사전 번들링 단계에서 서버 모듈 참조 하는 코드를 실행하고 에러가 발생하지 않도록 서버 모듈을 참조하는 코드에 빈 깡통을 넣어주고 있습니다.
그렇다면 공통 모듈에서는 왜 해당 기능이 동작하지 않았는지, 이는 어떤 차이에서 기인한건지를 찾아봤습니다.
Vite의 사전 번들링 단계에서, 서버 모듈 참조시 오류를 방지하는 로직은 어떻게 동작하나?
“사전 번들링을 하는 코드를 찾아 왜 문제가 발생한 라이브러리를 직접 설치했을 때는 require_https
이라는 이름으로 빈 깡통 객체를 만들어서 오류가 발생하지 않았는데, 이를 공통 모듈에서 참조하게 만들고 공통 모듈을 사용했을 때는 이러한 처리가 없고, 오류가 발생하는가?”
위 질문의 답을 찾고 싶어서 vite의 사전 번들링 코드를 읽어 봤습니다.
결론적으로 package.json
에서 browser
라는 필드 설정이 되어 있지 않아 발생한 문제였습니다. 사전 번들링 시 browser
라는 필드가 있으면 이는 브라우저 전용 라이브러리로 취급해 사전 번들링 시 오류가 발생하지 않도록 빈 깡통을 넣어주고 있는데, 사내 공통 라이브러리에는 해당 필드 설정이 누락되어 있어 발생한 문제였습니다.
사전 번들링 코드를 읽으면서 진행 흐름을 정리한 내용은 하단 “(참고) Vite 사전 번들링 로직 상세히 살펴보기”에 적어두었습니다.
(참고) Vite 사전 번들링 로직 상세히 살펴보기
흐름 요약
vite 레포를 다운받아서 원본 코드를 확인했고, 프로젝트에서 vite 코드를 수정해서 출력해보고 싶어서 터미널 모듈을 설치해서 디버깅 했습니다.
// vite 터미널 디버깅
import Terminal from "vite-plugin-terminal";
plugins: [..., Terminal()],
이제 vite 코드를 살펴보면,
/vite/packages/vite/src/node/optimizer/esbuildDepPlugin.ts:237 esbuildDepPlugin
함수 내부에서, namespace가 browser-external 이라면 빈 깡통을 넣어주는 코드가 있습니다.
build.onLoad({ filter: /.*/, namespace: "browser-external" }, ({ path }) => {
if (isProduction) {
return {
contents: "module.exports = {}",
};
} else {
return {
// ...
contents: `\
module.exports = Object.create(new Proxy({}, {
get(_, key) {
if (
key !== '__esModule' &&
key !== '__proto__' &&
key !== 'constructor' &&
key !== 'splice'
) {
console.warn(\`Module "${path}" has been externalized for browser compatibility. Cannot access "${path}.\${key}" in client code. See https://vitejs.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\`)
}
}
}))`,
};
}
});
namespace를 browser-external로 분류하는 코드를 vite를 디버깅하면서 찾아보았는데요
resolveResult
라는 함수에서 resolved 라는 파라미터를 가지고 분류하고 있습니다.
const resolveResult = (id, resolved) => {
if (resolved.startsWith(browserExternalId)) {
return {
path: id,
namespace: "browser-external"
};
}
// ...
resolved는 resolve 함수의 실행 결과가 들어가게 되는데요, resolve 함수는 packages/vite/src/node/optimizer/esbuildDepPlugin.ts:86
에서 확인할 수 있습니다.
const resolve = (
id: string,
importer: string,
kind: ImportKind,
resolveDir?: string
): Promise<string | undefined> => {
...
const resolver = kind.startsWith('require') ? _resolveRequire : _resolve;
return resolver(environment, id, _importer);
};
상황에 따라 ESM 또는 CommonJS 방식에 맞춰 적절한 모듈 경로를 동적으로 로드할 수 있도록 _resolveRequire
이나 _resolve
함수를 사용합니다
/vite/packages/vite/src/node/idResolver.ts
에서 볼 수 있듯, 이 두 함수는 createBackCompatIdResolver
라는 함수로 만들어지고, 이후 createIdResolver
를 호출합니다.
export function createIdResolver(
config: ResolvedConfig,
options?: Partial<InternalResolveOptions>,
): ResolveIdFn {
...
async function resolve(
environment: PartialEnvironment,
id: string,
importer?: string,
): Promise<PartialResolvedId | null> {
...
[
aliasPlugin({ entries: environment.config.resolve.alias }),
resolvePlugin({
...
resolvePlugin
에서는 tryResolveBrowserMapping
함수를 호출하는데요, 이는 참조할 라이브러리의 가까운 package.json을 가져오고, package.json의 browser 객체를 조회 하는 함수입니다.
그리고 mapWithBrowserField
함수를 호출해서 browser 객체에서 참조하려는 라이브러리가 명시되어 있는지 조회합니다.
function mapWithBrowserField(
relativePathInPkgDir: string,
map: Record<string, string | false>
): string | false | undefined {
const normalizedPath = path.posix.normalize(relativePathInPkgDir);
for (const key in map) {
const normalizedKey = path.posix.normalize(key);
if (
normalizedPath === normalizedKey ||
equalWithoutSuffix(normalizedPath, normalizedKey, ".js") ||
equalWithoutSuffix(normalizedPath, normalizedKey, "/index.js")
) {
return map[key];
}
}
}
참조하는 모듈과 찾은 browser 객체를 로깅하면 아래처럼 보여집니다.
이후 mapWithBrowserField
함수의 반환값이 false라면 browserExternalId를 반환합니다.
이는 package.json의 browser 필드에 대해 알아야 하는데요,
- browser 필드는 브라우저 환경에서 대체할 라이브러리를 지정하기 위해 사용됩니다.
- 예를 들어 위 경우는 브라우저 환경에서
./src/server-module.js
를 참조하려고 시도하면 대신./src/browser-module.js
를 참조하라고 지정합니다.
- 예를 들어 위 경우는 브라우저 환경에서
- 만약
path
나fs
처럼 브라우저 환경에서는 무시해야 한다면 false로 지정합니다.
{
"browser": {
"./src/server-module.js": "./src/browser-module.js", // 브라우저에서 참조 시도 시 사용할 대체 모듈
"path": false,
"fs": false
}
}
즉, browser 필드에 특정 모듈이 false로 설정되어 있으면 Vite는 이를 “browser-external”로 인식하고, 사전 번들링 단계에서 빈 객체로 대체하여 런타임 에러가 나지 않도록 처리해줍니다.
결론
결국 문제가 발생했던 라이브러리를 직접 import 했을 때 에러가 발생하지 않은건, 해당 라이브러리의 package.json의 browser 필드에 fs
, https
같은 서버 모듈을 false로 지정했기 때문이었고, 공통 모듈을 import 했을 때 사전 번들링 단계에서 에러가 발생했던건 browser 필드를 지정하지 않았기 때문이었습니다.
=> 공통 모듈의 package.json
파일의 browser
에 서버 모듈(fs, https) 등 을 false
로 지정하여 해결할 수 있었습니다.
"browser": {
"fs": false,
"https": false
}