본문으로 바로가기

pnpm은 어떻게 유령의존성을 제거했을까?

·
---
·
개발

pnpm은 어떻게 유령의존성을 제거했는지 deep dive 해보자

유령 의존성이란?

유령 의존성(phantom dependency)은 프로젝트의 package.json에 직접 선언하지 않은 패키지를 코드에서 import할 수 있는 상태를 말한다.

예를 들어 프로젝트의 package.json이 아래와 같다고 하자.

{
  "dependencies": {
    "A": "1.0.0"
  }
}

프로젝트는 A만 직접 의존한다. C는 선언하지 않았다.

그런데 코드에서 다음 import가 동작할 수 있다.

import something from "C";

이때 CA의 하위 의존성이고, 패키지 매니저가 C를 프로젝트 루트의 node_modules에 올려두었다면 Node.js의 모듈 해석 알고리즘은 C를 찾는다.

문제는 프로젝트가 C를 직접 소유하지 않는다는 점이다. C의 설치 여부와 버전은 A의 의존성 선언에 의해 우연히 결정된다.

따라서 다음 상황에서 깨질 수 있다.

  • A가 더 이상 C에 의존하지 않는다.
  • A가 의존하는 C의 메이저 버전이 바뀐다.
  • lockfile이 바뀌면서 hoisting 결과가 달라진다.
  • 로컬 node_modules에는 남아 있지만 CI의 clean install에는 없다.

대표적인 에러는 다음 형태다.

Module not found: Can't resolve 'C'

쉽게 말해서 설치하지 않은 패키지가 딸려들어와 package.json 는 모르는 상태로 사용할 수 있는 상태이다.


node_modules의 구조

Node.js는 bare specifier를 import할 때 node_modules를 탐색한다.

import x from "lodash";

이런 import는 상대 경로가 아니다. Node.js는 현재 파일 위치를 기준으로 가까운 node_modules부터 찾고, 없으면 상위 디렉토리로 올라가며 반복한다.

예를 들어 /project/src/app.ts에서 lodash를 찾는다면 대략 다음 순서로 탐색한다.

/project/src/node_modules/lodash
/project/node_modules/lodash
/node_modules/lodash

즉 루트 node_modules에 어떤 패키지가 존재하면 프로젝트 코드 대부분에서 접근 가능하다.

이 특성이 npm의 hoisting 구조와 만나면 유령 의존성이 생긴다.

Node.js 모듈 탐색 알고리즘

CommonJS 기준으로 require("lodash") 같은 bare specifier는 대략 다음 흐름으로 처리된다.

require(X) from module at path Y

1. X가 core module이면 core module을 반환한다.
2. X가 "/" 또는 "./" 또는 "../"로 시작하면 파일/디렉토리 경로로 해석한다.
3. 그 외에는 LOAD_NODE_MODULES(X, dirname(Y))를 실행한다.

여기서 핵심은 3번이다. bare specifier는 패키지 이름으로 취급되고, Node.js는 현재 파일이 있는 디렉토리부터 상위 디렉토리로 올라가며 node_modules를 찾는다.

의사코드로 쓰면 다음과 같다.

LOAD_NODE_MODULES(packageName, startDirectory):
  dirs = NODE_MODULES_PATHS(startDirectory)

  for dir in dirs:
    candidate = dir + "/" + packageName

    if LOAD_AS_FILE(candidate) succeeds:
      return resolved file

    if LOAD_AS_DIRECTORY(candidate) succeeds:
      return resolved package entry

  throw MODULE_NOT_FOUND

NODE_MODULES_PATHS는 현재 경로에서 루트까지 올라가며 node_modules 후보를 만든다.

NODE_MODULES_PATHS("/project/src/features"):
  /project/src/features/node_modules
  /project/src/node_modules
  /project/node_modules
  /node_modules

LOAD_AS_DIRECTORY는 패키지 디렉토리를 찾았을 때 다시 package.json의 entry를 읽는다.

LOAD_AS_DIRECTORY("/project/node_modules/lodash"):
  1. package.json이 있으면 "exports" 또는 "main"을 확인한다.
  2. entry가 있으면 그 파일을 LOAD_AS_FILE 한다.
  3. 없으면 index.js, index.json, index.node 등을 시도한다.

