"셋 다 같은 npm registry에서 패키지를 찾아. 차이는 받은 파일을 디스크에 어떻게 놓고, 선언하지 않은 의존성을 얼마나 엄격하게 막느냐에 있어."
npm은 의존성을 위로 끌어올려
npm 3 이후의 기본 전략은 의존성을 하나의 node_modules/ 트리에 최대한 평평하게 배치하는 거야. 패키지 A와 B가 모두 lodash@4.17을 요구하면 최상위에 사본 하나만 둬. 서로 다른 버전을 요구하면 한 버전은 위로 올리고, 나머지는 필요한 패키지 아래에 따로 넣어.
이 구조는 Node가 원래 쓰던 탐색 규칙과 잘 맞고 중복 파일도 줄여 줘. 하지만 phantom 의존성이라는 함정이 생겨. 내 package.json에 underscore가 없는데도 다른 패키지가 끌고 온 underscore가 최상위에 올라오면 require('underscore')가 우연히 성공할 수 있거든. 그 간접 의존성이 바뀌는 날 내 코드는 아무 경고 없이 깨져.
pnpm은 저장 공간과 접근 권한을 나눠
pnpm은 보통 ~/.local/share/pnpm/store 같은 위치에 content-addressable store를 하나 두고, 같은 패키지 버전은 머신 전체에서 한 번만 저장해. 각 프로젝트의 node_modules에는 그 저장소를 가리키는 심볼릭 링크나 hardlink가 들어가. Node 프로젝트가 여러 개일수록 디스크 절약 효과가 커져.
더 중요한 차이는 엄격한 격리야. 프로젝트 코드에서 바로 접근할 수 있는 패키지는 package.json에 직접 선언한 것뿐이야. 간접 의존성은 node_modules/.pnpm/ 안에 정리돼 있고, 각 패키지는 자신에게 허용된 의존성만 보게 돼. npm의 평탄화에서 생기던 phantom 의존성을 구조로 막는 셈이지.
Turborepo나 Nx 같은 모노레포 도구가 pnpm을 자주 권하는 이유도 여기 있어. 엄격함이 불편한 제한이 아니라, 선언과 실제 사용을 맞춰 주는 안전장치야.
Bun은 런타임과 설치 도구를 함께 제공해
Bun은 JavaScript 런타임이면서 bun install이라는 Node 호환 패키지 매니저도 제공해. 만들어지는 node_modules 트리는 Node에서도 쓸 수 있어. installer가 Zig으로 작성돼 있어 npm보다 5배에서 20배 빠르게 끝나는 경우도 흔해.
설치는 Bun으로 하고 실행은 node script.js로 해도 돼. 반대로 bun script.js처럼 Bun을 런타임으로 쓸 수도 있어. 설치 도구와 실행 런타임을 같은 것으로 강제하지 않는다는 점을 기억해 둬.
잠금 파일이 재현성을 맡아
package.json에는 허용 범위가 적히지만, 실제로 선택된 직접·간접 의존성 버전은 잠금 파일에 기록돼.
- npm은
package-lock.json - pnpm은
pnpm-lock.yaml - Yarn은
yarn.lock - Bun은 현재 텍스트
bun.lock, 예전 버전은 바이너리bun.lockb
잠금 파일은 반드시 커밋해. 그래야 개발 머신과 CI가 같은 의존성 트리를 재현해. npm install은 package.json의 범위 안에서 잠금 파일을 갱신할 수 있지만, npm ci는 둘이 어긋나면 실패해. CI에서 조용히 새 버전을 고르지 않게 만드는 장치야.
한 저장소에는 한 매니저만
같은 저장소에서 npm과 pnpm을 번갈아 쓰면 잠금 파일이 둘 생기고, node_modules 배치 방식도 서로 덮어써. 그 상태에서 재현 가능한 설치를 기대하면 욕심이야. package.json의 packageManager 필드에 "pnpm@9.10.0"처럼 도구와 버전을 적고 Corepack으로 맞춰. 사람과 CI가 같은 선택을 하게 만드는 가장 가까운 곳이 바로 package.json이야.