모노레포를 처음 구성할 때 공통 UI, 패키지 분리, Turborepo 설정처럼 구조적인 부분에만 집중했다.
하지만 셋팅하고 프로젝트를 진행할수록 의존성에 대한 문제가 발생했다.
사실 dependencies, peerDependencies 이런 개념도 정확히 모르고 사용하고 있었고, npm보다 pnpm이 왜 나은지도 깊게 고민해본 적이 없었기 때문에 그런 문제가 발생했을때 쉽게 대처하지 못했다.
그래서 이번 기회에 pnpm 모노레포에서 의존성을 어떻게 하면 잘 관리할수 있을지 정리해보려고 한다.
- 모노레포에서 의존성을 어떻게 배치할까
- pnpm은 의존성을 실제로 어떻게 저장하고 연결할까
- peerDependencies는 왜 중요하고 어떤 문제를 만들 수 있을까

1. 모노레포에서 의존성을 어떻게 배치할까
1-1. 의존성은 실제 사용하는 workspace에 선언한다
모노레포를 처음 구성했을때 나는 생성한 프로젝트에 라이브러리를 설치했고, 동료는 모든 라이브러리를 루트 package.json에 넣어버렸다. 동료는 그냥 이렇게 해도 프로젝트에서 잘 사용이 되던데요? 버전도 한곳에서 관리할수 있기 때문에 좋은거 아닌가요? 라고 질문했는데 정확히 대답하지 못하고 어느 프로젝트나 패키지가 이 라이브러리를 실제로 사용하는지에 대한 정보를 알수 없지 않을까요? 라고만 대답했다.
root/ ├── package.json ├── apps/ │ ├── app-a/ │ └── app-b/ └── packages/ ├── ui/ └── service/
하지만 이후 배포에서 이것에 관련한 실질적인 문제가 발생했다.
프로젝트에서 @tanstack/react-table을 직접 사용하고 있었지만, 해당 앱의 package.json에는 이 의존성이 선언되어 있지 않았던 것이다.
로컬에서는 너무나 잘 실행되어서 배포때 까지 문제점을 몰랐었다.
그 이유를 알아보니 로컬에서는 pnpm의 hoisting으로 인해 해당 패키지가 상위 node_modules에서 접근 가능한 상태였고, Node.js가 모듈을 찾을 때 부모 디렉터리의 node_modules까지 탐색하면서 정상적으로 import할 수 있었던 것이었다.
즉 실제 dependency 선언은 잘못되어 있었지만, hoisting이 문제를 가려주고 있었던 것이다.
로컬 root/node_modules └── @tanstack/react-table ← hoisting으로 접근 가능 ↑ │ 부모 node_modules 탐색 │ apps/admin └── package.json // ❌react-table 선언 없음
하지만 배포에서는 특정 앱 하나만 패키징하고 있었기 때문에 상황이 달라졌다.
admin dependency graph ├── react ├── next ├── @repo/ui └── @repo/service ❌ @tanstack/react-table 없음 ↓ 설치되지 않음 ↓ import 실패
배포에서 hoisting 자체가 동작하지 않은 것이 아니라, 앱의 dependency graph에 @tanstack/react-table이 없어서 애초에 hoisting할 패키지 자체가 없었던 것이다.
결국 특정 workspace에서 직접 사용하는 라이브러리는 해당 workspace의 package.json에 직접 선언해야 한다는 것을 깨닫게 되었다.
1-2. 루트에는 repository-level 도구를 둔다
그렇다면 루트에는 어떤걸 넣어야 할까..?
루트에는 보통 특정 앱의 기능을 위한 라이브러리가 아니라 repository 전체를 관리하기 위한 도구를 둔다고한다.
예를들면 다음과 같다.
{ "devDependencies": { "turbo": "...", "prettier": "...", "eslint": "...", "typescript": "..." } }
처음에는 여러 앱에서 공통으로 사용하는 패키지라면 루트에 두면 된다고 생각했지만,
더 정확한 기준은 **repository 자체를 관리하기 위한 도구인가?**이다.
예를 들어 ESLint나 Prettier는 앱의 실행 코드에서 직접 사용하는 라이브러리가 아니라, 모노레포 전체의 코드 품질과 개발 환경을 관리하기 위한 도구다. 따라서 루트의 devDependencies에서 관리하는 것이 자연스럽다.
반면 @tanstack/react-table처럼 특정 앱의 실행 코드에서 직접 import하는 라이브러리는 여러 앱에서 사용하더라도 루트에만 선언해서는 안 된다.
eslint, prettier, turbo → repository 전체를 관리 → root devDependencies @tanstack/react-table → apps/admin 코드에서 직접 사용 → apps/admin dependencies
1-3. 의존성 파편화를 pnpm catalogs로 제어하기
하지만 의존성을 각 workspace에 직접 선언하면 또 다른 문제가 생길 수 있다.
app-a react: 18.2.0 app-b react: 18.3.1 packages/ui react: 18.3.0
의존성의 소유권은 명확해졌지만 버전이 파편화될 수 있다.
이는 pnpm의 catalogs를 사용하면 이 둘을 분리할 수 있다.