정리하면 Node.js는 "이 패키지가 내 package.json에 선언되어 있는가?"를 검사하지 않는다. 파일 시스템에서 해석 가능한 위치에 패키지가 있으면 로드한다.

package.json의 dependency 검증은 Node.js 런타임의 역할이 아니다. 패키지 매니저와 lockfile, 그리고 설치된 node_modules 구조가 이 접근 가능성을 결정한다.


npm v2: 중첩 node_modules

npm v2 이하에서는 의존성을 중첩 구조로 설치했다.

node_modules/
├── A/
│   ├── package.json
│   └── node_modules/
│       └── C/
└── B/
    ├── package.json
    └── node_modules/
        └── C/

AC에 의존하고, BC에 의존하면 각각 자기 하위에 C를 가진다.

장점은 명확하다.

  • AA/node_modules/C를 사용한다.
  • BB/node_modules/C를 사용한다.
  • 프로젝트 루트에는 직접 의존성만 놓인다.

하지만 단점이 컸다.

같은 패키지가 여러 번 중복 설치된다. 의존성 트리가 깊어질수록 node_modules도 깊어진다. 과거 Windows에서는 경로 길이 제한에 걸리는 문제도 흔했다.


npm v3 이후: hoisting과 flat node_modules

npm v3부터는 의존성을 가능한 한 루트 node_modules로 끌어올린다. 이를 hoisting이라고 한다.

node_modules/
├── A/
├── B/
└── C/

CAB의 공통 하위 의존성이라면, 중복 설치하지 않고 루트에 하나만 배치할 수 있다.

이 방식의 장점은 분명하다.

  • 중복 패키지가 줄어든다.
  • 디렉토리 깊이가 얕아진다.
  • 설치 결과가 사람이 보기에는 단순해진다.

하지만 루트 node_modules에 올라온 패키지는 프로젝트 코드에서도 접근 가능하다.

node_modules/
├── A/     # package.json에 직접 선언됨
└── C/     # A의 하위 의존성이지만 루트로 hoist됨

이 상태에서 프로젝트 코드가 C를 import하면 Node.js는 /project/node_modules/C를 찾고 성공한다.

import c from "C";

package.json에는 C가 없지만 실행은 된다. 이것이 npm에서 유령 의존성이 생기는 가장 흔한 경로다.

npm hoisting은 어떤 식으로 동작할까?

실제 npm의 installer는 lockfile, peer dependency, package tree, dedupe 조건을 함께 다루기 때문에 단순한 알고리즘 하나로 설명할 수는 없다. 그래도 hoisting의 기본 아이디어는 다음과 같이 볼 수 있다.

입력:
  root dependencies:
    A@1.0.0
    B@1.0.0

  A dependencies:
    C@1.0.0

  B dependencies:
    C@1.0.0

목표:
  가능한 한 중복을 줄이고, 가능한 한 상위 node_modules에 배치한다.

단순화한 알고리즘은 이렇다.

PLACE(package, requestedBy):
  target = highest node_modules folder visible from requestedBy

  if target/packageName is empty:
    install package at target/packageName
    return

  if target/packageName has same version:
    reuse existing package
    return

  if target/packageName has conflicting version:
    install package deeper under requestedBy/node_modules
    return

예를 들어 AB가 같은 C@1.0.0을 요구하면 C는 루트로 올라갈 수 있다.

node_modules/
├── A/
├── B/
└── C@1.0.0/

반대로 AC@1.0.0, BC@2.0.0을 요구하면 둘 다 루트에 같은 이름으로 놓을 수 없다. 이 경우 하나는 루트에 놓이고, 다른 하나는 더 깊은 위치에 남는다.

node_modules/
├── A/
├── B/
│   └── node_modules/
│       └── C@2.0.0/
└── C@1.0.0/

또는 lockfile과 설치 순서에 따라 반대 배치가 될 수 있다.

중요한 점은 이것이다. hoisting의 목적은 "프로젝트 코드가 사용할 수 있는 패키지 목록을 엄격하게 제한하는 것"이 아니다. 목적은 dedupe와 호환성이다.

