node_modules의 문제
JavaScript 생태계에서 의존성 관리는 오랜 골칫거리였습니다. npm이 처음 등장했을 때 의존성은 중첩 구조 로 설치되었습니다.
node_modules/
express/
node_modules/
debug/
node_modules/
ms/
accepts/
cookie/
koa/
node_modules/
debug/ ← express와 동일한 패키지가 중복!
node_modules/
ms/ ← 또 중복!
accepts/이 중첩 구조는 세 가지 심각한 문제를 일으켰습니다.
- 경로 길이 초과 , Windows에서 260자 경로 제한에 걸림
- 디스크 낭비 , 같은 패키지가 여러 곳에 중복 설치
- 설치 속도 , 중복 다운로드와 디스크 I/O 증가
npm v3에서 이 문제를 해결하기 위해 flat 호이스팅 전략을 도입했지만, 이것이 또 다른 문제를 만들었습니다.
npm, Flat 호이스팅
npm v3+는 모든 의존성을 node_modules/ 루트로 끌어올립니다 (호이스팅). 같은 패키지의 같은 버전은 한 번만 설치됩니다.
node_modules/
express/
koa/
debug/ ← express, koa 모두의 하위 의존성, 루트로 호이스팅
ms/ ← debug의 하위 의존성, 역시 루트로 호이스팅
accepts/
cookie/중복은 줄었지만, 치명적인 부작용이 생겼습니다.
팬텀 의존성 (Phantom Dependencies)
package.json에 debug를 선언하지 않았는데도 import debug from 'debug'가 동작합니다. express의 하위 의존성인 debug가 루트에 호이스팅되었기 때문입니다.
// package.json에는 express만 선언
// { "dependencies": { "express": "^4.21.0" } }
// 그런데 이게 동작합니다!
import debug from 'debug'; // ← 팬텀 의존성문제는 express가 debug 의존성을 제거하는 순간 발생합니다. 어느 날 npm update 후 갑자기 앱이 깨집니다.
호이스팅 비결정성
같은 package.json이라도 설치 순서에 따라 node_modules 구조가 달라질 수 있습니다. A@1.0과 A@2.0이 동시에 필요할 때, 어떤 버전이 루트에 호이스팅되는지는 설치 순서에 의존합니다.
# 시나리오 1: A@1.0이 먼저 호이스팅
node_modules/
A@1.0/ ← 루트에 호이스팅
pkg-b/
node_modules/
A@2.0/ ← 중첩
# 시나리오 2: A@2.0이 먼저 호이스팅
node_modules/
A@2.0/ ← 루트에 호이스팅
pkg-a/
node_modules/
A@1.0/ ← 중첩이 비결정성을 해결하기 위해 package-lock.json이 도입되었습니다.
pnpm, Content-Addressable Store + Symlink
pnpm은 근본적으로 다른 접근법을 취합니다.
전역 Content-Addressable Store
모든 패키지 파일은 전역 store (~/.pnpm-store/v3/)에 한 번만 저장됩니다. 파일은 SHA-512 해시 기반으로 관리되며, 내용이 같으면 하나의 파일을 공유합니다.
~/.pnpm-store/v3/
files/
00/
abc123... ← debug/index.js의 실제 내용
01/
def456... ← ms/index.js의 실제 내용
...패키지가 100개의 파일을 가지고 새 버전에서 1개만 변경되었다면, store에는 변경된 1개 파일만 추가됩니다.
하드링크 + 심링크 구조
프로젝트의 node_modules는 store에서 하드링크로 연결된 가상 store와, 그것을 가리키는 심링크 로 구성됩니다.
node_modules/
express → .pnpm/express@4.21.0/node_modules/express (심링크)
.pnpm/ ← 가상 store
express@4.21.0/
node_modules/
express/ ← store에서 하드링크
debug/ ← store에서 하드링크
ms/ ← store에서 하드링크
cookie/ ← store에서 하드링크루트 node_modules/에는 package.json에 선언한 직접 의존성의 심링크만 존재합니다. 따라서 import debug from 'debug'는 구조적으로 불가능 합니다, debug는 루트에 없으니까요.
장점 정리
| 특성 | npm | pnpm |
|---|---|---|
| 디스크 사용량 | 프로젝트마다 중복 설치 | 전역 store 공유 (하드링크) |
| 팬텀 의존성 | 가능 (호이스팅) | 차단 (심링크 격리) |
| 설치 속도 | 매번 다운로드 + 복사 | store에 있으면 하드링크만 생성 |
| node_modules 구조 | 비결정적 가능 | 항상 결정적 |
Yarn Berry, Plug'n'Play (PnP)
Yarn Berry (v2+)는 더 급진적인 접근을 합니다. node_modules 폴더 자체를 제거 합니다.
.pnp.cjs, 의존성 룩업 테이블
Yarn PnP는 .pnp.cjs라는 단일 파일을 생성합니다. 이 파일은 모든 의존성의 위치와 버전을 매핑하는 룩업 테이블 입니다.
// .pnp.cjs (자동 생성, 실제 내용의 단순화)
["express", [
["npm:4.21.0", {
packageLocation: "./.yarn/cache/express-npm-4.21.0-abc123.zip/",
packageDependencies: [
["debug", "npm:4.3.4"],
["cookie", "npm:0.6.0"],
],
}],
]],Node.js의 모듈 해석을 이 파일이 대체합니다. 파일시스템을 순회하며 node_modules를 찾는 대신, 테이블에서 즉시 위치를 찾습니다.
패키지 저장: zip 파일
의존성은 .yarn/cache/에 zip 파일 로 저장됩니다.
.yarn/
cache/
express-npm-4.21.0-abc123.zip
debug-npm-4.3.4-def456.zip
ms-npm-2.1.3-ghi789.zip
releases/
yarn-4.6.0.cjsZero-Install
.pnp.cjs와 .yarn/cache/를 Git에 커밋하면, yarn install 없이 바로 실행할 수 있습니다. 이것이 Zero-Install입니다.
git clone my-project
node -r ./.pnp.cjs app.js # install 없이 바로 실행CI 환경에서 설치 단계가 사라지므로 파이프라인 시간이 크게 줄어듭니다.
PnP의 트레이드오프
PnP는 강력하지만, 모든 도구가 호환되는 것은 아닙니다.
- 호환성 ,
node_modules를 직접 접근하는 도구는 수정이 필요합니다 (VSCode, Jest 등은 SDK를 제공) - 디버깅 , zip 내부의 소스 코드를 직접 확인하기 어렵습니다
- 학습 비용 , 팀 전체가 PnP 개념을 이해해야 합니다
Corepack, 패키지 매니저의 매니저
Node.js 16.9+에는 Corepack이 내장되어 있습니다 (실험적 상태로 포함). package.json의 packageManager 필드를 읽어서 올바른 버전의 패키지 매니저를 자동으로 사용합니다.
{
"name": "my-app",
"packageManager": "pnpm@9.15.0"
}corepack enable # Corepack 활성화
pnpm install # 자동으로 pnpm@9.15.0 사용팀원 A는 pnpm 8을, 팀원 B는 pnpm 9를 쓰는 문제를 방지합니다. Node.js 25부터는 Corepack이 코어에서 분리되어 별도 설치 (npm install -g corepack)가 필요합니다.
Corepack은 현재 yarn과 pnpm만 지원합니다. npm은 Node.js와 함께 번들되므로 별도 관리가 필요 없습니다.
Lockfile 비교
세 패키지 매니저 모두 lockfile을 생성하여 의존성 트리를 고정합니다.
| 항목 | npm | pnpm | Yarn Berry |
|---|---|---|---|
| Lockfile | package-lock.json | pnpm-lock.yaml | yarn.lock |
| 포맷 | JSON | YAML | YAML (v2+) |
| 크기 | 큼 (모든 메타데이터 포함) | 중간 | 중간 |
| Git 충돌 | JSON이라 충돌 해결 어려움 | YAML이라 상대적으로 쉬움 | 자동 병합 지원 |
Lockfile은 반드시 Git에 커밋 해야 합니다. 이것이 없으면 npm install 결과가 머신마다 달라질 수 있습니다.
# .gitignore에 lockfile을 넣지 마세요!
# ❌ package-lock.json
# ❌ pnpm-lock.yaml
# ❌ yarn.lock시각화
세 가지 패키지 매니저의 node_modules 전략을 단계별로 비교합니다.
npm v3+는 모든 의존성을 node_modules/ 루트로 호이스팅합니다. 하위 의존성도 함께 올라오므로, package.json에 선언하지 않은 패키지를 직접 import할 수 있는 팬텀 의존성 문제가 발생합니다.
어떤 패키지 매니저를 선택할까?
정답은 없지만, 기준은 있습니다.
- npm , 별도 설치 없이 바로 사용 가능. 소규모 프로젝트나 빠른 프로토타이핑에 적합
- pnpm , 디스크 효율과 엄격한 의존성 관리. 모노레포에 특히 강점
- Yarn Berry (PnP) , Zero-Install과 극한의 성능. 팀이 PnP 호환성을 감수할 수 있다면 강력한 선택
체크리스트
-
package.json에 선언하지 않은 패키지를 import하고 있지 않은가? (팬텀 의존성) - lockfile이 Git에 커밋되어 있는가?
-
packageManager필드로 팀 전체의 패키지 매니저 버전을 고정했는가? - 모노레포라면 pnpm workspace나 Yarn workspace를 활용하고 있는가?
- CI에서
npm ci(또는pnpm install --frozen-lockfile)를 사용하는가?