JavaScript 패키지 매니저 비교
들어가며
퇴사 후 개인적으로 JavaScript 패키지 매니저의 내부 구조를 공부하던 중, 과거 프로젝트에서 Yarn Berry를 사용하다가 pnpm으로 전환하게 되었던 경험이 떠올랐고, 그 과정을 기술적으로 정리하고 싶어졌습니다.
당시 회사에서는 React 기반의 모노레포를 운영하고 있었으며, Yarn v2(PnP) 구조를 사용하고 있었습니다.
초기에는 node_modules 없이 빠르게 설치되었고, 의존성을 명확하게 통제할 수 있다는 점이 인상적이었습니다.
하지만 시간이 지나면서 몇 가지 현실적인 문제가 발생했습니다.
가장 명확했던 이슈는 제로 인스톨(Zero-Install) 정책으로 인해 GitHub 저장소 용량이 빠르게 증가한 점이었습니다.
.yarn/cache 폴더에 누적된 수백 개의 .zip 파일이 저장소에 포함되면서, 프로젝트 초기화 속도는 빨랐지만 clone, push, pull 과정에서 부담이 커졌고, GitHub 저장소 용량 제한에 도달하는 일도 발생했습니다.
또한 Yarn Berry 구조에 익숙하지 않은 개발자들에게는 초기 러닝커브도 분명히 존재했습니다. 기존 node_modules 기반의 설치 구조와 접근 방식이 달라, 진입 장벽으로 작용했습니다.
결과적으로 Yarn을 유지하기 위한 관리 비용이 점점 커진다고 판단되었고, 실제 프로젝트에서는 pnpm으로 마이그레이션하게 되었습니다.
이 글은 그 경험을 기반으로, npm, Yarn, pnpm이라는 세 가지 주요 패키지 매니저의 내부 구조를 비교하고, 어떤 기준으로 선택할 수 있는지를 기술적으로 정리해 보았습니다.
node_modules 구조의 진화
npm v1/v2 – 중첩 구조의 시작
초기 npm은 각 패키지가 자신만의 node_modules 디렉터리를 갖는 중첩 구조를 사용했습니다.
A/node_modules/B/node_modules/C이 구조는 의존성 간 격리를 보장할 수 있었지만, 다음과 같은 문제들이 존재했습니다.
- 동일한 패키지가 중복 설치되어 디스크 공간이 낭비되었습니다.
- Windows 환경에서는 경로 길이가 제한을 초과하여, 파일 삭제나 도구 실행이 실패하는 경우가 자주 발생했습니다.
npm v3 / Yarn Classic – 평탄화와 호이스팅
npm v3와 Yarn Classic은 이러한 문제를 해결하기 위해 호이스팅(Hoisting) 전략을 도입했습니다.
가능한 많은 의존성을 루트 디렉터리의 node_modules로 끌어올리는 방식이었습니다.
이로 인해 다음과 같은 새로운 문제가 발생했습니다.
팬텀 의존성(Phantom Dependency)
package.json에 명시되지 않은 패키지라도, 루트에 설치되어 있다면 require() 호출이 가능했습니다.
이는 의존성 정리가 되지 않거나, CI 환경에서 예상치 못한 모듈 오류로 이어질 수 있었습니다.
비결정적 설치
같은 package.json이라도 설치 순서나 환경에 따라 node_modules 구조가 달라질 수 있었습니다.
이로 인해 “개발자 A의 환경에서는 동작하지만, B의 환경에서는 오류가 발생하는” 상황이 자주 발생했습니다.
Lockfile의 등장 – 결정론적 설치로의 진화
Yarn은 yarn.lock을, npm은 package-lock.json을 도입하면서 결정론적 설치(Deterministic Install)를 지원하기 시작했습니다.
이제 동일한 package.json과 lock 파일이 있다면, 어느 환경에서든 같은 버전의 패키지를 설치할 수 있게 되었습니다.
하지만 이 결정론은 버전 단위에서만 보장되었고, 실제 파일 시스템 상의 구조까지 결정론적으로 고정되지는 않았습니다.
팬텀 의존성 문제 또한 여전히 존재했습니다.
pnpm – 링크 기반 아키텍처
pnpm은 하드 링크와 심볼릭 링크를 조합하여, 중복 없이 패키지를 설치하는 구조를 채택했습니다.
설치 구조는 다음과 같았습니다:
- 모든 패키지를 전역 저장소에 내용 기반으로 저장했습니다.
- 각 프로젝트의
.pnpm디렉터리로 하드 링크를 생성했습니다. .pnpm에서 루트node_modules로 심볼릭 링크를 연결했습니다.
이 방식은 다음과 같은 장점을 제공했습니다:
- 패키지가 한 번만 저장되므로 디스크 공간을 절약할 수 있었습니다.
- 하드 링크 덕분에 설치 속도가 빨랐습니다.
package.json에 명시된 패키지만 노출되어 팬텀 의존성이 원천적으로 차단되었습니다.
Yarn Berry – Plug’n’Play(PnP) 구조
Yarn v2부터는 Plug’n’Play(PnP) 구조를 채택하여, node_modules 디렉터리를 제거했습니다.
모든 패키지는 .yarn/cache에 .zip 형태로 저장되었고, .pnp.cjs 파일을 통해 require() 요청이 매핑되었습니다.
이 방식은 다음과 같은 특징을 가지고 있었습니다:
- 매우 빠른 설치 속도
- 완벽한 결정론 보장
- 팬텀 의존성 차단
하지만 실무 환경에서는 다음과 같은 문제가 발생했습니다:
.yarn/cache가 Git 저장소에 포함되면서 저장소 용량이 급격히 증가했습니다.- PnP 구조를 처음 접하는 개발자에게는 초기 진입 장벽이 존재했습니다.
비교 요약
| 항목 | npm / Yarn Classic | pnpm | Yarn Berry (PnP) |
|---|---|---|---|
| 설치 구조 | 호이스팅 | 링크 기반 저장소 | zip 기반 PnP |
| 팬텀 의존성 | 발생 | 차단 | 차단 |
| 디스크 효율 | 낮음 | 높음 | 보통 |
| 설치 속도 | 보통 | 빠름 | 매우 빠름 |
| 결정론 수준 | 버전 고정 | 구조 고정 | 완전 고정 |
| 툴 호환성 | 매우 높음 | 높음 | 낮음 |
| Git 저장소 부담 | 낮음 | 낮음 | 높음 |
| 초기 러닝커브 | 낮음 | 낮음 | 높음 |
결론 – 왜 pnpm으로 전환하게 되었는가
Yarn Berry의 PnP 구조는 기술적으로 완성도 높은 아키텍처였고, 이상적인 결정론적 환경을 제공했습니다.
하지만 실제 프로젝트를 운영하면서 다음과 같은 부담이 누적되었습니다.
.yarn/cache디렉터리의.zip파일로 인해 GitHub 저장소 용량이 빠르게 증가했습니다.- 저장소 clone 속도가 느려졌고, 커밋 충돌이나 GitHub 용량 경고가 반복적으로 발생했습니다.
- 개발 환경 설정 시 구조 이해에 시간이 더 들어, 신규 팀원에게는 초기 러닝커브가 있었습니다.
pnpm은 이러한 문제를 구조적으로 해결할 수 있는 대안이었습니다.
링크 기반 아키텍처를 통해 디스크 사용량을 줄였고, 설치 속도도 빨라졌습니다.
팬텀 의존성을 차단하면서도 Git 저장소에는 설치 파일이 포함되지 않아 관리 부담도 줄어들었습니다.
결론적으로 Yarn Berry에서 pnpm으로 전환하게 된 이유는 기능 확장을 위한 목적이 아니었습니다.
GitHub 저장소 용량 문제와 초기 진입 장벽을 해결하기 위해, 보다 단순하고 실용적인 구조로 이동한 선택이었습니다.
그 결과 유지보수 효율이 높아졌고, 팀 전체의 개발 흐름도 안정적으로 정리되었습니다.
모든 프로젝트에서 pnpm이 정답이라고 말할 수는 없습니다.
하지만 당시의 프로젝트 환경과 팀의 필요에 따라, 가장 합리적인 선택이었다고 생각이 듭니다.