그래서 루트로 올라간 transitive dependency는 Node.js 탐색 알고리즘상 프로젝트 코드에서도 접근 가능해진다.

dependency graph 관점:
  project -> A -> C

filesystem 관점:
  project/node_modules/C

Node.js 관점:
  project에서 C를 찾을 수 있음

이 세 관점이 어긋나는 지점이 유령 의존성이다.


npm에서 유령 의존성이 위험한 이유

유령 의존성은 당장 동작하기 때문에 더 위험하다. 실패하지 않으니 의존성 선언이 누락된 사실을 알아차리기 어렵다.

예를 들어 현재 설치 결과가 이렇다고 하자.

node_modules/
├── A/
└── C/

현재는 A 때문에 C가 설치된다. 하지만 A@2.0.0에서 C 의존성이 제거되면 다음 설치 결과가 된다.

node_modules/
└── A/

프로젝트 코드는 그대로 import "C"를 하고 있으므로 빌드는 실패한다.

또 다른 문제는 버전 통제다.

Cpackage.json에 없으면 프로젝트는 C의 버전을 직접 고정하지 않는다. C의 버전은 A의 의존성 범위와 lockfile에 의해 간접적으로 결정된다. 프로젝트 코드가 실제로는 C의 API에 의존하고 있는데, 그 API 버전을 프로젝트가 소유하지 않는 상태가 된다.

즉 유령 의존성은 다음 계약 위반이다.

코드가 사용하는 패키지 ⊄ package.json에 선언된 패키지

stale node_modules도 같은 종류의 문제

유령 의존성과 구분해야 할 문제가 하나 더 있다. stale node_modules다.

패키지가 과거에는 직접 의존성이었다가 나중에 package.json에서 제거됐다고 하자. 그런데 로컬 node_modules를 지우지 않았다면 해당 패키지가 디스크에 그대로 남을 수 있다.

# package.json에는 없다
rg '"@mui/material"' package.json package-lock.json

# 하지만 node_modules에는 남아 있다
ls node_modules/@mui/material

이 경우도 로컬에서는 import가 성공할 수 있다.

node -e "console.log(require.resolve('@mui/material/package.json'))"

이건 엄밀히 말하면 transitive dependency를 잘못 import한 phantom dependency가 아니다. 제거된 직접 의존성이 로컬 설치물에 남은 상태다.

하지만 실무에서 만드는 문제는 비슷하다.

  • 로컬에서는 된다.
  • clean install을 하는 CI에서는 안 된다.
  • package.json과 실제 실행 환경이 다르다.

따라서 dependency 문제를 볼 때는 둘을 구분하는 편이 좋다.

  • phantom dependency: 직접 선언하지 않은 하위 의존성을 우연히 import한다.
  • stale dependency: 제거된 패키지가 기존 node_modules에 남아 import된다.

둘 다 해결 방향은 같다. node_modules에 무엇이 있는지가 아니라 package.json과 lockfile이 무엇을 선언하는지를 기준으로 봐야 한다.


심볼릭 링크 vs 하드 링크

pnpm의 구조를 이해하려면 심볼릭 링크와 하드 링크를 구분해야 한다.

심볼릭 링크

심볼릭 링크(symbolic link, symlink)는 다른 경로를 가리키는 파일 시스템 엔트리다.

ln -s /real/path link

구조는 다음과 같다.

link -> /real/path

심볼릭 링크는 원본 파일의 내용 자체를 공유하는 것이 아니라 경로를 가리킨다.

특징은 다음과 같다.

  • 원본과 다른 inode를 가진다.
  • 파일뿐 아니라 디렉토리에도 만들 수 있다.
  • 다른 파일 시스템의 경로도 가리킬 수 있다.
  • 원본이 삭제되면 dangling symlink가 된다.

예를 들어 pnpm의 루트 node_modules/react는 보통 실제 패키지 디렉토리가 아니라 .pnpm 내부를 가리키는 symlink다.

readlink node_modules/react

예상 결과는 이런 형태다.

.pnpm/react@17.0.2/node_modules/react

하드 링크

