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)은 두 가지 메커니즘을 결합합니다.

  1. 글로벌 content-addressable store: 패키지 파일을 해시 기반으로 저장합니다. 동일한 파일은 시스템 전체에서 단 한 카피만 존재합니다.
  2. 심링크 + 하드링크: 프로젝트의 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은 디스크 효율과 의존성 안전성을 동시에 달성합니다.

설치 전략 비교

npmyarn classicpnpm
저장 방식프로젝트별 복사프로젝트별 복사글로벌 store + 하드링크
중복 제거hoistinghoistingcontent-addressable
엄격한 의존성❌ phantom 가능❌ phantom 가능✅ 엄격
모노레포workspaces (v7+)workspacesworkspaces (가장 성숙)
디스크 사용높음높음낮음

lockfile 비교

npmyarnpnpm
파일package-lock.jsonyarn.lockpnpm-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
의존성 안전성 중시pnpmphantom dependency 원천 차단

패키지 매니저의 본질은 node_modules를 어떻게 구성하느냐입니다. nested에서 flat으로, 다시 symlink로 — 각 단계는 이전 구조의 문제를 해결하기 위해 등장했습니다.

npm은 별도 설치 없이 시작할 수 있는 표준이고, yarn classic은 속도와 결정적 설치를 개선했고, pnpm은 디스크 효율과 의존성 안전성을 구조적으로 해결했습니다. 어떤 것이 "최고"인지보다, 프로젝트의 규모와 요구사항에 맞는 것을 고르는 것이 중요합니다.

다만 한 가지 확실한 것은 있습니다. package.json에 선언하지 않은 패키지를 사용하고 있다면, 그것은 언제든 깨질 수 있는 시한폭탄입니다. 이 글이 그 구조를 이해하는 데 도움이 되었기를 바랍니다.

npmyarnpnpmNode.js패키지 매니저모노레포