서론
JavaScript로 프로젝트를 관리할 때, 주로 패키지 매니저로 npm을 사용한다. 특히 React와 같은 유명 패키지에서도 설치 방법을 npm을 기반으로 설명하는 경우가 많다.
소규모 프로젝트에서 npm은 적합할 수 있다. 하지만 의존하는 패키지가 많아질수록 물리적인 디스크 사용량이 증가하여 설치 속도가 저하되는 문제를 맞이하게 된다. 이러한 문제를 해결하기 위해 pnpm과 같은 새로운 패키지 매니저들이 등장했다.
이번 글에서는 npm의 단점과 pnpm이 이를 해결하는 방법을 소개하려고 한다.
npm의 단점
유령 의존성(Phantom Dependency) 문제
npm은 디스크 사용량을 줄이기 위해 동일한 버전의 패키지는 중복으로 설치하지 않는다.
# package.json
{
"name": "hello_world",
"dependencies": {
"a": "1.0.0", // C@1.0.0 패키지 참조
"b": "1.0.0". // C@1.0.0 패키지 참조
}
}
프로젝트 dependencies는 A 패키지와 B 패키지를 의존하고 있고, 두 패키지 모두 C 패키지를 의존하는 경우를 가정해 보자. 이 경우 npm은 호이스팅(Hoisting) 기법을 사용하여, C 패키지는 node_modules 하위에 한 번만 설치하여 디스크 용량을 최적화한다.
C 패키지는 호이스팅되어 node_modules 하위에 폴더로 존재한다. 그래서 실제 코드에서는 C 패키지를 참조할 수 있다. 이와 같이 dependencies에 없는 패키지를 참조할 수 있는 현상을 유령 의존성(Phantom Dependency)이라고 한다.
import C from 'c';
console.log(C.greet());
만약 A와 B 패키지를 제거하게 되어 C 패키지가 제거되면, 위 코드 참조할 의존성이 사라져 오류가 발생하는 문제점이 있다.