하드 링크(hard link)는 같은 inode를 가리키는 또 다른 이름이다.

ln original hardlink

구조는 다음과 같다.

original ──┐
           ├── inode 12345 ── file data
hardlink ──┘

하드 링크는 원본과 링크가 같은 파일 데이터를 공유한다. 어느 쪽이 원본이라는 개념도 약하다. 같은 inode를 가리키는 이름이 여러 개 있을 뿐이다.

특징은 다음과 같다.

  • 원본과 같은 inode를 공유한다.
  • 파일 데이터가 중복 저장되지 않는다.
  • 원본 경로를 삭제해도 다른 하드 링크가 있으면 데이터는 유지된다.
  • 일반적으로 디렉토리에는 만들 수 없다.
  • 같은 파일 시스템 안에서만 만들 수 있다.

pnpm은 패키지 파일을 글로벌 스토어에 저장하고, 프로젝트의 .pnpm 내부 파일을 이 스토어 파일에 대한 hard link 또는 reflink로 만든다.

비교

항목심볼릭 링크하드 링크
가리키는 대상경로inode
inode원본과 다름원본과 같음
원본 삭제링크가 깨짐계속 접근 가능
디렉토리 링크가능일반적으로 불가
파일 시스템 경계넘을 수 있음보통 불가
pnpm에서의 역할의존성 그래프 구성파일 중복 제거

pnpm은 node_modules를 어떻게 구성할까?

pnpm은 npm처럼 모든 패키지를 루트 node_modules에 펼치지 않는다.

예를 들어 프로젝트가 foo@1.0.0을 직접 의존하고, foobar@1.0.0에 의존한다고 하자.

pnpm의 구조는 단순화하면 다음과 같다.

node_modules/
├── foo -> .pnpm/foo@1.0.0/node_modules/foo
└── .pnpm/
    ├── foo@1.0.0/
    │   └── node_modules/
    │       ├── foo/
    │       └── bar -> ../../bar@1.0.0/node_modules/bar
    └── bar@1.0.0/
        └── node_modules/
            └── bar/

여기서 루트 node_modules/foo는 symlink다. 프로젝트가 직접 의존하는 패키지만 루트에 노출된다.

bar는 루트에 없다. foo의 의존성이므로 foo가 접근 가능한 위치에 연결된다.

따라서 프로젝트 코드에서 다음 import를 하면 실패한다.

import bar from "bar";

프로젝트는 bar를 직접 선언하지 않았기 때문이다.

반면 foo 내부 코드에서 bar를 import하는 것은 가능하다. foo의 dependency graph 안에는 bar가 연결되어 있기 때문이다.

이 구조의 핵심은 루트 node_modules를 프로젝트의 public dependency surface로 취급한다는 점이다.

루트 node_modules에 노출됨 = 프로젝트가 직접 선언한 의존성

물론 pnpm도 생태계 호환성을 위해 일부 hoisting을 지원한다. 기본 설정은 완전한 strict라기보다는 semi-strict에 가깝다. 특히 hoist, public-hoist-pattern, shamefully-hoist, node-linker=hoisted 같은 설정에 따라 노출 범위가 달라진다.

그래도 npm의 flat node_modules와 비교하면 기본 구조가 훨씬 제한적이다. 프로젝트 코드가 우연히 하위 의존성을 import하는 가능성을 줄인다.

pnpm의 설치 결과를 그래프로 보면

pnpm의 node_modules는 "패키지 그래프"를 파일 시스템에 그대로 표현하려는 구조에 가깝다.

예를 들어 의존성 그래프가 다음과 같다고 하자.

project
└── foo@1.0.0
    └── bar@1.0.0
        └── qar@2.0.0

npm은 가능한 한 이것을 평탄화한다.

node_modules/
├── foo/
├── bar/
└── qar/

pnpm은 각 패키지가 자기 dependency set을 볼 수 있게 symlink를 구성한다.

node_modules/
├── foo -> .pnpm/foo@1.0.0/node_modules/foo
└── .pnpm/
    ├── foo@1.0.0/
    │   └── node_modules/
    │       ├── foo/
    │       └── bar -> ../../bar@1.0.0/node_modules/bar
    ├── bar@1.0.0/
    │   └── node_modules/
    │       ├── bar/
    │       └── qar -> ../../qar@2.0.0/node_modules/qar
    └── qar@2.0.0/
        └── node_modules/
            └── qar/

