Workspaces, react와 vite. 중복 라이브러리 관리를 위한 실제 사례 연구
Source: Dev.to
소개
npm workspaces를 사용해 프로젝트를 구성할 때 일반적인 구조는 여러 로컬 패키지와 애플리케이션을 조정하는 단일 루트 패키지로 이루어집니다:
my-workspace
│ package.json
├─ apps
│ ├─ client
│ └─ server
└─ packages
├─ package-a
│ ├─ src
│ └─ package.json
└─ package-b
├─ src
└─ package.json
이 아키텍처는 모듈성 및 코드 재사용성을 높여 주지만, 공유 의존성을 관리하면 모듈 해석이 복잡해져 진단하기 어려운 런타임 오류가 발생할 수 있습니다.
흔히 나타나는 증상은 다음 오류입니다:
Cannot destructure property 'basename' of 'w.useContext(...)' as it is null
이는 Router 제공자가 존재함에도 불구하고 컴포넌트가 라우터 컨텍스트에 접근하지 못한다는 의미이며, 근본 원인은 의존성 중복인 경우가 많습니다.
이 글에서는 다음을 분석합니다:
- npm이 버전 해석을 수행하는 방식
- Vite가 의존성 해석을 수행하는 방식
- Vite를 이용한 목표 지향 중복 제거 전략 구현 방법
의존성 설정
package-a
{
"dependencies": {
"react-router-dom": "^6.21.1"
}
}
package-b
{
"dependencies": {
"react-router": "6.30.1",
"react-router-dom": "6.30.1"
}
}
react-router-dom은 react-router에 의존합니다:
{
"name": "react-router-dom",
"version": "6.30.1",
"dependencies": {
"react-router": "6.30.1"
}
}
설치 결과
npm이 워크스페이스를 설치할 때, 의미 체계 버전 제약을 만족하면서 의존성을 워크스페이스 루트로 올리려고 시도합니다:
-
**
package-a**가 먼저 처리됩니다. 캐럿(^) 범위^6.21.1은 최신 호환 버전(예:6.30.2)으로 해석됩니다. 이 버전이 워크스페이스 루트에 설치되고,react-router-dom@6.30.2(내부적으로react-router@6.30.2에 의존)도 루트에 올려집니다. -
**
package-b**가 다음으로 처리됩니다. 여기서는react-router와react-router-dom을6.30.1로 고정합니다. 이 버전들이 루트에 올려진 버전과 다르기 때문에 npm은packages/package-b/node_modules내부에 별도의 복사본을 설치하고, 해당 폴더에react-router@6.30.1을 둡니다.
결과적으로 디스크에 두 개의 서로 다른 버전이 존재하게 됩니다:
my-workspace/
├─ node_modules/
│ ├─ react-router@6.30.2
│ └─ react-router-dom@6.30.2
└─ packages/
└─ package-b/
└─ node_modules/
├─ react-router@6.30.1
└─ react-router-dom@6.30.1
번들러의 역할
현대 프론트엔드 개발에서 번들러(Webpack, Rollup, Vite 등)는 애플리케이션의 의존성 그래프를 탐색하고, 각 import를 해석한 뒤 브라우저용 번들을 생성합니다.
두 개의 물리적 복사본이 존재하면 번들러는 두 버전 모두를 최종 번들에 포함시킵니다. 이로 인해:
- 번들 크기 증가
- 코드베이스 전반에 걸쳐 같은 라이브러리가 서로 다른 버전으로 사용될 때 일관성 없는 동작
react-router의 경우 버전이 맞지 않으면 컨텍스트 시스템이 깨져 앞서 언급한 런타임 오류가 발생합니다.
중복 제거 전략
1. npm overrides
루트 package.json에 overrides 필드를 추가하면 워크스페이스 전체에 단일 버전을 강제할 수 있습니다:
{
"overrides": {
"react-router": "6.30.1",
"react-router-dom": "6.30.1"
}
}
이 방법은 작동하지만 프로젝트에 따라 너무 거칠게 적용될 수 있습니다.
2. Vite resolve.dedupe
Vite는 기본적인 중복 제거 옵션을 제공합니다:
// vite.config.ts
export default {
resolve: {
dedupe: ['react-router', 'react-router-dom']
}
}
resolve.dedupe는 지정된 패키지를 프로젝트 루트에 있는 복사본으로 강제 해결합니다. 위 시나리오에서는 루트에 6.30.2 버전이 존재하므로 Vite는 여전히 최신 버전을 번들에 포함하고, package-b 내부에 고정된 6.30.1은 무시됩니다.
3. 세밀한 제어를 위한 커스텀 Vite 플러그인
특정 패키지 내부의 버전으로 항상 해결하고 싶을 때는, 모듈 해석을 가로채 원하는 위치로 리다이렉트하는 Vite 플러그인을 구현할 수 있습니다.
import fs from 'fs';
import path from 'path';
import { PluginOption } from 'vite';
/**
* 특정 패키지를 정의된 상대 경로에 있는 버전으로 강제 중복 제거하는 플러그인.
*
* @param dedupe - 중복 제거 대상 패키지 이름 배열 (예: ['react-router'])
* @param packagePath - "진실의 원천"으로 사용할 패키지의 상대 경로
*/
const enhancedDedupePlugin = ({
dedupe,
packagePath,
}: {
dedupe: string[];
packagePath: string;
}): PluginOption => ({
name: 'vite-plugin-enhanced-dedupe',
enforce: 'pre', // Vite 기본 resolver 이전에 실행
resolveId(source, _importer) {
// 화이트리스트에 없는 패키지는 무시
if (!dedupe.includes(source)) return null;
// 대상 node_modules 절대 경로 생성
const targetBase = path.resolve(__dirname, packagePath, 'node_modules', source);
if (!fs.existsSync(targetBase)) return null;
// 패키지 진입점 찾기
const pkgJsonPath = path.join(targetBase, 'package.json');
if (!fs.existsSync(pkgJsonPath)) return null;
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
const entry = pkg.module || pkg.main || 'index.js';
return path.join(targetBase, entry);
} catch {
return null; // 오류 발생 시 기본 해석으로 폴백
}
},
});
export default {
plugins: [
enhancedDedupePlugin({
dedupe: ['react-router', 'react-router-dom'],
packagePath: '../package-a' // 신뢰하는 패키지 경로로 조정
})
]
};
이 플러그인은:
- 가져온 모듈이
dedupe화이트리스트에 있는지 확인합니다. - 지정된
packagePath의node_modules폴더로 해석을 강제합니다. - 대상 패키지를 찾을 수 없거나 오류가 발생하면 Vite의 기본 해석으로 되돌아갑니다.
packagePath를 원하는 버전이 들어있는 패키지(예: ../package-a)로 지정하면, react-router와 react-router-dom의 모든 import가 동일한 물리적 복사본을 가리키게 되어 중복이 사라지고 런타임 오류도 방지됩니다.
결론
- npm workspaces는 버전 제약이 다를 경우 동일 라이브러리의 여러 버전을 올릴 수 있습니다.
- Vite는 기본적으로 발견한 각 물리적 복사본을 모두 번들에 포함시키며, 이는 번들 크기 증가와 기능 버그(예: React 컨텍스트 파손)를 초래합니다.
overrides나resolve.dedupe와 같은 간단한 해결책은 복잡한 모노레포에서 필요한 세밀함을 제공하지 못할 수 있습니다.- 원하는 패키지로 해석을 리다이렉트하는 커스텀 Vite 플러그인은 중복 제거에 대한 정확한 제어를 가능하게 하여, 워크스페이스 전체에서
react-router와 같은 핵심 라이브러리의 단일 인스턴스를 보장합니다.