सदस्यता लें

프엔 패키지매니저 비교하기 (npm에서 pnpm? yarn berry?)

작성자
현우
작성시각
수정시각
레퍼런스
खाली
npm install을 치고 멍하니 기다린 적이 있을 것이다. node_modules가 1GB를 넘어서 당황했던 경험도. 팀원이 yarn을 쓰는데 나는 npm을 써서 package-lock.jsonyarn.lock이 동시에 생겨버린 아찔한 기억도. 패키지 매니저는 워낙 당연한 도구라 깊이 생각하지 않고 그냥 쓰게 되는데, 사실 선택에 따라 설치 속도, 디스크 사용량, 팀 협업 경험이 꽤 크게 달라진다.
요즘 npm에서 pnpm이나 Yarn Berry로 갈아타는 팀이 늘고 있다. 어떤 패키지 매니저가 무엇이 다른지, 내부적으로 어떻게 동작하는지 제대로 정리해보려 한다.

패키지 매니저의 역사를 짧게 짚고 넘어가면

2010년 — npm이 Node.js와 함께 등장. 사실상 표준이 됨
2016년 — Facebook이 Yarn(v1) 출시. npm의 느린 속도와 비결정적 설치 문제를 해결하기 위해
2017년 — npm v5에서 package-lock.json 도입. Yarn의 장점을 상당 부분 흡수
2018년 — pnpm이 주목받기 시작. 디스크 효율성과 속도에서 차별화
2020년 — Yarn Berry(v2) 출시. node_modules를 아예 없애는 PnP 방식 도입
2022년 — Bun 등장. JS 런타임과 패키지 매니저를 통합한 새로운 접근
2023년 — Deno 2.0에서 npm 호환성 강화

패키지를 어떻게 설치하는가? — 핵심은 node_modules 구조

패키지 매니저들의 가장 근본적인 차이는 패키지를 디스크에 어떻게 배치하는가에 있다.

npm — 플랫 호이스팅(Flat Hoisting)

초창기 npm(v1~v2)은 의존성을 중첩(nested) 구조로 설치했다.
node_modules/
  package-a/
    node_modules/
      package-b/
        node_modules/
          package-c/
package-apackage-b를 필요로 하고, package-bpackage-c를 필요로 하면 이렇게 깊이 중첩됐다. Windows에서는 경로 길이 제한(260자)에 걸려 설치가 실패하는 일도 있었고, 같은 패키지가 여러 곳에 중복 설치되어 디스크를 낭비했다.
npm v3부터는 플랫 호이스팅을 도입했다.
node_modules/
  package-a/
  package-b/     ← 최상위로 끌어올림 (hoisting)
  package-c/
가능한 한 최상위 node_modules로 끌어올려서 중복을 줄이는 방식이다. 덕분에 경로 문제는 해결됐지만, 새로운 문제가 생겼다.
팬텀 디펜던시(Phantom Dependency) 문제다. package.json에 명시하지 않았지만 호이스팅된 덕분에 require('package-b')가 동작해버리는 것이다. 어느 날 package-a가 내부 의존성을 바꾸면 갑자기 내 코드가 깨지는 버그의 원인이 된다.
// package.json에는 package-a만 있음
{
  "dependencies": {
    "package-a": "^1.0.0"
  }
}
// 근데 이게 동작해버림 (팬텀 디펜던시)
const b = require('package-b')

Yarn Classic(v1) — 같은 방식, 더 빠르게

Yarn v1은 npm과 동일한 플랫 호이스팅 구조를 사용한다. 구조 자체는 같지만, 병렬 다운로드와 로컬 캐시 활용으로 속도를 끌어올린 것이 핵심이었다. yarn.lock을 통해 결정적 설치(deterministic install)를 보장했고, 이 개념을 npm이 나중에 package-lock.json으로 따라했다.
현재 Yarn v1은 사실상 유지보수 모드다. 신규 프로젝트에서 굳이 선택할 이유는 없다.