프로젝트 루트에는 foo만 있다. barqar는 각각 자신을 필요로 하는 패키지의 dependency context 안에서만 보인다.

이 구조에서 foo 내부 코드가 bar를 import하면 Node.js는 symlink의 realpath를 기준으로 다음 위치에서 bar를 찾는다.

node_modules/.pnpm/foo@1.0.0/node_modules/bar

이 위치에는 bar로 가는 symlink가 있다.

반면 프로젝트 코드가 bar를 import하면 Node.js는 루트 쪽을 본다.

node_modules/bar

여기에는 symlink가 없다. 그래서 실패한다.

.pnpm/<pkg>/node_modules/<pkg>처럼 한 번 더 감쌀까?

pnpm 구조를 처음 보면 이상해 보이는 부분이 있다.

node_modules/.pnpm/foo@1.0.0/node_modules/foo

그냥 아래처럼 두면 안 될까?

node_modules/.pnpm/foo@1.0.0

pnpm이 패키지를 node_modules/<name> 아래에 두는 이유는 Node.js 패키지 해석 규칙과 자기 참조(self import) 때문이다.

패키지 내부에서 자기 자신을 import하는 경우가 있다.

import pkg from "foo";

또는 자기 package.json을 읽을 수 있다.

import pkg from "foo/package.json";

패키지가 실제로 .../node_modules/foo 위치에 있으면 Node.js의 기존 해석 규칙과 잘 맞는다. 또한 dependency symlink도 같은 node_modules 폴더 안에 배치할 수 있어 순환 symlink 없이 그래프를 만들기 쉽다.


pnpm install 내부 흐름

pnpm install은 크게 네 단계로 볼 수 있다.

1. resolve
2. fetch
3. import
4. link

1. resolve

package.jsonpnpm-lock.yaml을 기준으로 필요한 패키지와 버전을 결정한다.

project package.json
  -> direct dependencies
  -> dependency ranges
  -> peer dependency constraints
  -> lockfile snapshots
  -> exact package versions
  • -frozen-lockfile이면 lockfile을 수정하지 않는다. package.json과 lockfile이 맞지 않으면 설치를 실패시킨다.

2. fetch

필요한 package tarball을 registry에서 가져오거나, 이미 store에 있으면 재사용한다.

package tarball
  -> integrity 검증
  -> unpack
  -> content-addressable store 저장

여기서 store는 패키지 단위 캐시라기보다 파일 내용 기반 저장소다. 같은 내용의 파일은 같은 해시를 가지므로 중복 저장할 필요가 없다.

3. import

store에 있는 파일을 프로젝트의 virtual store로 가져온다.

virtual store는 기본적으로 다음 위치다.

node_modules/.pnpm

import 방식은 환경과 설정에 따라 달라진다.

clone/reflink 가능 -> reflink
hard link 가능    -> hard link
둘 다 어려움      -> copy

즉 프로젝트 안의 .pnpm에 보이는 파일들이 항상 독립 복사본인 것은 아니다. 대부분은 글로벌 store의 파일을 참조한다.

4. link

마지막으로 dependency graph에 맞게 symlink를 만든다.

단순화한 의사코드는 다음과 같다.

for each package snapshot in lockfile:
  packageLocation = node_modules/.pnpm/<packageId>/node_modules/<name>

  for each dependency of package:
    dependencyLocation = node_modules/.pnpm/<dependencyId>/node_modules/<depName>
    create symlink:
      packageLocation/../<depName> -> dependencyLocation

for each direct dependency of project:
  create symlink:
    node_modules/<name> -> node_modules/.pnpm/<packageId>/node_modules/<name>

이 마지막 줄이 중요하다.

루트 node_modules에 symlink되는 것은 프로젝트의 direct dependency다. direct dependency가 아닌 패키지는 .pnpm 안에 존재하더라도 루트 public surface에는 올라오지 않는다.


pnpm은 유령 의존성을 어떻게 줄일까?