루트에 아래처럼 react 버전을 나눈 catalog를 등록해두면, 각 앱에서는 필요한 버전을 선택하여 사용할수 있다.
이렇게 하나의 파일에서 관리하면 버전을 변경할 때도 여러 package.json을 하나씩 수정하는 대신 catalog의 버전만 변경할 수 있어, workspace 사이의 버전 불일치나 업데이트 누락 가능성을 줄일 수 있다.
# pnpm-workspace.yaml catalogs: react18: react: 18.3.1 react-dom: 18.3.1 react19: react: 19.2.0 react-dom: 19.2.0
각 앱에서는 필요한 버전을 선택한다.
{ "dependencies": { "react": "catalog:react18", "react-dom": "catalog:react18" } }
추가로 workspace 사이에서 같은 라이브러리의 버전이 불필요하게 달라졌는지 확인하고 싶다면 Syncpack 같은 도구도 사용할 수 있다.
pnpm dlx syncpack list-mismatches

다만 모든 버전을 무조건 통일할 필요는 없다.
React, React DOM처럼 공유 패키지와 밀접한 라이브러리는 통일할 가치가 크지만, 특정 앱에서만 사용하는 라이브러리는 서로 다른 버전을 사용해도 문제가 없을 수 있다.
2. pnpm은 의존성을 어떻게 저장하고 연결할까
그렇다면 모노레포에서는 pnpm을 많이 쓰던데 왜 npm과 yarn 을 택하지 않고 pnpm인건지에 대해서 궁금해졌다.
단순히 설치 속도가 빠른 패키지 매니저라고만 알고 있었지만, 실제 차이는 패키지를 저장하고 node_modules를 구성하는 방식에 있었다.
- npm은 프로젝트마다 필요한 패키지를 node_modules에 설치하는 방식이라 같은 패키지가 여러 프로젝트에서 사용되면 파일이 중복될 수 있다.
- pnpm은 패키지를 전역 저장소(Global Store)에 한 번 저장한 뒤, 각 프로젝트에서는 Hard Link와 Symbolic Link를 이용해 필요한 패키지를 연결한다.
npm project-a/node_modules/react project-b/node_modules/react → 각 프로젝트에 패키지 파일이 존재
pnpm Global Store ↓ Hard Link node_modules/.pnpm ↓ Symbolic Link 각 workspace의 node_modules → 패키지 파일을 재사용하면서 필요한 위치에 연결
이 구조 덕분에 pnpm은 디스크 사용량을 줄이고 설치 속도를 높일 수 있으며, 각 workspace가 선언한 의존성을 보다 엄격하게 분리할 수 있다고 한다.
그렇다면 pnpm은 실제로 패키지를 어디에 저장하고, 우리가 사용하는 node_modules까지 어떻게 연결하는 걸까?
이를 이해하기 위해 먼저 두 가지 개념부터 살펴보려고 한다.
- Global Store와 Hard Link
- Symbolic Link
2-1. Global Store와 Hard Link
pnpm은 동일한 패키지 파일을 프로젝트마다 반복해서 복사하지 않는다. 예를 들어 여러 프로젝트에서 모두 아래 react를 사용한다고 해보자.
react@18.3.1
pnpm은 컴퓨터의 Content-addressable Store에 실제 패키지 파일을 한 번 저장한다.
그리고 각 프로젝트에서는 해당 파일의 Hard Link를 만든다.
pnpm Global Store react@18.3.1 │ │ Hard Link ↓ project/node_modules/.pnpm/...
Hard Link의 역할은 단순하다. '실제 패키지 파일을 효율적으로 재사용한다.'
같은 파일을 프로젝트마다 복사하지 않기 때문에 디스크 공간을 줄이고 설치 속도도 높일 수 있다.
2-2. Symbolic Link
Hard Link가 파일 저장 문제를 해결한다면
Symbolic Link는 패키지를 실제 사용 위치와 연결하는 역할을 한다.
예를 들어 특정 프로젝트의 package.json이 다음과 같다고 하자.
{ "dependencies": { "react": "18.3.1" } }
우리는 코드에서 이렇게 사용한다.
import React from "react";
이때 앱에는 다음 경로가 존재한다.
apps/app-a/node_modules/react
하지만 React 파일이 실제로 이곳에 복사되어 있는 것은 아니다.
대략 다음과 같은 Symbolic Link로 저장된다.
apps/app-a/node_modules/react │ │ Symbolic Link ↓ root/node_modules/.pnpm/react@18.3.1/node_modules/react
정리하면 둘의 역할은 아래와 같다고 할 수 있다.
Hard Link → 실제 패키지 파일을 효율적으로 저장 Symbolic Link → 필요한 위치에 패키지를 연결
2-3. Phantom Dependency를 막는 구조
pnpm 의 장점중 또 한가지는 유령 의존성을 막을 수 있다는 것이다.
기존 npm의 flat한 node_modules 구조에서는 하위 의존성이 상위로 hoist되는 경우가 많았다.
예를 들어 프로젝트에서는 library-a만 직접 설치했다고 해보자.
그리고 library-a가 내부적으로 lodash라는 라이브러리를 사용하고 있다고 하자.
app └── library-a └── lodash
npm에서는 설치 과정에서 lodash가 상위 node_modules로 올라올 수 있다.
node_modules/ ├── library-a └── lodash
그러면 내 프로젝트의 package.json에는 lodash가 없는데도 아래 코드가 정상적으로 동작하는 충격적인 일이 발생한다.
import lodash from "lodash";
이처럼 직접 선언하지 않은 의존성을 우연히 사용할 수 있는 상태를 Phantom Dependency, 즉 유령 의존성이라고 한다.
문제는 이런 코드가 현재 환경에서는 정상적으로 동작하더라도, 의존성 구조가 바뀌거나 library-a가 더 이상 lodash를 사용하지 않게 되면 갑자기 깨질 수 있다는 점이다.
pnpm은 이러한 문제를 줄이기 위해 각 workspace의 node_modules에 해당 workspace가 직접 선언한 dependency를 중심으로 노출한다.
app/package.json ├── library-a └── ❌ lodash 없음 app/node_modules └── library-a → 앱 코드에서 lodash를 직접 사용하기 어려움 `lodash`를 직접 사용하고 싶다면 명시적으로 의존성을 선언해야 한다.
다만 pnpm이 모든 미선언 의존성을 완전히 차단하는 것은 아니다.
일부 외부 패키지가 잘못된 의존성 선언에 기대고 있어도 동작할 수 있도록, node_modules 내부 패키지를 위한 hidden hoisting 영역을 제공한다.
앱 코드 → 직접 선언한 dependency 중심으로 접근 node_modules 내부 패키지 → hidden hoisting을 통해 일부 dependency 접근 가능
따라서 pnpm은 완전히 strict한 구조라기보다는, 앱 코드에는 엄격하게, 설치된 외부 패키지들끼리는 조금 느슨하게 허용한다라고 이해할 수 있다.
2-4. pnpm node_modules의 3개 Layer 이해해보기
pnpm의 구조를 이해할 때는 node_modules를 세 개의 Layer로 나누어 보면 편하다.
Layer 1 apps/*/node_modules Layer 2 root/node_modules/.pnpm Layer 3 root/node_modules/.pnpm/node_modules
Layer 1. 앱(프로젝트)의 node_modules
앱이 직접 사용할 수 있는 패키지를 노출한다.
apps/app-a/node_modules/
애플리케이션이 직접 바라보는 공간이다. 예를 들어:
{ "dependencies": { "react": "18.3.1", "axios": "1.7.0" } }
이라면 node_modules에는 아래처럼 보일것이다.
하지만 대부분 실제 파일이 아니라 Layer 2를 가리키는 Symbolic Link다.
apps/app-a/node_modules/ react axios
Layer 2. .pnpm Virtual Store
pnpm이 실제 dependency graph를 구성하는 공간
node_modules/.pnpm/
해당 폴더에는 예를 들면 다음과 같은 디렉터리를 볼 수 있다.

특히 다음처럼 패키지 이름 뒤에 다른 버전들이 붙어 있을 수 있다.
이는 pnpm이 단순히 패키지 버전만 저장하는 것이 아니라 peer dependency까지 포함한 dependency context를 관리하기 때문이다.
highcharts-react-official@3.2.1 _highcharts@11.4.8 _react@18.3.1
Layer 3. Internal Hoisting Layer
앞에서 pnpm은 각 workspace에 직접 선언한 dependency를 중심으로 노출해 앱 코드의 Phantom Dependency를 줄인다고 설명했다.
하지만 이를 모든 패키지에 완전히 엄격하게 적용하면 문제가 생길 수 있다.
npm 생태계에는 자신이 사용하는 dependency를 정확히 선언하지 않고, 기존의 hoisting 구조에 기대어 동작하는 패키지도 있기 때문이다.
이런 패키지와의 호환성을 위해 pnpm은 일부 dependency를 다음 위치에 내부적으로 hoist한다. 이 공간이 Internal Hoisting Layer다.

node_modules/.pnpm/node_modules/
즉 pnpm은:
앱 코드 → 미선언 dependency 접근을 최대한 제한 node_modules 내부 패키지 → 호환성을 위해 일부 미선언 dependency 접근 허용
이라는 semi-strict한 구조를 사용한다는 것을 알 수 있다.
다만 이 Layer는 뒤에서 살펴볼 버전 오염 문제를 만들 수도 있다.
3. peerDependencies는 왜 중요할까
모노레포의 공유 패키지를 운영하다 보면 peerDependencies가 중요해진다.
모노레포를 사용하기 전에는 dependencies와 devDependencies만 사용하고 있던 터라 peerDependencies에 대한 존재조차 몰랐었고 수많은 오류를 겪었던거 같다.
pnpm을 이해하면, peerDependencies가 어떤건지, 어떻게 활용하는지에 대해서 조금이나마 쉽게 이해가 가능하다.
3-1. 패키지에서 dependencies와 peerDependencies의 차이
공통 UI 패키지가 React를 사용한다고 해보자. React를 일반 dependencies에 넣을 수도 있다.
packages/ui { "dependencies": { "react": "18.3.1" } }
그러면 구조상 다음과 같이 될 수 있다.
app ├── react └── @repo/ui └── react
즉 앱도 React를 가지고 있고, @repo/ui도 별도의 React를 가질 수 있다.
React처럼 host 앱과 동일한 인스턴스를 공유하는 것이 중요한 라이브러리는 이 구조가 문제가 될 수 있다.
그래서 ui 패키지와 같은 라이브러리 성격의 패키지에서는 다음처럼 사용할 수 있다.
{ "peerDependencies": { "react": "^18.0.0" } }
의미는 다음과 같다.
나는 React와 함께 동작하며, 나를 사용하는 애플리케이션과 호환되는 React 인스턴스를 공유해야 한다.
현재 pnpm은 optional로 표시되지 않은 누락 peer dependency를 자동 설치할 수 있으므로,
peerDependencies의 핵심은 “설치하지 않는다”가 아니라 consumer와의 호환 범위와 공유 관계를 선언하는 데 있다.
하지만 @repo/ui 자체를 개발할 때도 React는 필요하다.
Storybook을 실행하거나 타입 체크를 해야 하기 때문이다. 그래서 보통 다음처럼 함께 사용할 수 있다.
{ "peerDependencies": { "react": "^18.0.0" // 실제 consumer 프로젝트가 제공 }, "devDependencies": { "react": "18.3.1" // 패키지 자체 개발에 사용 } }
3-2. 같은 버전의 패키지가 .pnpm에 여러 개 생기는 이유
pnpm에서는 같은 패키지, 같은 버전이라도 함께 사용하는 peer dependency 버전이 다르면 서로 다른 환경으로 구분한다.
예를 들어 App A는 React 18을, App B는 React 19를 사용한다고 하자.
두 앱 모두 같은 @repo/ui@1.0.0을 사용하지만, @repo/ui는 React를 peerDependencies로 선언하고 있다.
App A └── @repo/ui@1.0.0 └── React 18과 함께 사용 App B └── @repo/ui@1.0.0 └── React 19와 함께 사용
@repo/ui 자체의 버전은 둘 다 1.0.0으로 같지만, 함께 연결되는 React 버전이 다르다.
그래서 pnpm은 이를 서로 다른 dependency context로 구분할 수 있다.
@repo+ui@1.0.0_react@18... @repo+ui@1.0.0_react@19...
즉:
같은 @repo/ui@1.0.0 + React 18 환경 → 하나의 context + React 19 환경 → 또 다른 context
처럼 이해하면 된다.
따라서 .pnpm에 같은 버전의 패키지가 여러 개 보인다고 해서 반드시 중복 설치 문제가 있는 것은 아니다. peer dependency 환경이 달라서 pnpm이 각각 따로 관리하고 있을 수 있다.
3-3. 같은 버전이어도 문제가 생길 수 있다
앞에서 살펴본 것처럼 pnpm은 프로젝트의 .pnpm 안에서 같은 패키지의 같은 버전이라도 peer dependency 조합이 다르면 서로 다른 dependency context로 관리할 수 있다.
예를 들어 하나의 .pnpm 안에 같은 React Query가 다음처럼 여러 context로 존재할 수 있다.
node_modules/.pnpm/ ├── @tanstack+react-query@4.36.1_...context-A └── @tanstack+react-query@4.36.1_...context-B
이렇게 여러 context가 존재하는 것 자체는 문제가 아니다.
문제는 하나의 앱에서 서로 다른 dependency 경로를 통해 각각 다른 React Query 인스턴스를 참조하게 되는 경우다.
React Query는 내부적으로 QueryClientContext를 사용한다.
만약 QueryClientProvider와 useQuery가 서로 다른 React Query 인스턴스를 사용한다면:
하나의 App QueryClientProvider → React Query A → Context A에 QueryClient 저장 useQuery → React Query B → Context B를 조회
처럼 서로 다른 Context를 바라보게 된다.
Provider는 분명히 존재하지만 useQuery가 다른 Context를 확인하고 있기 때문에 QueryClient를 찾지 못하고 다음과 같은 에러가 발생할 수 있다.
No QueryClient set
여기서 .pnpm에 같은 패키지가 여러 개 존재하는 것 자체가 문제는 아니다.
하지만 하나의 앱에서 서로 다른 dependency 경로가 같은 라이브러리의 서로 다른 인스턴스를 참조하게 될 때 문제가 발생할 수 있다.
따라서 이런 문제가 생긴다면 단순히 패키지 버전만 확인하는 것이 아니라, .pnpm에 어떤 peer dependency context가 만들어졌고 실제 앱이 어느 인스턴스를 참조하고 있는지도 함께 확인해야 한다.
Provider와 Hook이 서로 다른 React Query 인스턴스를 보고 있다면, 그 둘을 억지로 합치는 게 아니라 peer context가 갈린 dependency 경로를 찾아서 같은 peer 환경을 사용하도록 의존성 구조를 수정해야 한다.
3-4. 같은 workspace package를 서로 다른 React 버전에서 사용한다면?
pnpm workspace의 내부 패키지는 보통 원본 폴더를 Symbolic Link로 연결해서 사용한다.
App A ─┐ ↓ packages/ui ↑ App B ─┘
즉 App A와 App B가 모두 @repo/ui를 사용하더라도 실제로는 같은 packages/ui를 바라본다.
그런데 두 앱의 React 버전이 다르다면 어떨까?
App A └── React 18 └── @repo/ui App B └── React 19 └── @repo/ui
@repo/ui가 React를 peerDependencies로 사용한다면 App A에서는 React 18과, App B에서는 React 19와 연결되어야 한다.
하지만 기본 workspace 연결에서는 두 앱이 같은 packages/ui를 바라보고 있기 때문에,
consumer 프로젝트 마다 다른 peer dependency 환경을 만들기 어려운 경우가 있다.
이럴 때 사용할 수 있는 기능이 injected다.
{ "dependenciesMeta": { "@repo/ui": { "injected": true } } }
injected를 사용하면 하나의 packages/ui를 그대로 공유하는 대신, 각 앱에 맞는 별도의 dependency context를 만들 수 있다.
App A └── @repo/ui └── React 18 App B └── @repo/ui └── React 19
다만 일반적인 Symbolic Link처럼 원본 수정이 바로 반영되지 않을 수 있기 때문에,
여러 consumer가 서로 다른 peer dependency 환경을 사용해야 할 때 고려할 수 있는 기능 정도로 이해하면 된다.
3-5. Internal Hoisting 때문에 다른 버전을 바라볼 수도 있다
앞에서 pnpm은 .pnpm 안에서 서로 다른 버전의 dependency를 분리해서 관리할 수 있다고 했다.
예를 들어:
App A → @types/react 18 App B → @types/react 19
.pnpm에서는 두 버전이 각각 따로 존재할 수 있다.
.pnpm/ ├── @types+react@18... └── @types+react@19...
여기까지는 문제가 없다.
하지만 앞에서 살펴본 Internal Hoisting Layer에는 일부 dependency가 공통 위치로 올라올 수 있다.
.pnpm/node_modules/@types/react
만약 여기에 React 19 타입이 올라와 있는데, 어떤 외부 라이브러리가 자신의 @types/react를 제대로 선언하지 않았다면 이 hoisted 버전을 잘못 가져다 사용할 수 있다.
App A └── React 18 └── @types/react 18 하지만 특정 외부 라이브러리 └── hoisted @types/react 19 참조
그러면 React 18 앱인데도 React 19 타입 관련 에러가 발생할 수 있다.
즉 여러 버전을 사용하는 것 자체가 문제가 아니라, 원래 분리되어야 할 dependency가 hoisting된 버전을 잘못 사용하게 되는 것이 문제다.
필요하다면 특정 dependency를 hoisting 대상에서 제외할 수 있다.
hoistPattern: - "*" - "!@types/react" - "!@types/react-dom"
그리고 외부 라이브러리의 dependency 선언 자체가 잘못되어 있다면 packageExtensions로 부족한 선언을 보완할 수도 있다.
packageExtensions: "@some/library": peerDependencies: "@types/react": "*"
3-6. 의존성 문제는 어떻게 찾을까?
의존성 문제가 생겼다고 해서 처음부터 node_modules 전체를 분석할 필요는 없다.
나는 다음 순서로 확인하면 이해하기 쉽다고 생각했다.
package.json확인
먼저 해당 라이브러리를 사용하는 workspace에 dependency가 제대로 선언되어 있는지 확인한다.
이 라이브러리를 누가 사용하는가? ↓ 그 workspace의 package.json에 선언되어 있는가?
pnpm why로 어디에서 들어왔는지 확인
내가 직접 설치하지 않은 dependency가 보인다면pnpm why를 사용한다.
pnpm -r why react-native
예를 들어:
react-spring └── @react-spring/native └── react-native
처럼 나오면 react-native가 어디에서 들어왔는지 알 수 있다.
즉: pnpm why = 누가 이 dependency를 가져왔는지 확인 이라고 생각하면 된다.
.pnpm에서 실제 설치 상태 확인
같은 패키지가 여러 개 보이거나 peer dependency 문제가 의심된다면 .pnpm을 확인한다.
ls node_modules/.pnpm | grep "react-query"
react-query@4.36.1_react@18... react-query@4.36.1_react@18..._react-native@...
같은 버전인데 뒤에 붙은 peer dependency가 다르다면 서로 다른 dependency context로 설치된 것이다.
- 필요하면
pnpm-lock.yaml까지 확인
더 정확하게 어떤 버전과 peer dependency 조합이 결정되었는지는 lockfile에서도 확인할 수 있다.
package.json ↓ pnpm why ↓ .pnpm ↓ pnpm-lock.yaml
그래도 원인을 찾기 어렵고 특히 타입 관련 문제가 발생한다면 마지막으로 .pnpm/node_modules의 Internal Hoisting까지 확인해볼 수 있다.
마치며
처음에는 모노레포에서 의존성을 관리한다는 것이 단순히 라이브러리를 어디에 설치할지 정하는 문제라고 생각했다.
하지만 pnpm의 구조를 살펴보니 패키지를 각 프로젝트마다 단순히 복사해서 사용하는 방식과는 조금 달랐다.
-
pnpm은 실제 패키지 파일을 Global Store에 저장해 재사용하고, 프로젝트에서는 Hard Link와 Symbolic Link를 이용해 필요한 패키지를 연결한다.
-
그리고
.pnpm에서는 단순히 패키지의 버전만 관리하는 것이 아니라, 어떤 peer dependency와 함께 사용되는지도 하나의 dependency context로 관리한다.
따라서 같은 패키지의 같은 버전이라도 peer dependency가 다르면 서로 다른 context가 만들어질 수 있다. -
이렇게 분리해서 관리하는 덕분에 서로 다른 dependency 환경을 사용할 수 있지만, React Query처럼 하나의 인스턴스를 공유하는 것이 중요한 라이브러리가 여러 context로 나뉘면 예상하지 못한 문제가 발생할 수도 있다.
결국 pnpm을 사용하면서 중요한 것은 의존성의 위치, 버전, 그리고 다른 패키지와의 관계를 명확하게 만드는 것이라는 것을 알게 되었다.
다음 세 가지를 기준으로 생각하면 조금 단순해지는 것 같다.
1. 누가 사용하는가? → 실제 사용하는 workspace에 dependency를 선언한다. 2. 어떤 버전을 사용할 것인가? → 필요하다면 catalogs 등을 통해 버전 정책을 관리한다. 3. 누구와 함께 사용되어야 하는가? → 공유 패키지라면 peerDependencies를 고려한다.
그리고 문제가 생겼을 때는 처음부터 복잡한 .pnpm 구조를 모두 분석하기보다 아래 순서로 문제를 좁혀가 보고 있다.
package.json → pnpm why → .pnpm → pnpm-lock.yaml
먼저 dependency가 올바른 workspace에 선언되어 있는지 확인하고, pnpm why를 통해 어떤 경로로 들어왔는지 살펴본다. 그래도 원인을 찾기 어렵다면 .pnpm과 pnpm-lock.yaml을 확인하면서 실제 dependency context가 어떻게 만들어졌는지 확인할 수 있다.
pnpm의 node_modules 구조는 처음 보면 복잡하지만, 결국 패키지 파일은 최대한 재사용하면서 package.json에 선언한 의존성 관계를 실제 설치 구조에 최대한 정확하게 표현하려는 방식이라고 생각하면 이해하기 쉬운 것 같다.
결국 모노레포의 의존성 관리는 “이 dependency를 누가 사용하고, 어떤 버전을 사용하며, 어떤 dependency와 함께 동작해야 하는가?”를 명확하게 설계하는 일에 가깝다는 생각이 든다.