Workspaces, react와 vite. 중복 라이브러리 관리를 위한 실제 사례 연구

발행: (2025년 12월 5일 오전 04:03 GMT+9)
9 min read
원문: Dev.to

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-domreact-router에 의존합니다:

{
  "name": "react-router-dom",
  "version": "6.30.1",
  "dependencies": {
    "react-router": "6.30.1"
  }
}

설치 결과

npm이 워크스페이스를 설치할 때, 의미 체계 버전 제약을 만족하면서 의존성을 워크스페이스 루트로 올리려고 시도합니다:

  1. **package-a**가 먼저 처리됩니다. 캐럿(^) 범위 ^6.21.1은 최신 호환 버전(예: 6.30.2)으로 해석됩니다. 이 버전이 워크스페이스 루트에 설치되고, react-router-dom@6.30.2(내부적으로 react-router@6.30.2에 의존)도 루트에 올려집니다.

  2. **package-b**가 다음으로 처리됩니다. 여기서는 react-routerreact-router-dom6.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.jsonoverrides 필드를 추가하면 워크스페이스 전체에 단일 버전을 강제할 수 있습니다:

{
  "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 화이트리스트에 있는지 확인합니다.
  • 지정된 packagePathnode_modules 폴더로 해석을 강제합니다.
  • 대상 패키지를 찾을 수 없거나 오류가 발생하면 Vite의 기본 해석으로 되돌아갑니다.

packagePath를 원하는 버전이 들어있는 패키지(예: ../package-a)로 지정하면, react-routerreact-router-dom의 모든 import가 동일한 물리적 복사본을 가리키게 되어 중복이 사라지고 런타임 오류도 방지됩니다.


결론

  • npm workspaces는 버전 제약이 다를 경우 동일 라이브러리의 여러 버전을 올릴 수 있습니다.
  • Vite는 기본적으로 발견한 각 물리적 복사본을 모두 번들에 포함시키며, 이는 번들 크기 증가와 기능 버그(예: React 컨텍스트 파손)를 초래합니다.
  • overridesresolve.dedupe와 같은 간단한 해결책은 복잡한 모노레포에서 필요한 세밀함을 제공하지 못할 수 있습니다.
  • 원하는 패키지로 해석을 리다이렉트하는 커스텀 Vite 플러그인은 중복 제거에 대한 정확한 제어를 가능하게 하여, 워크스페이스 전체에서 react-router와 같은 핵심 라이브러리의 단일 인스턴스를 보장합니다.
Back to Blog

관련 글

더 보기 »

React 및 Next.js의 RCE 취약점

기사 URL: https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp 댓글 URL: https://news.ycombinator.com/item?id=46136026 포인트: 26 Co...