npm의 flat 구조에서는 하위 의존성이 루트로 올라올 수 있다.

node_modules/
├── A/
└── C/  # A의 하위 의존성이지만 프로젝트에서도 보임

pnpm의 isolated 구조에서는 직접 의존성만 루트에 symlink된다.

node_modules/
├── A -> .pnpm/A@1.0.0/node_modules/A
└── .pnpm/
    └── A@1.0.0/
        └── node_modules/
            ├── A/
            └── C/

프로젝트 코드의 모듈 탐색은 루트 node_modules를 본다. 여기에 C가 없으므로 import "C"는 실패한다.

즉 pnpm은 유령 의존성을 런타임에 숨겨주는 것이 아니라, 잘못된 import를 설치 구조 단계에서 드러낸다.

해결 방법은 명확하다.

정말 프로젝트 코드가 C를 사용한다면 직접 의존성으로 선언해야 한다.

pnpm add C

사용하지 않는다면 import를 제거해야 한다.

이 점이 중요하다. pnpm 전환 후 빌드가 깨지는 것은 pnpm이 문제를 만든 것이 아니라, 이미 있던 암묵적 의존성을 드러낸 것이다.


pnpm 글로벌 스토어는 어디에 있을까?

pnpm은 패키지 파일을 프로젝트마다 복사하지 않는다. content-addressable store에 저장한다.

스토어 경로는 다음 명령으로 확인한다.

pnpm store path

macOS에서는 보통 다음 경로를 사용한다.

/Users/<user>/Library/pnpm/store/v10

Linux에서는 보통 다음 경로를 사용한다.

~/.local/share/pnpm/store/v10

Windows에서는 보통 다음 경로를 사용한다.

%LOCALAPPDATA%/pnpm/store/v10

스토어 내부는 대략 다음 성격을 가진다.

store/v10/
├── files/     # 파일 내용 기반 저장소
├── index/     # 패키지 메타데이터
├── projects/  # 프로젝트 참조 정보
└── tmp/

content-addressable이라는 말은 파일의 위치가 파일 내용의 해시에 의해 결정된다는 뜻이다.

같은 내용의 파일은 같은 해시를 가진다. 따라서 같은 파일을 여러 패키지나 여러 프로젝트가 사용해도 스토어에는 한 번만 저장할 수 있다.

프로젝트의 node_modules/.pnpm 내부 파일은 이 글로벌 스토어의 파일을 hard link 또는 reflink로 참조한다.

확인하려면 inode를 비교할 수 있다.

ls -li node_modules/.pnpm/<package>/node_modules/<package>/package.json

스토어 쪽 파일과 inode가 같으면 hard link다. APFS 같은 CoW 파일 시스템에서는 reflink가 사용될 수 있고, 환경에 따라 copy로 fallback할 수도 있다.


pnpm install은 왜 빠를까?

pnpm이 빠른 이유는 단순히 구현이 빠르기 때문이 아니다. 설치 중 수행하는 디스크 작업의 성격이 다르다.

1. 이미 받은 패키지를 다시 복사하지 않는다

npm은 프로젝트별 node_modules에 패키지 파일을 쓴다.

registry -> tarball download -> extract -> project/node_modules

프로젝트가 여러 개면 같은 패키지를 여러 프로젝트의 node_modules에 반복해서 쓴다.

pnpm은 한 번 받은 패키지를 글로벌 스토어에 저장한다.

registry -> store
store -> project/node_modules via hard link/reflink

이미 store에 있는 패키지는 다시 다운로드하거나 압축 해제할 필요가 적다. 프로젝트에는 링크를 만든다.

파일 복사보다 링크 생성이 훨씬 싸다. 특히 파일 수가 많은 JavaScript 패키지 생태계에서는 차이가 크다.

2. content-addressable store가 중복 파일을 제거한다

같은 패키지 버전을 여러 프로젝트에서 사용하면 npm은 프로젝트마다 파일을 가진다.

pnpm은 store에 한 벌만 저장하고 각 프로젝트에서 링크한다.

또 같은 패키지의 다른 버전이라도 파일 내용이 같으면 같은 store entry를 재사용할 수 있다. content hash가 같기 때문이다.