중복된 패키지 버전을 설치하는 문제
npm의 호이스팅 방식은 동일한 버전의 패키지를 설치하는 경우 디스크 사용량을 최적화한다. 하지만 버전이 다른 패키지를 설치하는 경우에는 중복으로 설치하는 문제가 있다.
# package.json
{
"name": "hello_world",
"dependencies": {
"a": "1.0.0", // C@1.0.0 패키지 참조
"b": "1.0.0", // C@1.0.0 패키지 참조
"d": "1.0.0", // C@1.0.2 패키지 참조
"e": "1.0.0", // C@1.0.2 패키지 참조
}
}
dependencies에 새롭게 D, E 패키지가 추가되었다고 가정해 보자. D, E 패키지는 C@1.0.2 패키지를 의존하고 있지만, C@1.0.0과 버전이 달라 호이스팅 되지 않는다. 이 경우 C@1.0.2 패키지는 디스크에 중복으로 설치되는 문제점이 있다.
pnpm
pnpm은 Performant Node Package Manager”의 줄임말로 성능 개선에 초점을 둔 패키지 매니저이다.
(npm은 “Node Package Manager”의 줄임말은 아니다!, 관련 문서)
앞서 살펴본 npm 프로젝트의 예시를 pnpm으로 패키지를 설치한다고 가정해 보자. pnpm으로 패키지를 설치하면 프로젝트의 전역 저장소와 node_modules 폴더 하위에 크게 2가지 폴더가 생성된다.
- 전역 저장소(Content-addressable store): 패키지의 파일 내용 기반으로 생성한 해싱값을 파일명으로 저장한다. pnpm store path 명령어를 통해 전역 저장소 경로를 확인할 수 있다.
- node_modules/.pnpm: 프로젝트가 의존하고 있는 모든 패키지의 버전이 설치된다. 패키지 파일들은 전역 저장소와 하드 링크 방식으로 연결된다.
- node_modules/{package_name}: dependencies에 존재하는 패키지 목록이다. 내부적으로 node_modules/.pnpm 경로에 소프트 링크 방식으로 연결된다.
# 전역 저장소
# - 전역 저장소 경로는 pnpm store path 명령어를 통해 확인할 수 있다.
# - 실제로 패키지가 설치되는 경로는 store 하위에서 다를 수 있다.
.
└── /Users/USER/Library/pnpm/store/v10/files/a1/
├── 3f4d2e913... # a@1.0.0
├── af89b77f6... # b@1.0.0
├── 1a109ec8c... # c@1.0.0
├── 5ce2ab4dd... # c@1.0.2
├── bd13e9c5f... # d@1.0.0
└── d1ceaf132... # e@1.0.0
# 프로젝트 내부
.
├── node_modules/
│ ├── .pnpm/
│ │ ├── a@1.0.0
│ │ ├── b@1.0.0
│ │ ├── c@1.0.0
│ │ ├── c@1.0.2
│ │ ├── d@1.0.0
│ │ └── e@1.0.0
│ ├── a
│ ├── b
│ ├── d
│ └── e
├── package.json
└── pnpm-lock.json
프로젝트가 의존하고 있는 패키지는 모두 전역 스토어에 저장된다. dependencies에 있던 A, B, D, E 패키지는 node_modules, .pnpm 하위에 폴더가 생성되었다. 반면 dependencies에 없는 C 패키지는 .pnpm 하위에만 폴더가 생성되었다.
이제 pnpm이 npm의 유령 의존성, 디스크 용량 문제를 해결하는 방법을 알아보자.
유령 의존성 문제 해결하기
Node.js는 내부적으로 외부 패키지의 경로를 찾을 때 node_modules 폴더를 참조한다. npm에서 유령 의존성이 발생하는 이유는 dependencies에 존재하지 않는 패키지가 node_modules에 하위에 생성되기 때문이다.
pnpm은 이를 해결하기 위해 패키지를 node_modules에 직접 설치하지 않고, node_modules 하위에 가상 스토어(virtual store) 역할을 수행하는 node_modules/.pnpm 폴더 하위에 설치한다. 물론 dependencies에 있는 패키지는 node_modules 하위에 폴더를 생성한다.
이러한 폴더 구조를 기반으로 dependencies에 설치되어 있지 않은 패키지 참조를 제한해 유령 의존성 문제를 해결한다.
단일 프로젝트의 중복된 패키지 버전을 설치하는 문제 해결하기
pnpm은 패키지 중복 설치를 방지하기 위해 소프트 링크(Soft link) 방식과 하드 링크(Hard Link) 방식을 활용한다.
소프트 링크와 하드 링크 모두 파일을 복제하는 것이 아니라 참조를 통해 파일을 접근하는 방식이다. 참조를 통해 파일을 접근하므로 단순히 파일을 복사하는 것과 다르게 디스크 용량을 최적화할 수 있다.
dependencies에 있는 패키지는 node_modules 하위에 폴더가 생성된 후 .pnpm 폴더 하위에 소프트 링크로 연결하여 의존성을 관리한다. dependencies가 의존하는 패키지에서 동일한 버전의 패키지의 중복된 의존성이 있더라도 .pnpm 폴더에는 버전별로 단 하나의 폴더만 생성된다. (예시) c@1.0.0, c@1.0.2 폴더)
여러 프로젝트의 중복된 패키지 버전을 설치하는 문제 해결하기
소프트 링크를 통해 패키지의 버전별로 단 하나의 폴더만을 관리하더라도, 디스크 용량을 최적화하기에는 제한된다. 예를 들어 로컬 환경의 여러 프로젝트에서 c@1.0.0, c@1.0.2를 설치하는 경우를 가정해 보자. 프로젝트의 의존성을 설치할 때마다 매번 c@1.0.0, c@1.0.2 패키지를 설치하면 디스크 용량이 증가한다.
pnpm은 전역 저장소(Content-addressable store)를 사용하여 이를 해결한다. pnpm은 의존성을 설치할 때 전역 저장소에 파일 내용 기반으로 생성된 해시값을 이름으로 파일을 저장한다. 이후 프로젝트의 .pnpm 폴더 내부에 패키지 관련 파일들을 전역 저장소에 하드 링크나 카피 온 라이트 방식으로 연결한다.
이미 전역 저장소에 저장된 패키지의 파일이라면, 동일한 버전의 패키지를 참조하는 프로젝트가 늘어나더라도 물리적인 디스크 용량이 증가하지 않아 디스크 용량을 최적화할 수 있다.
앞선 예시를 소프트 링크, 하드 링크 방식으로 표현해 보면 다음과 같다.
# "-->" 기호는 심볼릭 링크를 의미한다.
# "=>" 기호는 하드 링크를 의미한다.
.
└── node_modules/
├── .pnpm/
│ ├── a@1.0.0/
│ │ └── node_modules/
│ │ └── a => /Users/USER/Library/pnpm/store/v10/files/a1/3f4d2e913...
│ │ └── c --> ../../c@1.0.0/node_modules/c
│ ├── b@1.0.0/
│ │ └── node_modules/
│ │ ├── b => /Users/USER/Library/pnpm/store/v10/files/a1/af89b77f6...
│ │ └── c --> ../../c@1.0.0/node_modules/c
│ ├── c@1.0.0/
│ │ └── node_modules/
│ │ └── c => /Users/USER/Library/pnpm/store/v10/files/a1/1a109ec8c...
│ ├── c@1.0.2/
│ │ └── node_modules/
│ │ └── c => /Users/USER/Library/pnpm/store/v10/files/a1/5ce2ab4dd...
│ ├── d@1.0.0/
│ │ └── node_modules/
│ │ ├── d => /Users/USER/Library/pnpm/store/v10/files/a1/bd13e9c5f...
│ │ └── c --> ../../c@1.0.2/node_modules/c
│ ├── e@1.0.0/
│ │ └── node_modules/
│ │ ├── e => /Users/USER/Library/pnpm/store/v10/files/a1/d1ceaf132...
│ │ └── c --> ../../c@1.0.2/node_modules/c
│ ├── a --> .pnpm/a@1.0.0/node_modules/a
│ ├── b --> .pnpm/b@1.0.0/node_modules/b
│ ├── d --> .pnpm/d@1.0.0/node_modules/d
│ └── e --> .pnpm/e@1.0.0/node_modules/e
├── package.json
└── pnpm-lock.json

결론
pnpm은 npm의 단점을 해소하기 위해 등장한 패키지 매니저다. npm의 유령 의존성, 디스크 용량 문제의 단점을 겪고 있다면 pnpm 사용을 고려해 보자.
참고
- npm Deep Dive 도서
https://product.kyobobook.co.kr/detail/S000216669881 - 성능이 좋은 이유. performant npm
https://youtu.be/YWnH0M-p_H4?si=hm-6KpHhYuBR2RfI - pnpm 공식 문서
https://pnpm.io/ko/
댓글