npm과 pnpm은 대체 뭐가 다른 걸까
목차
- Node.js는 모듈을 어떻게 찾는가
- npm의 node_modules: Nested에서 Flat으로
- npm v2 시절 — 정직한 중첩
- npm v3 이후 — 호이스팅(Hoisting)
- 유령 의존성이라는 구조적 결함
- pnpm이 선택한 길: 처음부터 다시 설계하기
- 1. Content-Addressable Store (CAS)
- 2. Hard Link + Symbolic Link의 조합
- .pnpm 안에 왜 또 node_modules가 있는가
- pnpm은 현실과 어떻게 타협하는가
- 모노레포에서 벌어지는 차이
- npm workspaces
- pnpm workspace
- 설치 속도는 왜 다른가
- 같은 알고리즘, 정반대의 해석
- 참고 자료
npm과 pnpm을 비교하는 글은 이미 많다. 그런데 대부분 pnpm이 빠르고 디스크를 적게 쓴다는 결론으로 끝난다.

실제로 pnpm을 쓰다가 node_modules 안을 열어봤더니 구조가 npm과 완전히 달랐다.
.pnpm이라는 디렉토리 안에 심볼릭 링크가 잔뜩 걸려 있고, 내가 설치한 패키지는 최상위에 달랑 하나만 있었다.
pnpm의 설계 구조에 대해 굉장히 흥미로워서, 어떤 이유로 이 구조로 설계되었는지 알아보고자 한다.
Node.js는 모듈을 어떻게 찾는가
npm이든 pnpm이든 결국 node_modules라는 디렉토리에 패키지를 배치하는 도구인데, 이 배치 방식이 왜 중요하냐면 Node.js의 모듈 해석 알고리즘과 직접적으로 맞물리기 때문이다.
이 알고리즘을 모르면 npm과 pnpm의 차이가 왜 생기는지를 이해할 수가 없다.
Node.js에서 import express from 'express'를 만나면 일어나는 일을 간략하게 정리하면 이렇다.
1. 코어 모듈인지 확인 (fs, path, http 등)
2. './' 또는 '../'로 시작하면 상대 경로로 해석
3. 그 외엔 node_modules 탐색 시작
→ 현재 파일이 위치한 디렉토리의 node_modules/
→ 없으면 상위 디렉토리의 node_modules/
→ 없으면 또 상위의 node_modules/
→ 루트까지 반복
핵심은 3번이다. Node.js는 import 'express'를 만나면 현재 파일 위치부터 위쪽으로 올라가면서 node_modules/express를 찾는다. 이 탐색 방식은 CommonJS의 require든 ESM의 import든 동일하다.
이 위로 올라가면서 찾기 메커니즘이 npm의 호이스팅이 작동할 수 있는 이유이자, 동시에 유령 의존성이 생기는 원인이기도 하다.
여기서 하나 더 중요한 게 있다.
Node.js는 심볼릭 링크를 기본적으로 resolve해서 실제 경로(realpath)를 기준으로 모듈을 탐색한다.
--preserve-symlinks 플래그를 주지 않는 한, 심볼릭 링크를 따라간 끝에 있는 실제 파일의 위치를 기준으로 node_modules 탐색을 시작한다는 뜻이다.
이건 뒤에서 pnpm이 왜 하드 링크를 쓰는지 설명할 때 다시 나온다.
npm의 node_modules: Nested에서 Flat으로
npm v2 시절 — 정직한 중첩
npm의 초기 버전은 의존성 트리를 있는 그대로 디스크에 풀어놨다.
node_modules/
├── A/
│ └── node_modules/
│ └── B@1.0/
├── C/
│ └── node_modules/
│ └── B@2.0/
└── D/
└── node_modules/
└── B@1.0/ ← B@1.0이 A 아래에도 있고 D 아래에도 있다
구조는 직관적이고 장점도 분명했다.
각 패키지가 자기만의 node_modules를 갖고 있으니까 의존성 격리가 완벽했다.
A가 B@1.0을, C가 B@2.0을 쓰더라도 서로 간섭할 일이 없었다. 그리고 디스크에 풀린 구조가 곧 논리적 의존성 트리와 일치하니까 디버깅할 때도 직관적이었다.
하지만 이 구조에는 실용적인 한계가 있었다.
첫째, 같은 패키지의 중복 복사.
위 예시에서 B@1.0이 A와 D 아래에 각각 존재한다.
실제 프로젝트에서는 이런 중복이 수십, 수백 단위로 발생했다. npm v2 시절에 npm dedupe라는 명령어가 별도로 존재했던 것 자체가 이 문제의 심각성을 보여준다.
설치 후에 수동으로 중복을 정리해야 하는 구조였다는 뜻이니까.
둘째, Windows의 경로 길이 제한.
이게 단순한 불편이 아니라 npm의 설계를 바꾸게 만든 가장 직접적인 압력이었다. Windows API는 파일 경로를 최대 260자(MAX_PATH)로 제한하는데, 이건 NTFS 파일 시스템의 한계가 아니라 Win32 API의 역사적 제약이다.
중첩 구조에서 의존성이 깊어지면 경로가 아래와 같이 된다.
C:\\Users\\dev\\project\\node_modules\\grunt-bower-task\\node_modules\\bower\\
node_modules\\update-notifier\\node_modules\\request\\node_modules\\form-data\\
node_modules\\combined-stream\\node_modules\\delayed-stream\\...
이 경로만으로 이미 200자가 넘는다. 여기에 실제 파일 경로까지 더하면 260자를 쉽게 넘겼다.
결과는?
npm install이 실패하거나, 설치는 되는데 Windows 탐색기에서 node_modules 폴더를 삭제할 수 없는 상황이 벌어졌다. 2014년에 Node.js GitHub 저장소에 올라온 이슈 #6960의 제목이 ‘Node의 중첩된 node_modules 구조는 사실상 Windows와 양립하기 어렵다’였을 정도다.
이건 Node.js 생태계에서 무시할 수 없는 문제였다. 2015년 당시 Windows는 개발자 데스크톱에서 여전히 큰 비중을 차지하고 있었고, 기업 환경에서는 더더욱 그랬다. npm이 Windows에서 제대로 안 돈다는 건 Node.js 생태계의 성장 자체를 가로막는 병목이었다.
셋째, 설치 속도와 디스크 사용량.
중복 복사는 곧 네트워크에서 같은 패키지를 여러 번 받고, 디스크에 같은 파일을 여러 번 쓴다는 뜻이었다.
프로젝트 규모가 커질수록 npm install은 느려졌고, node_modules의 크기는 기하급수적으로 불어났다.
npm v3 이후 — 호이스팅(Hoisting)
npm v3(2015년 6월 릴리스)는 이 문제들을 한꺼번에 해결하기 위해 설치 알고리즘 자체를 바꿨다.
v3 릴리스 노트의 첫 문장은 다음과 같았다.
‘Your dependencies will now be installed maximally flat’
가능한 한 모든 패키지를 node_modules 최상위로 끌어올리기 시작한 것이다.
node_modules/
├── A/
├── B@1.0/ ← 최상위로 호이스팅됨
├── C/
│ └── node_modules/
│ └── B@2.0/ ← 충돌이 있는 버전만 중첩
└── D/ ← B@1.0은 위에 있으니까 여기선 안 둔다
A와 D가 공통으로 쓰는 B@1.0을 루트에 한 번만 두고, 버전이 다른 B@2.0만 C 하위에 배치한다.
중복이 사라지니 디스크 사용량이 줄고, 트리가 플랫해지니 경로 깊이 문제도 해결된다.
npm v3 릴리스 노트에서도 ‘이 변경으로 인해 Windows 사용자들이 경로가 너무 길어지는 문제를 겪는 대부분의 경우가 사라지기를 바란다’라고 직접 언급했을 만큼, Windows 호환성은 이 변경의 핵심 동기 중 하나였다.
npm v2에서 별도 명령어(npm dedupe)로 수동 처리하던 걸, v3에서는 설치 시점에 자동으로 수행하게 만든 셈이다. 설계 의도만 놓고 보면 합리적인 선택이었다.
중첩 구조의 격리성을 포기하는 대신 실용성을 택한 것이니까.
근데 이 호이스팅이라는 게, 앞서 살펴본 Node.js의 모듈 해석 알고리즘과 만나면서 의도하지 않은 부작용을 만들어냈다.
유령 의존성이라는 구조적 결함
express만 설치한 프로젝트를 생각해보자.
express 내부적으로 accepts라는 패키지를 의존하고 있다.
npm의 호이스팅 때문에 accepts가 node_modules/ 최상위에 올라온다.
node_modules/
├── express/
├── accepts/ ← express의 의존성인데 최상위에 올라와 있다
├── mime-types/ ← accepts의 의존성인데 역시 올라와 있다
└── ...
이 상태에서 내 코드에 이렇게 쓰면?
// package.json에는 express만 있는데...
import accepts from "accepts"; // 동작한다!
Node.js의 모듈 해석 알고리즘은 현재 파일 위치에서 위로 올라가면서 node_modules/accepts를 찾는데, 루트 node_modules에 실제로 있으니까 아무 문제 없이 찾는다.
package.json에 선언했는지 안 했는지는 Node.js가 신경 쓸 바가 아니다.
이걸 유령 의존성(Phantom Dependency)이라고 부른다.
왜 이게 문제인지 구체적으로 보면 express를 업데이트했는데 전혀 관계없는 코드가 깨진다.
express가 내부적으로 accepts@1.x에서 accepts@2.x로 올렸다면, 호이스팅되는 accepts의 버전도 바뀐다. 내 코드에서 accepts의 API를 직접 쓰고 있었다면 breaking change를 맞는다. 그런데 express만 업데이트했을 뿐이니까 원인을 찾기가 쉽지 않다.

