2025년 11월 1일
패키지 매니저 4종(npm, yarn, yarn berry, pnpm) 제대로 알고 쓰기
- 패키지 매니저
- npm
- yarn
- yarn berry
- pnpm
회사에서는 yarn, 개인 프로젝트는 pnpm을 쓰면서 실수로 패키지 매니저를 크로스 사용해서 오류가 나기도 하고, pnpm의 경우 특정 환경에서 에러가 나기도 하면서 패키지 매니저 간의 패키지 관리 방법에 대해 궁금해져 찾아봤던 내용을 정리해보려고 한다.
1. npm (2010)
npm은 Node.js가 처음 등장하던 시기에 함께 만들어진 기본 패키지 매니저다. (제작자가 Node.js 초기 코어 컨트리뷰터 중 한 명이다)
외부 라이브러리를 설치하고, package.json을 통해 의존성을 관리하는 표준적인 방법을 제공했다는 점에서, npm은 Node 생태계를 가능하게 만든 기반이라고 볼 수 있다.
하지만 npm의 설계는 당시 기준으로는 단순하고 직관적이었지만, 프로젝트 규모가 커질수록 명확한 한계를 드러냈다.
npm은 모든 의존성을 node_modules 디렉토리에 중첩된 트리 구조로 설치했다. 같은 패키지라도 버전이나 의존성 위치가 다르면 여러 번 설치되었고, 이로 인해 디스크 사용량이 급격히 증가했다.
npm의 기본 구조
npm은 의존성을 다음과 같은 중첩 트리 형태로 설치한다.
node_modules/
├─ react/
│ └─ node_modules/
│ └─ object-assign/
├─ redux/
│ └─ node_modules/
│ └─ object-assign/
설치 과정 또한 느릴 수밖에 없었다. 수천 개의 작은 파일을 다운로드하고, 압축을 풀고, 디렉토리를 생성하고, 파일을 쓰는 과정이 반복되면서 디스크 I/O 병목이 발생했다.
더 큰 문제는 재현성이었다. 초창기 npm에는 lock 파일이 없었기 때문에, 같은 package.json을 가지고도 설치 시점이나 환경에 따라 서로 다른 의존성 트리가 만들어지는 유령 의존성 문제가 생기기도 했다.
이로 인해 내 컴퓨터에서는 되는데, 팀원 컴퓨터에서는 안 되는 상황이 빈번하게 발생했고, 이것이 흔히 말하는 Dependency Hell로 이어졌다.
2. Yarn (2016)
Yarn은 2016년, Facebook이 React 프로젝트를 운영하며 느낀 위 npm의 문제점을 개선하기 위해 만든 패키지 매니저다.
Yarn의 목표는 구조를 완전히 바꾸는 것이 아니라, npm의 불안정성과 느린 속도를 현실적으로 개선하는 것이었다.
가장 중요한 변화는 yarn.lock의 도입이었다. Yarn은 설치 결과를 lock 파일로 고정함으로써, 누가 설치하든 동일한 의존성 트리를 재현할 수 있게 만들었다.
이 하나의 변화만으로도 팀 단위 개발에서 발생하던 수많은 문제들이 사라졌다.
또한 Yarn은 패키지를 병렬로 다운로드하고, 오프라인 캐시를 적극적으로 활용하면서 npm보다 훨씬 빠른 설치 속도를 제공했다.
이 시점에서 많은 팀들이 npm 대신 Yarn을 선택하게 된 이유는 충분했다.
다만 Yarn v1은 근본적인 구조는 npm과 동일했다.
node_modules 트리 구조를 그대로 유지했기 때문에, 중복 설치 문제나 디스크 비효율성은 여전히 남아 있었고, 해결보다는 개선에 가까운 구조라고 볼 수 있다.
3. Yarn Berry (2020)
Yarn Berry(Yarn 2+)는 기존 Yarn의 연장이 아닌 재설계의 방향으로 개발됐다.
“node_modules를 어떻게 빠르게 만들까”가 아니라, “왜 node_modules가 필요할까”라는 질문을 던져, Plug’n’Play(PnP) 시스템을 적용시켰다.
Yarn Berry는 node_modules 폴더 자체를 없애고, .pnp.cjs라는 단일 파일에 모든 의존성의 위치 정보를 기록한다. 런타임에서 패키지를 불러올 때는 이 파일을 통해 즉시 정확한 위치를 찾아간다.
이 방식은 디스크 사용량을 획기적으로 줄이고, 의존성 해석 속도를 크게 향상시켰다.
또한 의존성 캐시를 Git에 포함시키는 Zero-Install 전략도 가능해졌다. CI 환경이나 신규 개발자 온보딩에서 특히 강력한 장점을 가진다.
하지만 대가도 컸다.
기존의 많은 툴들은 node_modules가 존재한다는 전제를 가지고 만들어졌기 때문에, PnP 환경에서는 추가 설정이나 패치가 필요하다.
실무 환경에서도 pnp 설정 등에서 기존 틀과 충돌이 많아 사용률이 높지는 않은 것으로 보인다.
4. pnpm (2017)
pnpm은 Yarn Berry와 비슷한 시기에 등장했지만, 다소 급진적인 yarn berry의 방식과 다르게 node_modules를 유지하면서 구조를 개선하는 방식으로 개발됐다.
pnpm의 목표는 node_modules를 버리는 것이 아니라, 중복 설치 문제를 파일 시스템 수준에서 해결하는 것이었다.
pnpm은 모든 패키지를 전역의 content-addressable store에 한 번만 설치하는 방식으로 최적화 했다.
구체적인 단계는 아래와 같다.
- 같은 패키지 파일을 디스크에 여러 번 복사하지 않는 대신 전역 저장소에 한 번만 저장
- 각 프로젝트의 node_modules에 전역 스토어를 가리키는 하드링크 또는 심볼릭 링크 생성
- 각 프로젝트에서는 그 파일을 링크로 참조
이 방식 덕분에 디스크 사용량은 크게 줄고, 설치 속도는 매우 빨라졌으며, 동시에 node_modules 구조 자체는 유지되기 때문에 기존 툴과의 호환성도 거의 문제가 없다.
또 하나의 중요한 특징은 명시적으로 선언한 의존성만 접근 가능하다는 점이다.
의존성 누수를 원천적으로 막아주기 때문에, 더 예측 가능한 dependency graph를 강제한다.
dependency graph
누가, 어떤 패키지에, 어떤 버전으로 의존하는지를 트리(그래프)로 그린 것을 말한다.
우리 프로젝트
├─ react
│ └─ scheduler
└─ axios
└─ follow-redirects
이 부분이 왜 pnpm의 장점으로 꼽히는지 이해하려면 npm, yarn의 호이스팅 문제부터 살펴봐야 한다.
npm / yarn(v1)은 hoisting(끌어올리기)를 한다.
다음과 같은 dependency가 설치되어 있다고 할 때
우리 프로젝트
├─ A (의존성)
│ └─ lodash
└─ B (의존성)
└─ lodash
npm은 이렇게 설치할 수 있다.
node_modules/
├─ lodash ← 최상위로 올라감
├─ A
└─ B
여기서 생기는 문제는, 우리 프로젝트의 package.json에는 다음 의존성만 존재하는데도
{
"dependencies": {
"A": "...",
"B": "..."
}
}
A나 B 덕분에 lodash가 node*modules 최상위에 존재하게 되어, import * from 'lodash’가 동작할 수 있게 된다.
의존성 그래프에 없는 연결이 발생하게 되어, 예측가능성이 떨어지게 되는 것이다.
이런 연결이 발생하게 되면 명시하지 않은 의존성에 기대게 되고, A나 B 라이브러리가 lodash를 제거하거나 버전을 바꾸게 되면 우리 프로젝트의 코드가 아무런 예고 없이 깨질 위험이 생긴다.
그리고 앞서 언급했던 유령 의존성 문제 때문에 내 로컬 환경에서는 동작했는데 배포 환경이나 팀원 로컬에서는 다르게 동작하는 일이 생길 수도 있다.
pnpm은 이걸 구조적으로 막아서 해결했다.
pnpm의 node_modules는 다음과 같은 구조를 가지고 있어, lodash는 A나 B 내부에서만 접근 가능하기 때문에 npm이나 Yarn과 같은 문제가 발생하지 않는다.
node_modules/
├─ .pnpm/
│ ├─ A@1.0.0/
│ │ └─ node_modules/
│ │ └─ lodash
│ ├─ B@1.0.0/
│ │ └─ node_modules/
│ │ └─ lodash
└─ A
└─ B
요약 및 정리
표로 한눈에 비교하기
| 항목 | npm | Yarn (v1) | Yarn Berry (v2+) | pnpm |
|---|---|---|---|---|
| 등장 시기 | ~2010 | 2016 | 2020 | 2017 |
| 주요 목적 | 기본 패키지 매니저 | npm 속도/안정성 개선 | 구조적 혁신 (PnP) | 빠르고 효율적인 설치 |
| 속도 | 느림 | 빠름 | 매우 빠름 | 매우 빠름 |
| node_modules | 있음 (트리형 중복) | 있음 | ❌ (PnP) | 있음 (하드링크 구조) |
| 디스크 효율 | 낮음 | 중간 | 매우 높음 | 매우 높음 |
| 호환성 | 높음 | 높음 | 낮음 | 높음 |
| 대표 특징 | 기본/보편적 | 안정적/일관성 | 미래지향적/PnP | 효율적/실용적 |
➡️ 정리
- npm의 문제를 실무적으로 보완한 것이 Yarn v1
- node_modules 패러다임 자체를 부정한 것이 Yarn Berry
- node_modules를 유지한 채 구조적으로 최적화한 것이 pnpm
Yarn과 pnpm이 호환되지 않는 이유
구조적으로 Yarn berry는 node modules가 존재하지 않아 npm, pnpm과 호환되지 않는 점이 납득이 되는데, Yarn과 pnpm이 호환되지 않는 이유는 잘 이해되지 않았다.
결론부터 말하면 차이는 node_modules가 있느냐가 아니라, node_modules를 어떤 규칙으로 구성하느냐에 있다.
Yarn과 pnpm을 섞어 쓰면 Yarn으로 설치된 node_modules를 pnpm이 정상 상태로 인식하지 못한다. (반대도 마찬가지)
원인 1) 의존성 관리 방식의 차이
- Yarn / npm
- 의존성을 최대한 위로 끌어올림(hoisting)
- 느슨한 구조, 암묵적 접근 허용
- pnpm
- hoisting을 최소화
- 의존성은 .pnpm 내부에 격리
- 명시된 의존성만 접근 가능
⇒ 그래서 Yarn 기준으로는 정상인 구조가 pnpm에서는 에러가 된다.
원인 2) lock 파일이 호환되지 않음
Yarn과 pnpm은 lock 파일이 서로 호환되지 않는다.
- Yarn → yarn.lock
- pnpm → pnpm-lock.yaml
lock 파일은 단순히 의존성 목록을 관리하는 것 뿐만 아니라, 전체적인 dependency graph, 설치 방식, 의존성 배치 전략을 포함하기 때문에 두 패키지 매니저는 각각 npm과는 호환되지만 서로는 호환되지 않는 것이다.
지금까지 총 4개의 패키지 매니저에 대해서 알아보고, 패키지 매니저를 섞어 쓰면 안되는 이유에 대해서도 알아봤다.
실제로 프로젝트를 진행할 때는 다른 패키지 매니저를 사용하는 순간 에러가 터져서 배포 환경에까지 실수할 일은 거의 없었지만, 한 패키지 매니저를 강제하기 위해서 packageManager 필드, Corepack, preinstall 스크립트 등을 쓴다고 한다.