npm, yarn, pnpm — 패키지 매니저가 node_modules를 만드는 세 가지 방식
들어가며
npm install을 실행하면 node_modules 폴더가 생깁니다. yarn이나 pnpm으로 바꿔도 결과는 비슷해 보입니다. 하지만 그 안의 구조는 전혀 다릅니다.
세 패키지 매니저는 같은 package.json을 읽고도 다른 방식으로 node_modules를 구성합니다. 이 차이가 디스크 사용량, 설치 속도, 의존성 안전성에 직접적인 영향을 줍니다. 이 글에서는 벤치마크 숫자보다 왜 다르게 만드는지에 집중합니다.
node_modules의 역사 — 구조가 바뀌어 온 이유
1단계: nested (npm v1~v2)
초기 npm(v1~v2)은 가장 직관적인 방식을 택했습니다. 패키지 A가 B@1.0에 의존하면, node_modules/A/node_modules/B에 B를 설치합니다. C도 B@1.0에 의존하면 node_modules/C/node_modules/B에 같은 패키지를 한 번 더 설치합니다.
Node.js의 모듈 해석 알고리즘(require()가 부모 디렉토리를 따라 올라가며 node_modules를 탐색)과 완벽하게 일치하는 구조였지만, 문제가 두 가지 있었습니다.
- 디스크 낭비: 동일한 버전의 패키지가 의존하는 곳마다 복사됩니다. 실제 프로젝트에서 수십~수백 개의 중복이 발생했습니다.
- Windows MAX_PATH: 중첩이 깊어지면 경로가 260자를 넘습니다.
node_modules/A/node_modules/B/node_modules/C/...이 실제로 파일시스템 한계에 부딪혔습니다.
2단계: flat/hoisted (npm v3+ / yarn classic)
npm v3(2015)은 hoisting 전략을 도입합니다. 의존성을 최대한 프로젝트 루트의 node_modules로 끌어올리는 방식입니다. A와 C가 모두 B@1.0에 의존하면, B@1.0을 루트에 한 번만 설치합니다.
디스크 중복과 경로 깊이 문제를 동시에 해결했습니다. yarn classic(2016)도 같은 전략을 사용합니다. 하지만 이 구조에는 의도하지 않은 부작용이 있었습니다. 루트로 끌어올려진 패키지는 누구나 접근 가능해집니다. package.json에 선언하지 않은 패키지도 import가 성공하는 것입니다. 이것이 바로 phantom dependency 문제입니다.
3단계: content-addressable store + symlink (pnpm)
pnpm(2017)은 두 가지 메커니즘을 결합합니다.
- 글로벌 content-addressable store: 패키지 파일을 해시 기반으로 저장합니다. 동일한 파일은 시스템 전체에서 단 한 카피만 존재합니다.
- 심링크 + 하드링크: 프로젝트의
node_modules에는 실제 파일 대신 링크를 생성합니다.node_modules/.pnpm디렉토리에 모든 패키지의 flat 구조를 만들고, 루트node_modules에는package.json에 선언한 직접 의존성만 심링크합니다.
결과적으로 hoisting의 디스크 절약 효과는 유지하면서, package.json에 선언하지 않은 패키지에는 접근할 수 없는 엄격한 격리를 달성합니다.
phantom dependency — hoisting이 만든 함정
package.json에 선언하지 않은 패키지를 코드에서 사용할 수 있는 현상입니다. npm/yarn의 hoisting이 만든 구조적 부작용입니다.
예를 들어, express는 내부적으로 debug 패키지에 의존합니다. hoisting이 debug를 루트 node_modules로 끌어올리면, 프로젝트 코드에서 import debug from 'debug'가 성공합니다. package.json에 debug를 선언하지 않았는데도요.
문제는 express가 버전을 올리면서 debug 의존을 제거하거나 메이저 버전을 바꿀 때 발생합니다. 어제까지 동작하던 코드가 npm install 한 번으로 깨집니다. 선언하지 않은 의존성이기 때문에 어디서 문제가 생겼는지 추적하기도 어렵습니다.
// package.json에 선언: express
// 실제로 사용: debug (express가 내부적으로 의존)
import debug from 'debug'; // npm/yarn에서는 동작, pnpm에서는 에러
pnpm은 이를 구조적으로 차단합니다. 루트 node_modules에는 package.json에 선언한 패키지의 심링크만 생성합니다. 실제 파일은 node_modules/.pnpm 하위에 flat하게 존재하지만, Node.js의 모듈 해석 경로에서 직접 접근할 수 없습니다. debug를 선언하지 않으면 import debug from 'debug'는 MODULE_NOT_FOUND 에러를 발생시킵니다.
세 패키지 매니저의 내부 동작
npm — 가장 오래된 표준
Node.js를 설치하면 함께 들어오는 기본 패키지 매니저입니다. 별도 설치가 필요 없다는 것이 가장 큰 장점입니다.
- 저장: 프로젝트별로 패키지 파일을 복사합니다. 10개 프로젝트가 같은 패키지를 쓰면 10카피가 존재합니다.
- 구조: hoisted flat 방식. 가능한 한 루트에 올리고, 버전 충돌이 있는 것만 하위
node_modules에 중첩합니다. - lockfile:
package-lock.json(JSON). 정확한 버전과 전체 의존성 트리를 기록합니다. - 모노레포: v7부터
workspaces필드를 지원합니다. 기본적인 패키지 간 참조는 가능하지만, 선택적 설치나 필터링 기능은 제한적입니다.
yarn classic — npm의 개선
2016년, Facebook이 npm의 느린 설치 속도와 비결정적 의존성 해석을 해결하기 위해 만들었습니다.
- yarn.lock: 모든 의존성의 정확한 버전을 잠그는 lockfile을 도입했습니다. 어느 환경에서든 동일한 트리를 보장합니다. (npm도 이후
package-lock.json으로 따라갔습니다.) - 병렬 다운로드: npm이 순차적으로 패키지를 받던 시절, yarn은 병렬 다운로드로 설치 속도를 크게 개선했습니다.
- 오프라인 캐시: 한 번 받은 패키지를 로컬 캐시에 저장해 네트워크 없이도 설치 가능합니다.
속도와 안정성은 개선했지만, node_modules 구조는 npm과 동일한 hoisted flat 방식입니다. phantom dependency 문제도 그대로 가지고 있습니다.
yarn berry (PnP) — 급진적 실험
yarn v2(2020)부터 도입된 Plug'n'Play(PnP) 모드는 가장 급진적인 접근입니다. node_modules 디렉토리 자체를 없앱니다.
- 의존성을 zip 아카이브로 저장하고,
.pnp.cjs파일이 Node.js의 모듈 해석을 가로채서 zip에서 직접 로드합니다. - 선언하지 않은 패키지 접근을 차단하므로 phantom dependency를 완전히 방지합니다.
- zip 파일을 git에 커밋하면 CI에서
install없이 바로 실행하는 zero-install도 가능합니다.
다만 node_modules의 존재를 전제로 하는 도구들(TypeScript, ESLint, Jest 등)과의 호환성 문제가 있어 채택률이 높지 않습니다. 이 글에서는 깊이 다루지 않겠습니다.
pnpm — 구조적 해결
pnpm은 "왜 같은 파일을 여러 번 저장하는가?"라는 질문에서 출발합니다.
- content-addressable store:
~/.local/share/pnpm/store에 패키지 파일을 해시(checksum) 기준으로 저장합니다. 파일 내용이 같으면 같은 위치를 가리킵니다.lodash@4.17.21을 10개 프로젝트에서 쓰더라도 디스크에는 1카피만 존재합니다. - 하드링크: 프로젝트의
node_modules/.pnpm에 있는 파일은 글로벌 store의 하드링크입니다. 파일을 복사하지 않으므로 설치가 빠르고 디스크도 절약됩니다. - 심링크 격리: 루트
node_modules에는 직접 의존성만 심링크로 노출합니다. 간접 의존성은.pnpm내부에만 존재하므로 phantom dependency가 구조적으로 불가능합니다.
이 세 가지 조합으로 pnpm은 디스크 효율과 의존성 안전성을 동시에 달성합니다.
설치 전략 비교
| npm | yarn classic | pnpm | |
|---|---|---|---|
| 저장 방식 | 프로젝트별 복사 | 프로젝트별 복사 | 글로벌 store + 하드링크 |
| 중복 제거 | hoisting | hoisting | content-addressable |
| 엄격한 의존성 | ❌ phantom 가능 | ❌ phantom 가능 | ✅ 엄격 |
| 모노레포 | workspaces (v7+) | workspaces | workspaces (가장 성숙) |
| 디스크 사용 | 높음 | 높음 | 낮음 |
lockfile 비교
| npm | yarn | pnpm | |
|---|---|---|---|
| 파일 | package-lock.json | yarn.lock | pnpm-lock.yaml |
| 형식 | JSON | 커스텀 | YAML |
| git diff | 가능하나 장황 | 가능 | 가능, 가장 읽기 쉬움 |
실전: 1,633개 패키지 모노레포에서의 pnpm
이론만으로는 체감이 어려우니, 실제 운영 중인 모노레포의 수치를 보겠습니다. NestJS 백엔드, Next.js 15 프론트엔드 3개, 공유 패키지 10여 개로 구성된 프로젝트입니다.
- 글로벌 store: 4.5GB (모든 프로젝트가 공유)
- node_modules: 1.6GB (하드링크 기반, 실제 디스크 추가 사용 최소)
pnpm install --frozen-lockfile: 1,603개 resolved, 1,584개 reused, 다운로드 0건, 8.9초- 8.9초 전부 링킹(하드링크 생성 + 디렉토리 구조)에 소비
--filter로 필요한 패키지만 설치하면 2~3초로 단축
주목할 점은 1,603개 중 1,584개가 reused라는 것입니다. 글로벌 store에 이미 있는 패키지는 다운로드 없이 하드링크만 생성합니다. 8.9초의 대부분은 네트워크가 아니라 파일시스템 I/O(하드링크 생성, 디렉토리 구조 구성)에 소비됩니다.
이는 중요한 시사점을 줍니다. CI에서 캐시가 잘 작동하는 환경이라면, 패키지 매니저 간 설치 속도 차이는 벤치마크만큼 크지 않습니다. 실제 병목은 네트워크가 아니라 I/O이고, 이 부분은 패키지 매니저보다 디스크 성능에 더 의존합니다.
모노레포에서 pnpm이 강한 이유
모노레포에서 pnpm이 특히 강한 이유는 세 가지입니다.
workspace:*프로토콜: 패키지 간 의존성을 명시적으로 선언합니다."@my-blog/shared-types": "workspace:*"로 로컬 패키지를 참조하면, publish 시 자동으로 실제 버전으로 치환됩니다.--filter:pnpm --filter backend build처럼 특정 패키지만 선택해서 명령을 실행할 수 있습니다. 의존하는 패키지만 빌드하므로 CI 시간을 크게 줄입니다.- 엄격한 격리: 패키지 A가 선언하지 않은 패키지 B를 사용하면 즉시 에러가 납니다. 모노레포에서 패키지가 수십 개로 늘어나면, 이 엄격함이 의도치 않은 의존성 순환을 방지하는 안전장치가 됩니다.
npm과 yarn도 workspaces를 지원하지만, hoisting 기반이라 패키지 간 경계가 느슨합니다. 패키지 수가 늘어날수록 이 차이는 커집니다.
정리
| 상황 | 추천 | 이유 |
|---|---|---|
| 소규모 프로젝트, 빠른 시작 | npm | 별도 설치 불필요, Node.js에 내장 |
| 기존 yarn 프로젝트 | yarn classic 유지 | 마이그레이션 비용 대비 이점 평가 |
| 모노레포, 디스크 절약 | pnpm | 엄격한 의존성 + 글로벌 store |
| 의존성 안전성 중시 | pnpm | phantom dependency 원천 차단 |
패키지 매니저의 본질은 node_modules를 어떻게 구성하느냐입니다. nested에서 flat으로, 다시 symlink로 — 각 단계는 이전 구조의 문제를 해결하기 위해 등장했습니다.
npm은 별도 설치 없이 시작할 수 있는 표준이고, yarn classic은 속도와 결정적 설치를 개선했고, pnpm은 디스크 효율과 의존성 안전성을 구조적으로 해결했습니다. 어떤 것이 "최고"인지보다, 프로젝트의 규모와 요구사항에 맞는 것을 고르는 것이 중요합니다.
다만 한 가지 확실한 것은 있습니다. package.json에 선언하지 않은 패키지를 사용하고 있다면, 그것은 언제든 깨질 수 있는 시한폭탄입니다. 이 글이 그 구조를 이해하는 데 도움이 되었기를 바랍니다.
관련 글
Node.js만 있는 게 아니다 — Bun과 Deno, 같은 언어 다른 런타임
JavaScript를 실행하는 런타임은 Node.js만이 아닙니다. Bun과 Deno는 같은 언어를 다른 방식으로 실행합니다. 세 런타임의 설계 철학, 내부 구조, 실용적 차이를 정리합니다.
Bun으로 갈아탈까? 실제 모노레포로 검증해본 결과
1633개 패키지를 가진 실제 프로덕션 모노레포에서 Bun 마이그레이션을 검토했습니다. 네이티브 모듈 호환성부터 체감 속도 예측, 워크트리 설치 시간 실측까지, 벤치마크 숫자가 아닌 현실적인 분석을 정리했습니다.
CommonJS와 ESM — require와 import는 어떻게 다르게 동작하는가 (8편)
Node.js의 두 가지 모듈 시스템 — CommonJS의 require와 ESM의 import가 내부에서 어떻게 다르게 동작하는지 추적합니다. 로딩 시점, 캐싱, 순환 참조 처리의 차이를 살펴봅니다.