pnpm — 콘텐츠 주소 기반 저장소 + 심볼릭 링크

pnpm의 접근은 근본적으로 다르다. 글로벌 저장소(content-addressable store) 에 패키지를 한 번만 저장하고, 프로젝트의 node_modules에는 심볼릭 링크(symlink) 만 만든다.
~/.pnpm-store/             ← 글로벌 저장소 (한 번만 저장)
  v3/
    files/
      00/abc123...         ← 파일 해시 기반으로 저장
      01/def456...

my-project/
  node_modules/
    .pnpm/                 ← 실제 패키지들 (글로벌 저장소에서 하드링크)
      package-a@1.0.0/
        node_modules/
          package-b → .pnpm/package-b@2.0.0/  ← 심볼릭 링크
    package-a → .pnpm/package-a@1.0.0/node_modules/package-a
이 구조가 주는 이점이 상당하다.
디스크 절약: react@18.2.0을 10개 프로젝트에서 써도 파일은 글로벌 저장소에 하나만 존재한다. 프로젝트마다 하드링크만 생긴다.
팬텀 디펜던시 차단: node_modules 최상위에는 package.json에 명시한 패키지만 노출된다. 내부 의존성은 .pnpm/ 안에 격리된다.
빠른 설치: 이미 글로벌 저장소에 있는 파일은 다운로드하지 않고 링크만 생성한다.
# 같은 패키지를 100개 프로젝트에서 설치해도
# 실제 파일은 글로벌 저장소에 딱 하나

~/.pnpm-store/v3/files/  ← react@18.2.0 파일들이 여기 한 번만
project-1/node_modules/.pnpm/react@18.2.0/  ← 하드링크
project-2/node_modules/.pnpm/react@18.2.0/  ← 하드링크
project-3/node_modules/.pnpm/react@18.2.0/  ← 하드링크
💬
하드링크와 심볼릭링크 차이
하드링크는 원본 파일과 동일한 실제 데이터를 공유하여 원본 삭제 후에도 데이터가 유지 되나,
심볼릭 링크는 원본의 경로만 기록한 바로가기 파일로 원본 삭제 시 링크가 깨진다.
심볼릭 링크는 디렉토리 링크와 파티션 간 이동이 가능하지만, 하드링크는 동일 파일 시스템 내 파일만 가능하다.

Yarn Berry(v2+) — PnP로 node_modules를 없애다

Yarn Berry는 가장 급진적인 접근을 택했다. Plug'n'Play(PnP) 라는 방식으로 node_modules 디렉토리 자체를 없애버린다.
대신 .yarn/cache/ 에 압축된 zip 파일로 패키지를 저장하고, .pnp.cjs 라는 파일 하나로 "어떤 패키지가 어디에 있는지"를 Node.js에 알려준다.
my-project/
  .yarn/
    cache/
      package-a-npm-1.0.0-abc123.zip   ← zip으로 압축 저장
      package-b-npm-2.0.0-def456.zip
  .pnp.cjs                              ← 패키지 위치 맵
  package.json
  yarn.lock
// .pnp.cjs 내부 (간략화)
const packageLocatorsByLocations = new Map([
  ["package-a/", { name: "package-a", version: "1.0.0" }],
  // ...
])
Node.js가 require('package-a')를 만나면 기존처럼 node_modules를 탐색하는 대신, .pnp.cjs의 맵을 참조해서 zip 안에서 바로 파일을 읽는다.
Zero-Installs라는 개념도 여기서 나온다. .yarn/cache/의 zip 파일들을 Git에 커밋해두면, 저장소를 클론한 직후 yarn install 없이도 바로 실행이 가능하다.
git clone https://github.com/my-project
cd my-project
# yarn install 없이 바로
yarn dev  # 동작함
단, zip 파일을 Git에 올리면 저장소 크기가 상당히 커진다는 트레이드오프가 있다.

Bun — 런타임과 패키지 매니저의 통합