내 로컬에서는 되는데 동료 머신이나 CI에서 안 되는 그 상황이 발생하는 것이다.
npm의 호이스팅 결과는 설치 순서에 영향을 받는다. 정확히 같은 package.json이라도 lockfile 상태나 npm 버전, 혹은 이미 설치된 패키지의 상태에 따라 어떤 버전이 최상위에 올라가는지가 달라질 수 있다.
express를 제거하면 내 코드가 깨진다. express를 지우면 express가 끌고 왔던 accepts도 함께 사라진다. 내 코드가 accepts에 의존하고 있었다는 사실을 그제야 알게 된다.
package-lock.json이 호이스팅 결과를 고정시켜주긴 하지만, 이건 증상을 관리하는 것이지 구조적 결함 자체를 해결하는 건 아니다.
pnpm이 선택한 길: 처음부터 다시 설계하기
pnpm은 이 문제를 패치가 아니라 node_modules의 구조 자체를 바꾸는 것으로 해결한다.
두 가지 핵심 개념이 있다.
1. Content-Addressable Store (CAS)
pnpm은 패키지 파일을 프로젝트 안에 복사하지 않는다.
대신 디스크 어딘가에 있는 전역 저장소에 파일을 저장한다.
보통 ~/.local/share/pnpm/store/(Linux) 또는 ~/Library/pnpm/store/(macOS) 경로에 위치한다.
저장소의 구조는 다음과 같다.
~/.local/share/pnpm/store/v3/
└── files/
├── 00/
│ ├── a92134...023
│ └── b7f291...8a1
├── 01/
│ └── ...
└── ff/
└── ...
파일 이름이 해시값이다.
파일의 내용을 해시한 값이 곧 파일의 주소가 되는 구조라서 Content-Addressable이라고 부른다.
이 구조가 주는 이점이 꽤 강력한데, lodash를 예로 들어보면
- 10개의 프로젝트가 같은 버전의 lodash를 쓴다 → 디스크에는 1벌만 존재한다. 10개 프로젝트 모두 같은 해시를 가리키니까.
- lodash가 마이너 업데이트되면서 500개 파일 중 1개만 바뀌었다 → 변경된 1개 파일의 해시만 새로 추가된다. 나머지 499개는 기존 해시를 그대로 참조한다.
npm은 프로젝트마다 패키지 파일 전체를 복사하니까, 10개 프로젝트면 10벌이다. 업데이트하면 500개 파일 전체를 다시 복사한다. 이에 반해 pnpm은 굉장히 효율적이다.
npm이 복사로 해결한 문제를, pnpm은 파일 시스템 레벨의 링크로 해결한다. 복잡해 보이지만, 구조는 오히려 더 정직하다.
2. Hard Link + Symbolic Link의 조합
전역 저장소에 파일이 한 벌만 있다면, 프로젝트의 node_modules에는 뭐가 있는 걸까?
링크가 있다.
pnpm의 node_modules 구조를 express 하나만 설치한 경우로 살펴보자.
node_modules/
│
├── express → .pnpm/express@4.18.2/node_modules/express ← ①
│
└── .pnpm/
│
├── express@4.18.2/
│ └── node_modules/
│ ├── express/
│ │ ├── index.js ──→ <store>/a92... ← ②
│ │ └── package.json ──→ <store>/b7f...
│ ├── accepts → ../../accepts@1.3.8/node_modules/accepts ← ③
│ └── body-parser → ../../body-parser@1.20.1/node_modules/body-parser
│
├── accepts@1.3.8/
│ └── node_modules/
│ ├── accepts/
│ │ ├── index.js ──→ <store>/...
│ │ └── package.json ──→ <store>/...
│ └── mime-types → ../../mime-types@2.1.35/node_modules/mime-types
│
└── ...
세 종류의 링크가 쓰이고 있다. 하나씩 보자.
① 프로젝트 루트 → .pnpm (심볼릭 링크)
node_modules/express는 심볼릭 링크다. .pnpm 안에 있는 실제 위치를 가리킨다. 그리고 여기가 핵심인데, package.json에 내가 직접 선언한 패키지만 이 최상위 심볼릭 링크를 갖는다.
accepts는? 최상위에 없다.
그래서 내 코드에서 import accepts from 'accepts'를 하면 Node.js가 현재 디렉토리부터 위로 올라가면서 node_modules/accepts를 찾지만, 루트 node_modules에는 accepts가 없으므로 MODULE_NOT_FOUND 에러가 난다.
유령 의존성이 구조적으로 불가능해지는 지점이 바로 여기다.
② .pnpm 내부 파일 → Store (하드 링크)
.pnpm/express@4.18.2/node_modules/express/index.js는 전역 저장소의 파일에 대한 하드 링크다. 심볼릭 링크가 아니라 하드 링크인 이유가 있다.
앞에서 ‘Node.js는 심볼릭 링크를 따라가서 realpath를 구한 뒤, 그 위치 기준으로 모듈을 탐색한다’고 했다.
만약 index.js를 전역 저장소(~/.local/share/pnpm/store/...)로 심볼릭 링크했다면, Node.js는 realpath인 저장소 경로를 기준으로 node_modules를 찾으려 할 것이다.
당연히 거기엔 node_modules가 없으니 의존성 해석이 전부 깨진다.
하드 링크는 이 문제가 없다. 하드 링크는 같은 inode를 가리키는 또 다른 이름일 뿐이고, OS와 Node.js 입장에서는 그냥 독립된 파일처럼 보인다. realpath를 구해봐도 node_modules 안의 경로가 그대로 나온다. 그래서 모듈 해석이 정상적으로 동작한다. 동시에 실제 데이터는 저장소의 것을 공유하니까 디스크 공간도 추가로 안 든다.
③ 패키지 간 의존성 → 다른 .pnpm 경로 (심볼릭 링크)
express가 accepts에 의존하니까, express@4.18.2/node_modules/ 안에 accepts를 가리키는 심볼릭 링크가 있다. express의 코드에서 import accepts from 'accepts'를 하면 Node.js가 express@4.18.2/node_modules/accepts를 먼저 찾고, 이 심볼릭 링크를 타고 accepts@1.3.8의 실제 위치로 간다.
.pnpm 안에 왜 또 node_modules가 있는가
아래 구조를 처음 보면 좀 이상하게 느껴진다.
.pnpm/express@4.18.2/node_modules/express/
express@4.18.2 아래에 node_modules가 왜 한 번 더 있고, 그 안에 또 express가 있을까?
이건 Node.js의 모듈 해석 알고리즘을 이용하기 위한 의도적인 설계다. 두 가지 이유가 있다.
첫째, express의 의존성 범위를 정확히 제어하기 위해.
express의 코드에서 import accepts from 'accepts'를 하면 Node.js는 express 파일이 위치한 디렉토리(express@4.18.2/node_modules/express/)부터 위로 올라가면서 node_modules/accepts를 찾는다. 한 단계 위인 express@4.18.2/node_modules/에 accepts 심볼릭 링크가 있으니까 바로 찾게 된다.
만약 express@4.18.2/ 바로 아래에 파일을 뒀다면?
Node.js가 위로 올라가면서 .pnpm/node_modules/를 찾게 될 텐데, 거기엔 다른 패키지들의 의존성이 섞여 있을 수 있다.
패키지마다 격리된 node_modules를 두는 것이 의존성 범위를 정확하게 만드는 방법이다.
둘째, 패키지가 자기 자신을 참조(self-referencing)할 수 있도록.
일부 패키지는 자기 자신의 이름으로 import를 하는 경우가 있다.
express@4.18.2/node_modules/express/ 안에 코드가 있으니까, import express from 'express'를 하면 같은 node_modules/express/에서 자기 자신을 찾을 수 있다.
pnpm은 현실과 어떻게 타협하는가
pnpm의 기본 구조는 package.json에 선언하지 않은 패키지에 대한 접근을 차단한다.
그런데 현실의 npm 생태계에는 유령 의존성에 (의도적이든 아니든) 기대고 있는 패키지들이 적지 않다.
대표적인 예가 플러그인 시스템을 가진 도구들이다.
ESLint 플러그인이 ESLint 코어의 유틸리티를 암묵적으로 참조한다거나, NestJS CLI가 내부적으로 @angular-devkit/schematics를 가져오면서 해당 패키지가 호이스팅되어 있을 것을 전제하는 식이다.
플러그인이 호스트 패키지의 의존성을 암묵적으로 참조하는 구조 자체가 유령 의존성을 전제로 설계된 셈이다. 이런 패키지들을 pnpm의 엄격한 기본 구조에서 그대로 쓰면 MODULE_NOT_FOUND가 나고, 처음 pnpm을 도입할 때 가장 자주 마주치는 벽이 바로 이 지점이다.
pnpm은 이를 위해 .npmrc에 엄격성을 조절할 수 있는 설정을 두고 있다.
가장 눈에 띄는 건 shamefully-hoist다. 부끄럽지만 호이스팅합니다.
이걸 true로 켜면 npm처럼 모든 패키지가 최상위로 올라간다.
이름에 shamefully를 붙인 건, 이게 자신들의 설계 철학에 반하는 타협이라는 걸 사용자에게 명시적으로 알려주겠다는 의도다.
좀 더 세밀한 조절도 가능하다.
hoist-pattern으로 특정 패키지만 선택적으로 호이스팅하거나, node-linker=hoisted로 아예 npm과 동일한 flat 구조를 만들 수도 있다.
심볼릭 링크와 호환이 안 되는 환경(React Native, 일부 서버리스 플랫폼 등)에서는 이런 설정이 필요할 수 있다. 세부 옵션은 pnpm 공식 문서를 참고하자.
모노레포에서 벌어지는 차이
단일 프로젝트에서의 차이도 크지만, 모노레포에서는 두 도구의 설계 차이가 더 극명하게 드러난다.
npm workspaces
npm은 v7부터 workspaces를 지원한다. 루트 node_modules에 모든 워크스페이스 패키지의 의존성을 호이스팅한다.
monorepo/
├── node_modules/ ← 모든 패키지의 의존성이 여기에 flat하게 깔린다
│ ├── @nestjs/core/
│ ├── typeorm/
│ └── ...
├── packages/
│ ├── api/
│ │ └── package.json ← @nestjs/core만 선언
│ └── batch/
│ └── package.json ← typeorm만 선언
└── package.json
문제는 packages/api/에서 typeorm을 import할 수 있다는 거다.
api는 typeorm을 선언하지 않았는데, 루트 node_modules에 있으니까 Node.js가 위로 올라가면서 찾아버린다.
단일 프로젝트에서의 유령 의존성 문제가 모노레포에서는 패키지 경계를 넘어서 발생하는 셈이다.
api 개발자가 typeorm을 자기 의존성인 줄 알고 쓰다가, batch가 typeorm을 제거하는 순간 api가 터진다. 패키지가 수십 개인 모노레포에서 이런 일이 생기면 원인 추적이 꽤 괴롭다.
pnpm workspace
pnpm은 pnpm-workspace.yaml로 워크스페이스를 관리한다.
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
핵심은 앞에서 설명한 pnpm의 symlink 기반 격리 구조가 모노레포의 각 패키지에도 동일하게 적용된다는 점이다. 각 패키지의 node_modules/에는 해당 패키지가 package.json에 선언한 의존성의 심볼릭 링크만 생긴다. api의 node_modules/에 typeorm 심볼릭 링크가 없으니까 import { Repository } from 'typeorm'은 실패한다. 단일 프로젝트에서 유령 의존성을 차단하는 것과 정확히 같은 원리가 모노레포 전체에 일관되게 작동하는 거다.
패키지 간 참조는 workspace: 프로토콜로 명시적으로 선언한다.
// packages/app/package.json
{
"dependencies": {
"@my-org/shared-utils": "workspace:*"
}
}
workspace:*는 워크스페이스에 있는 현재 버전을 가져오라는 의미다.
이렇게 선언하면 app의 node_modules/에 @my-org/shared-utils를 가리키는 심볼릭 링크가 생긴다. 퍼블리시할 때는 실제 버전 번호로 자동 치환된다.
패키지가 많아질수록 유령 의존성이 터질 확률은 올라간다.
이걸 코드 리뷰나 린트 규칙이 아니라 파일 시스템 구조 자체로 막아주는 건 꽤 큰 의미가 있다.
설치 속도는 왜 다른가
‘pnpm이 빠르다’는 건 많이 들어봤을 텐데, 왜 빠른지를 알면 그냥 벤치마크 숫자 이상의 이해가 된다.
수치가 궁금하다면 pnpm 공식 벤치마크를 참고하자.
npm의 설치 과정은 대략 의존성 트리 해석 → 레지스트리에서 패키지 다운로드 → node_modules에 파일 복사(압축 해제 포함) 순서다.
pnpm은 의존성 트리 해석 → 전역 저장소에 없는 패키지만 다운로드 → 하드 링크 + 심볼릭 링크 생성 순서다.
차이가 나는 지점이 두 번째와 세 번째 단계다.
다운로드 단계. 전역 저장소에 이미 있는 패키지는 아예 네트워크 요청을 안 한다.
다른 프로젝트에서 이미 설치한 적 있는 패키지라면 즉시 재사용된다. npm도 캐시가 있긴 하지만, 캐시에서 가져온 패키지를 node_modules에 복사하는 과정은 여전히 필요하다.
링크 생성 단계. pnpm은 파일을 복사하는 게 아니라 링크를 생성한다.
파일 복사는 데이터 블록을 읽고 쓰는 I/O 작업인 반면, 하드 링크 생성은 파일 시스템의 메타데이터(inode 참조 카운터)만 업데이트하면 끝난다.
OS 레벨에서 보면 link() syscall 한 번이 open() → read() → write() → close() 시퀀스를 대체하는 셈이다. 파일 크기가 클수록, 파일 수가 많을수록 이 차이는 벌어진다.
병렬 처리. pnpm은 해석, 다운로드, 링크 생성을 패키지 단위로 병렬 처리한다. A 패키지를 다운로드하는 동안 이미 받은 B 패키지는 링크를 생성하고, 아직 안 받은 C 패키지는 해석을 진행할 수 있다. npm은 이 단계들이 상대적으로 순차적이다.
cold install(캐시 없이 처음 설치)에서도 차이가 있지만,
진짜 차이가 크게 벌어지는 건 warm install(캐시가 있는 상태에서 재설치)이다.
npm은 캐시가 있어도 복사를 해야 하지만, pnpm은 링크만 걸면 되기 때문에!
같은 알고리즘, 정반대의 해석
npm은 파일 시스템을 dependency graph의 캐시로 사용하고,
pnpm은 파일 시스템을 dependency graph의 표현 계층으로 사용한다.
이 글에서 계속 Node.js의 모듈 해석 알고리즘을 기준으로 설명한 이유는, 결국 npm과 pnpm의 차이가 같은 알고리즘을 어떻게 활용하느냐의 문제이기 때문이다.
npm은 위로 올라가면서 찾아주니까 위에 두자였다. Windows의 경로 제한, 디스크 중복, 느린 설치 속도라는 현실적 문제를 풀기 위해, 최대한 많은 패키지를 최상위에 호이스팅해서 트리를 flat하게 만들었다.
중첩 구조가 주던 격리성을 포기하는 대신, 당장의 실용성과 호환성을 택한 것이다.
그리고 그 트레이드오프의 비용은 유령 의존성이라는 구조적 결함으로 돌아왔다.
pnpm은 위로 올라가면서 찾으니까, 위에 안 두면 접근 자체가 안 되겠다였다.
npm이 포기한 격리성을 되찾되, npm v2의 중복 문제는 전역 저장소와 하드 링크로 우회했다.
직접 선언한 패키지만 최상위에 심볼릭 링크를 두고, 나머지는 .pnpm 안에 격리시켰다.
그리고 실제 파일은 전역 저장소에 한 벌만 두고 하드 링크로 연결했다.
같은 규칙을 읽고 정반대 방향으로 설계한 셈인데, 결과적으로 npm은 편의와 호환성을, pnpm은 정확성과 효율성을 우선한 선택이었다.
참고 자료
- https://github.com/nodejs/node-v0.x-archive/issues/6960
- https://github.com/npm/npm/releases/tag/v3.0.0
- https://pnpm.io/motivation
- https://pnpm.io/symlinked-node-modules-structure
- https://pnpm.io/pnpm-vs-npm
- https://pnpm.io/benchmarks
- https://nodejs.org/api/modules.html
- https://rushjs.io/pages/advanced/phantom_deps/
- https://github.com/orgs/pnpm/discussions/6800