3. 설치 단계가 분리되어 병렬화하기 좋다

pnpm install은 크게 다음 흐름을 가진다.

resolve -> fetch to store -> link node_modules

lockfile이 있으면 버전 결정 비용이 줄어든다. --frozen-lockfile을 사용하면 lockfile을 변경하지 않고, package.json과 lockfile이 맞지 않으면 실패한다.

즉 CI에서는 다음 성질을 얻는다.

  • 의존성 버전이 결정론적이다.
  • lockfile 갱신이 일어나지 않는다.
  • 이미 store/cache에 있는 패키지는 빠르게 재사용된다.
  • 프로젝트 node_modules 구성은 link 작업 중심이 된다.

4. node_modules를 평탄하게 대량 생성하지 않는다

npm의 flat layout은 루트 node_modules에 많은 패키지를 직접 배치한다. 반면 pnpm은 루트에는 직접 의존성 symlink만 두고, 실제 패키지 그래프는 .pnpm 안에 구성한다.

루트에 노출되는 엔트리 수가 줄어든다.

예를 들어 한 프로젝트에서 측정한 결과는 다음과 같았다.

항목npmpnpm
설치 시간66.3초11.9초
node_modules 크기765MB723MB
루트 node_modules 엔트리 수733개69개
pnpm .pnpm 내부 패키지 수-1,069개

이 숫자는 모든 프로젝트에 일반화할 수 없다. 캐시 상태, 디스크, 네트워크, postinstall script, lockfile 상태에 따라 달라진다.

다만 차이의 방향은 설명 가능하다. pnpm은 이미 있는 파일을 재사용하고, 프로젝트에는 링크 중심으로 node_modules를 구성한다.

설치 비용을 단계별로 나누면

패키지 설치 비용은 대략 다음 항목으로 나눌 수 있다.

total install time
  = dependency resolution
  + registry metadata request
  + tarball download
  + integrity check
  + archive extraction
  + file write/copy
  + node_modules layout creation
  + lifecycle scripts

npm과 pnpm은 특히 file write/copynode_modules layout creation에서 차이가 난다.

npm의 clean install은 프로젝트 node_modules를 결과물의 중심으로 둔다.

tarball -> extract -> write many files into project/node_modules

pnpm은 store를 중심으로 둔다.

tarball -> extract into store -> link/import into project virtual store

store hit가 난 경우에는 더 짧아진다.

store hit -> import/link only

즉 두 번째 설치부터는 네트워크와 압축 해제 비용이 크게 줄고, 프로젝트에는 링크를 구성하는 작업이 주가 된다.

store hit와 store miss

pnpm install에서 패키지별 상태는 크게 둘 중 하나다.

store miss:
  registry에서 tarball을 받는다.
  integrity를 검증한다.
  store에 파일을 저장한다.
  프로젝트 virtual store로 import한다.

store hit:
  registry 다운로드를 생략한다.
  store의 파일을 프로젝트 virtual store로 import한다.

의사코드로 쓰면 다음과 같다.

for each package in lockfile:
  if package files exist in content-addressable store:
    importPackageFromStore(package)
  else:
    tarball = download(package.resolution.tarball)
    verifyIntegrity(tarball, package.integrity)
    files = unpack(tarball)
    saveFilesToStore(files)
    importPackageFromStore(package)

linkDependencyGraph()
runLifecycleScripts()

여기서 importPackageFromStore가 항상 copy를 의미하지 않는다. 가능하면 clone/reflink 또는 hard link를 사용한다.

hard link가 빠른 이유

일반적인 파일 복사는 파일 내용 전체를 새 위치에 쓴다.

read source bytes -> write destination bytes

파일이 10,000개라면 파일 내용 쓰기도 10,000번 발생한다. JavaScript 패키지는 작은 파일이 많기 때문에 메타데이터 작업과 파일 쓰기 비용이 모두 커진다.

hard link는 파일 데이터를 다시 쓰지 않는다. 같은 inode를 가리키는 directory entry를 추가한다.

create directory entry -> point to existing inode