Bun은 패키지 매니저만이 아니라 JS 런타임, 번들러, 테스트 러너까지 통합한 도구다. 패키지 설치 방식은 npm과 유사한 플랫 구조를 따르지만, Zig로 작성된 네이티브 코드 덕분에 속도가 압도적으로 빠르다. 글로벌 캐시도 지원한다.
bun install    # npm install과 동일한 사용법
bun add react  # npm install react
bun remove react
package.json과 호환되고 bun.lockb라는 바이너리 락파일을 생성한다.

성능 비교

벤치마크는 환경과 프로젝트 크기에 따라 다르지만, 일반적으로 알려진 경향성이 있다.

설치 속도 (캐시 없을 때 → 캐시 있을 때)

패키지 매니저
캐시 없음
캐시 있음
lockfile만 있을 때
npm
느림
보통
보통
Yarn v1
보통
빠름
빠름
pnpm
빠름
매우 빠름
매우 빠름
Yarn Berry (PnP)
보통
빠름
매우 빠름
Bun
매우 빠름
압도적으로 빠름
압도적으로 빠름
Bun의 경우 npm 대비 1025배 빠른 벤치마크 결과도 자주 보인다. pnpm은 캐시 활용 시 npm 대비 23배 빠른 경우가 많다.

디스크 사용량

패키지 매니저
특징
npm
프로젝트마다 완전한 복사본
Yarn v1
npm과 비슷, 글로벌 캐시 별도
pnpm
글로벌 저장소 하드링크 → 가장 효율적
Yarn Berry
zip 압축으로 npm 대비 30~50% 절약
Bun
글로벌 캐시 + npm 수준의 node_modules
pnpm이 디스크 사용량에서 가장 효율적이다. 모노레포 환경에서 특히 차이가 두드러진다.

node_modules 크기 직관적 비교

같은 Next.js 프로젝트 기준으로 대략적인 크기 비교:
npm install    → node_modules: ~350MB
yarn install   → node_modules: ~340MB
pnpm install   → node_modules: ~180MB (하드링크 + 글로벌 저장소)
yarn berry     → .yarn/cache: ~70MB (zip 압축)

각 패키지 매니저의 주요 명령어

# 패키지 설치 (전체)
npm install
yarn
pnpm install
bun install

# 패키지 추가
npm install react
yarn add react
pnpm add react
bun add react

# 개발 의존성 추가
npm install -D typescript
yarn add -D typescript
pnpm add -D typescript
bun add -d typescript

# 패키지 제거
npm uninstall react
yarn remove react
pnpm remove react
bun remove react

# 스크립트 실행
npm run dev
yarn dev
pnpm dev
bun run dev

각 패키지 매니저의 장단점

npm

장점
Node.js와 함께 기본 설치됨. 아무것도 안 해도 쓸 수 있다
가장 많은 문서와 레퍼런스
호환성 문제가 거의 없다
단점
속도가 가장 느리다
팬텀 디펜던시 문제가 여전히 존재
모노레포 지원이 약하다 (workspaces는 있지만 기능이 제한적)

Yarn Classic(v1)

장점
npm보다 빠름
Workspaces 지원이 npm보다 성숙했음
단점
사실상 유지보수 모드. 새 프로젝트에서 굳이 선택할 이유가 없다
npm과 구조적 차이가 없어 팬텀 디펜던시 문제 동일하게 존재

pnpm

장점
속도와 디스크 효율 모두 우수
팬텀 디펜던시를 구조적으로 차단
모노레포(workspaces) 지원이 강력
npm, yarn과 명령어 호환성이 높아 마이그레이션이 쉬움
node_modules 구조를 유지해서 대부분의 도구와 호환됨
단점
심볼릭 링크 방식이 일부 도구와 충돌할 수 있음 (특히 오래된 빌드 도구)
팀 전체가 같은 버전을 써야 안정적

Yarn Berry(v2+)

