JavaScript 패키지 매니저 비교

JavaScript 패키지 매니저 비교

Nov 12, 2024 ·
10 분 읽음

들어가며

퇴사 후 개인적으로 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

이 구조는 의존성 간 격리를 보장할 수 있었지만, 다음과 같은 문제들이 존재했습니다.

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은 하드 링크와 심볼릭 링크를 조합하여, 중복 없이 패키지를 설치하는 구조를 채택했습니다.

설치 구조는 다음과 같았습니다:

  1. 모든 패키지를 전역 저장소에 내용 기반으로 저장했습니다.
  2. 각 프로젝트의 .pnpm 디렉터리로 하드 링크를 생성했습니다.
  3. .pnpm에서 루트 node_modules로 심볼릭 링크를 연결했습니다.

이 방식은 다음과 같은 장점을 제공했습니다:

Yarn Berry – Plug’n’Play(PnP) 구조

Yarn v2부터는 Plug’n’Play(PnP) 구조를 채택하여, node_modules 디렉터리를 제거했습니다.
모든 패키지는 .yarn/cache.zip 형태로 저장되었고, .pnp.cjs 파일을 통해 require() 요청이 매핑되었습니다.

이 방식은 다음과 같은 특징을 가지고 있었습니다:

하지만 실무 환경에서는 다음과 같은 문제가 발생했습니다:

비교 요약

항목npm / Yarn ClassicpnpmYarn Berry (PnP)
설치 구조호이스팅링크 기반 저장소zip 기반 PnP
팬텀 의존성발생차단차단
디스크 효율낮음높음보통
설치 속도보통빠름매우 빠름
결정론 수준버전 고정구조 고정완전 고정
툴 호환성매우 높음높음낮음
Git 저장소 부담낮음낮음높음
초기 러닝커브낮음낮음높음

결론 – 왜 pnpm으로 전환하게 되었는가

Yarn Berry의 PnP 구조는 기술적으로 완성도 높은 아키텍처였고, 이상적인 결정론적 환경을 제공했습니다.
하지만 실제 프로젝트를 운영하면서 다음과 같은 부담이 누적되었습니다.

pnpm은 이러한 문제를 구조적으로 해결할 수 있는 대안이었습니다.
링크 기반 아키텍처를 통해 디스크 사용량을 줄였고, 설치 속도도 빨라졌습니다.
팬텀 의존성을 차단하면서도 Git 저장소에는 설치 파일이 포함되지 않아 관리 부담도 줄어들었습니다.

결론적으로 Yarn Berry에서 pnpm으로 전환하게 된 이유는 기능 확장을 위한 목적이 아니었습니다.
GitHub 저장소 용량 문제와 초기 진입 장벽을 해결하기 위해, 보다 단순하고 실용적인 구조로 이동한 선택이었습니다.
그 결과 유지보수 효율이 높아졌고, 팀 전체의 개발 흐름도 안정적으로 정리되었습니다.

모든 프로젝트에서 pnpm이 정답이라고 말할 수는 없습니다.
하지만 당시의 프로젝트 환경과 팀의 필요에 따라, 가장 합리적인 선택이었다고 생각이 듭니다.

참고