reflink도 비슷하게 초기 복사 비용을 줄인다. copy-on-write 방식이므로 처음에는 데이터 블록을 공유하고, 수정이 발생할 때만 블록을 복사한다.

패키지 파일은 일반적으로 설치 후 수정하지 않는다. 따라서 hard link/reflink와 패키지 매니저의 궁합이 좋다.

lockfile이 있을 때 빠른 이유

lockfile이 없으면 패키지 매니저는 dependency range를 실제 버전으로 해석해야 한다.

"react": "^17.0.0"
  -> registry metadata 조회
  -> 조건을 만족하는 최신 버전 선택
  -> peer dependency 조건 계산
  -> 전체 dependency graph 확정

lockfile이 있으면 이미 정확한 버전과 resolution이 기록되어 있다.

react@17.0.2:
  resolution:
    integrity: sha512-...
  dependencies:
    loose-envify: 1.4.0
  • -frozen-lockfile은 이 정보를 기준으로 설치하고 lockfile을 수정하지 않는다. 그래서 CI에서 "설치할 때마다 의존성 그래프가 다시 바뀌는" 일을 막는다.

속도 측면에서는 version solving과 lockfile write 비용이 줄어든다. 정확성 측면에서는 package.json과 lockfile이 어긋났을 때 실패하므로 재현 가능한 설치가 된다.


실제 전환에서 확인한 것

전환 커밋에서는 packageManager를 명시했다.

{
  "packageManager": "pnpm@10.31.0"
}

CI는 npm 기준에서 pnpm 기준으로 변경했다.

# before
- run: npm ci
- run: npm run build

# after
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm run build

Dockerfile도 같은 방향으로 바꿨다.

RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile
RUN pnpm run build
CMD ["pnpm", "start"]

전환 중에 확인한 문제는 두 종류였다. 먼저 프로젝트가 직접 선언하지 않은 패키지에 기대는 import가 있었다.

이 경우는 실제로 사용하는 패키지를 직접 dependency로 추가하거나 import를 제거해야 한다.

pnpm add <package>

두번째는 과거에 제거된 패키지가 로컬 node_modules에 남아 import가 되는 경우가 있었다.

예를 들어 package.json과 lockfile에는 없는데 node_modules/@mui/material에는 남아 있는 상태를 만들 수 있었다.

rg '"@mui/material"' package.json package-lock.json
ls node_modules/@mui/material
node -e "console.log(require.resolve('@mui/material/package.json'))"

이 상태는 clean install에서는 재현되지 않는다. 따라서 로컬에서만 성공하는 import를 만들 수 있다.

이런 문제를 줄이려면 의존성 변경 후에는 clean install 기준으로 검증해야 한다.

rm -rf node_modules
pnpm install --frozen-lockfile
pnpm run build

정리

npm의 flat node_modules는 중복과 깊은 디렉토리 문제를 해결했지만, 루트에 hoist된 하위 의존성이 프로젝트 코드에 노출되는 부작용을 만들었다.

pnpm은 루트 node_modules에는 직접 의존성만 symlink하고, 실제 의존성 그래프는 .pnpm 아래에 구성한다. 패키지 파일은 content-addressable store에 저장하고, 프로젝트에서는 hard link 또는 reflink로 참조한다.

이 구조 때문에 다음 효과가 생긴다.

  • 직접 선언하지 않은 패키지를 프로젝트 코드에서 import하기 어렵다.
  • 잘못된 의존성 선언이 install/build 단계에서 빨리 드러난다.
  • 여러 프로젝트가 같은 패키지 파일을 공유할 수 있다.
  • install은 다운로드와 파일 복사보다 store 재사용과 link 생성 중심으로 동작한다.

pnpm이 유령 의존성을 마법처럼 고치는 것은 아니다. 잘못된 import는 여전히 고쳐야 한다. 다만 npm에서는 우연히 동작하던 문제가 pnpm에서는 더 빨리 실패한다.

패키지 매니저 전환에서 중요한 부분은 명령어 교체가 아니라 이 실패를 받아들이는 것이다.

코드가 사용하는 패키지는 package.json에 있어야 한다. node_modules에 있다는 이유만으로 의존성이 존재한다고 보면 안 된다.


참고 자료

목차