장점
PnP 방식으로 팬텀 디펜던시 완전 차단
Zero-Installs로 CI 캐시 없이도 빠른 설치
zip 압축으로 디스크 효율이 높음
타입스크립트 인텔리전스 지원 (@yarnpkg/sdks)
단점
학습 곡선이 높다. PnP는 기존 개념과 많이 다름
PnP와 호환되지 않는 패키지들이 아직 있다 (node-linker=node-modules로 우회 가능하지만 PnP 이점이 줄어듦)
에디터 설정이 필요함 (.pnp.cjs 인식을 위해)
Zero-Installs 사용 시 Git 저장소 크기 증가

Bun

장점
압도적인 설치 속도
패키지 매니저 외 런타임, 번들러, 테스트 러너 통합
npm과 동일한 사용법으로 학습 부담 없음
단점
아직 성숙하지 않은 생태계. 일부 Node.js API와 호환성 이슈
Linux/macOS만 완전 지원 (Windows는 실험적)
회사 프로덕션 환경에 도입하기엔 아직 리스크가 있음
락파일(bun.lockb)이 바이너리 포맷이라 diff 확인이 어려움

어떤 걸 선택해야 할까?

개인 프로젝트나 소규모 팀

pnpm 을 추천한다. npm에서 마이그레이션이 쉽고, 속도와 디스크 효율이 좋다. pnpm import 명령어 하나로 기존 package-lock.json에서 pnpm-lock.yaml을 생성할 수 있다.
npm install -g pnpm
pnpm import        # package-lock.json → pnpm-lock.yaml 변환
rm package-lock.json
pnpm install

모노레포

pnpm workspaces 또는 Yarn Berry. pnpm은 pnpm-workspace.yaml 하나로 설정이 간단하고, 패키지 간 링크도 자동으로 처리된다.
# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

CI 속도가 최우선

Yarn Berry의 Zero-Installs 또는 Bun. Zero-Installs는 CI에서 yarn install 자체를 건너뛸 수 있어서 극단적으로 빠르다. Bun은 캐시가 있을 때 가장 빠른 설치 속도를 보인다.

안정성과 호환성이 최우선인 팀

npm 또는 pnpm (node-modules linker). 새로운 방식이 주는 이점보다 호환성 트러블슈팅 비용이 더 크다면 검증된 방식을 유지하는 게 맞다.

마이그레이션 체크리스트

npm에서 pnpm으로 전환할 때 확인할 것들:
# 1. pnpm 설치
npm install -g pnpm

# 2. 기존 lock 파일 변환
pnpm import

# 3. node_modules 재설치
rm -rf node_modules
pnpm install

# 4. .npmrc에 hoisting 설정 확인 (필요 시)
# .npmrc
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

# 5. CI 스크립트 수정 (npm → pnpm)
# .github/workflows/ci.yml
- uses: pnpm/action-setup@v2
  with:
    version: 8
팬텀 디펜던시로 인한 오류가 생기면 실제로 의존하는 패키지를 package.json에 명시적으로 추가해주면 된다. 처음에 약간의 트러블슈팅이 있을 수 있지만, 한 번 정리해두면 오히려 의존성이 명확해지는 장점이 있다.
패키지 매니저는 어떤 것을 써도 기본 동작은 한다. 하지만 프로젝트 규모가 커질수록, 팀이 커질수록, CI가 느릴수록 차이가 피부로 느껴지기 시작한다. npm을 오래 써왔다면 pnpm을 한 번 써보는 것을 권한다. 마이그레이션 비용 대비 얻는 것이 꽤 크다.
Yarn Berry의 PnP는 분명 미래지향적인 접근이지만, 생태계 호환성 문제를 먼저 확인하는 것이 좋다. Bun은 지금 당장 프로덕션 도입보다는 개인 프로젝트에서 먼저 경험해보는 것을 추천한다. 속도는 정말 인상적이다.
결국 완벽한 패키지 매니저는 없고, 팀의 상황과 프로젝트 특성에 맞는 선택이 있을 뿐이다. 일단 써봐야 안다.