Ray Book
프론트엔드 인프라

패키지 매니저

npm, yarn, pnpm은 node_modules를 어떻게 다루는가, 호이스팅, 팬텀 의존성, symlink 전략을 시각화합니다

infranpmyarnpnpmnode-modulespackage-manager

node_modules의 문제

JavaScript 생태계에서 의존성 관리는 오랜 골칫거리였습니다. npm이 처음 등장했을 때 의존성은 중첩 구조 로 설치되었습니다.

node_modules/
  express/
      node_modules/
          debug/
              node_modules/
                  ms/
          accepts/
          cookie/
  koa/
      node_modules/
          debug/          ← express와 동일한 패키지가 중복!
              node_modules/
                  ms/    ← 또 중복!
          accepts/

이 중첩 구조는 세 가지 심각한 문제를 일으켰습니다.

  1. 경로 길이 초과 , Windows에서 260자 경로 제한에 걸림
  2. 디스크 낭비 , 같은 패키지가 여러 곳에 중복 설치
  3. 설치 속도 , 중복 다운로드와 디스크 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.jsondebug를 선언하지 않았는데도 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

모든 패키지 파일은 전역 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는 루트에 없으니까요.

장점 정리

특성npmpnpm
디스크 사용량프로젝트마다 중복 설치전역 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.cjs

Zero-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.jsonpackageManager 필드를 읽어서 올바른 버전의 패키지 매니저를 자동으로 사용합니다.

{
  "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을 생성하여 의존성 트리를 고정합니다.

항목npmpnpmYarn Berry
Lockfilepackage-lock.jsonpnpm-lock.yamlyarn.lock
포맷JSONYAMLYAML (v2+)
크기큼 (모든 메타데이터 포함)중간중간
Git 충돌JSON이라 충돌 해결 어려움YAML이라 상대적으로 쉬움자동 병합 지원

Lockfile은 반드시 Git에 커밋 해야 합니다. 이것이 없으면 npm install 결과가 머신마다 달라질 수 있습니다.

# .gitignore에 lockfile을 넣지 마세요!
# ❌ package-lock.json
# ❌ pnpm-lock.yaml
# ❌ yarn.lock

시각화

세 가지 패키지 매니저의 node_modules 전략을 단계별로 비교합니다.

Step 1/3npm — Flat 호이스팅

npm v3+는 모든 의존성을 node_modules/ 루트로 호이스팅합니다. 하위 의존성도 함께 올라오므로, package.json에 선언하지 않은 패키지를 직접 import할 수 있는 팬텀 의존성 문제가 발생합니다.

npm — Flat Hoisting
node_modules/
├── express/
├── debug/
├── ms/
├── cookie/
├── accepts/
⚠ express의 하위 의존성이 루트에 호이스팅됨
→ import debug from 'debug' 가 package.json 없이도 동작 (팬텀 의존성)
호이스팅팬텀 의존성 가능중복 설치 가능

어떤 패키지 매니저를 선택할까?

정답은 없지만, 기준은 있습니다.

  • 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)를 사용하는가?