뒤로가기

PNPM vs Yarn Berry: 의존성 관리 전략과 성능 비교 분석

pnpm

Node.js 생태계에서 패키지 매니저를 선택할 때, Yarn Berry(Yarn 2+)와 PNPM은 차세대 솔루션으로 주목받고 있습니다. 두 도구 모두 npm과 Yarn Classic의 한계를 극복하기 위해 설계되었지만, 근본적으로 다른 철학과 기술적 접근 방식을 취합니다.

이 글에서는 의존성 관리 메커니즘, 디스크 효율성, 성능 벤치마크, 실전 사용 사례를 비교 분석하고, 프로젝트 특성에 맞는 선택 기준을 제시합니다.

전통적인 패키지 매니저의 한계

npm/Yarn Classic의 구조적 문제

1. Phantom Dependency (유령 의존성)

// package.json
{
  "dependencies": {
    "express": "^4.18.0"
  }
}
// 코드에서 직접 선언하지 않은 패키지 사용 가능
import lodash from 'lodash'; // express의 의존성
// npm/Yarn Classic: 동작 [권장] (잘못된 동작)
// PNPM/Yarn Berry PnP: 에러 [주의] (올바른 동작)

npm은 평탄화(hoisting)된 node_modules 구조로 인해 직접 선언하지 않은 패키지에 접근 가능합니다.

2. 디스크 공간 낭비

# 10개 프로젝트에서 react 사용 시
npm:         10 × 3.2MB = 32MB (중복 저장)
PNPM:        1 × 3.2MB + 하드링크 = ~3.2MB
Yarn Berry:  1 × 1.8MB (ZIP 압축) = 1.8MB

3. 느린 설치 속도

npm/Yarn Classic은 패키지를 네트워크에서 다운로드 후 압축 해제하여 node_modules에 복사하는 방식으로, I/O 오버헤드가 큽니다.

의존성 관리 방식 비교

PNPM: Content-Addressable Storage

PNPM은 하드링크 기반의 중앙 집중식 저장소를 사용합니다.

디렉토리 구조:

~/Library/pnpm/store/v3/files/
└── 00/
    └── a1b2c3d4e5f6... (패키지 파일의 해시)

my-project/node_modules/
├── .pnpm/
│   └── react@18.2.0/
│       └── node_modules/
│           └── react/ → 하드링크 to store
└── react → 심볼릭 링크 to .pnpm/react@18.2.0/node_modules/react

핵심 메커니즘:

  1. 전역 저장소: ~/Library/pnpm/store에 패키지를 content-addressable 방식으로 저장
  2. 하드링크: 프로젝트별 .pnpm 디렉토리에서 전역 저장소를 하드링크로 참조
  3. 심볼릭 링크: 루트 node_modules에서 .pnpm의 패키지를 심볼릭 링크로 연결

장점:

  • 디스크 효율성: 동일 패키지는 물리적으로 1번만 저장
  • 빠른 설치: 하드링크 생성은 복사보다 월등히 빠름
  • Phantom Dependency 방지: 선언되지 않은 패키지는 심볼릭 링크가 없어 접근 불가

Yarn Berry: Plug'n'Play (PnP)

Yarn Berry는 node_modules를 완전히 제거하고 .pnp.cjs 파일로 의존성을 관리합니다.

디렉토리 구조 (PnP 모드):

.yarn/cache/
├── react-npm-18.2.0-1eae08fee2-88e38092da.zip
└── lodash-npm-4.17.21-6382451519-eb835a2e51.zip

.pnp.cjs (의존성 맵)
.pnp.loader.mjs
package.json

.pnp.cjs 구조:

// .pnp.cjs (간소화된 구조)
const packageRegistry = new Map([
  ["react", [
    ["npm:18.2.0", {
      packageLocation: "./.yarn/cache/react-npm-18.2.0-1eae08fee2-88e38092da.zip/node_modules/react/",
      packageDependencies: new Map([
        ["loose-envify", "npm:1.4.0"],
      ]),
    }],
  ]],
]);
 
// Node.js의 require/import를 가로채기
const originalResolveFilename = Module._resolveFilename;
Module._resolveFilename = function(request, parent, isMain) {
  // .pnp.cjs의 맵을 사용하여 패키지 위치 직접 반환
  return resolveFromPnpMap(request, parent);
};

핵심 메커니즘:

  1. ZIP 기반 저장: 패키지를 ZIP 파일 형태로 .yarn/cache에 저장
  2. 의존성 맵: .pnp.cjs에 모든 의존성 관계와 위치 정보 저장
  3. Module Resolution Hook: Node.js의 모듈 해석 과정을 가로채서 .pnp.cjs 참조

장점:

  • 극도로 빠른 설치: ZIP 파일만 다운로드, 압축 해제 불필요
  • Zero Install: .yarn/cache를 Git에 커밋하면 yarn install 생략 가능
  • 패키지 탐색 속도: 파일 시스템 순회 없이 .pnp.cjs 맵에서 즉시 위치 확인

Node Linker 설정 전략

Yarn Berry의 Node Linker 옵션

# .yarnrc.yml
nodeLinker: pnp  # 기본값, 권장

1. pnp (Plug'n'Play)

# 구조
.yarn/cache/         # ZIP 파일
.pnp.cjs            # 의존성 맵
# node_modules 없음!

특징:

  • 가장 빠른 설치 및 실행 속도
  • Zero Install 지원
  • Phantom Dependency 완벽 차단

2. pnpm (PNPM 스타일)

# 구조
node_modules/
└── .bin/
# PNPM과 유사한 하드링크 방식

3. node-modules (호환성 모드)

# 구조
node_modules/
├── react/
└── lodash/
# npm과 동일한 평탄화 구조

레거시 도구와의 호환성을 위해 사용하지만, PnP의 이점을 포기합니다.

PNPM의 Node Linker 옵션

# .npmrc
node-linker=isolated  # 기본값

1. isolated (기본값)

# 구조
node_modules/
├── .pnpm/
   └── react@18.2.0/node_modules/react/  # 하드링크
└── react -> .pnpm/react@18.2.0/node_modules/react  # 심볼릭 링크

2. hoisted

# 구조
node_modules/
├── react/  # 평탄화
└── lodash/

npm과 유사한 구조이지만 여전히 전역 저장소 사용

3. pnp

# 구조
.pnp.cjs
# Yarn Berry PnP와 동일

PNPM v8.6+에서 실험적으로 지원

성능 벤치마크 비교

설치 속도 테스트

테스트 환경:

  • Next.js 14 프로젝트 (125개 패키지)
  • M2 MacBook Pro, SSD
  • 빈 캐시 / 캐시 있음 두 가지 케이스

결과:

패키지 매니저 빈 캐시 캐시 있음 Lockfile 생성
npm 10 34.2s 18.5s 12.3s
Yarn Classic 28.7s 14.2s 10.8s
PNPM 18.3s 6.2s 4.5s
Yarn Berry PnP 12.5s 3.8s 2.1s

분석:

  • PNPM: 하드링크 생성이 복사보다 빠름 (~3배 빠름)
  • Yarn Berry PnP: ZIP 파일만 다운로드, 압축 해제 불필요 (~5배 빠름)

디스크 사용량 테스트

10개 프로젝트에서 공통 패키지 사용 시:

패키지 매니저 디스크 사용량 절감률
npm 2.8GB 0%
Yarn Classic 2.7GB 3.6%
PNPM 680MB 75.7%
Yarn Berry 520MB 81.4%

PNPM의 디스크 절감 원리:

# 동일 파일은 하드링크로 공유
ls -li node_modules/.pnpm/react@18.2.0/node_modules/react/index.js
# 12345678 (inode 번호)
 
ls -li ~/Library/pnpm/store/v3/files/00/a1b2c3...
# 12345678 (동일한 inode - 물리적으로 같은 파일)

Plug'n'Play (PnP) 상세 분석

PnP의 동작 원리

전통적인 모듈 해석 과정:

require('lodash')
→ node_modules/lodash/package.json 검색
→ 상위 디렉토리 순회: ../node_modules/lodash/
→ 계속 상위로: ../../node_modules/lodash/
→ 최대 수십 번의 파일 시스템 접근

PnP 모듈 해석:

require('lodash')
→ .pnp.cjs 맵 조회 (메모리)
→ 즉시 위치 반환: .yarn/cache/lodash-npm-4.17.21.zip/node_modules/lodash/
→ 1번의 조회로 완료

PnP 호환성 문제와 해결

1. 레거시 패키지 호환성

# .yarnrc.yml
packageExtensions:
  "legacy-package@*":
    dependencies:
      "missing-peer-dep": "^1.0.0"

2. IDE 지원

// .vscode/settings.json
{
  "typescript.tsdk": ".yarn/sdks/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true
}

설정 명령:

yarn dlx @yarnpkg/sdks vscode

3. 네이티브 모듈

# .yarnrc.yml
enableGlobalCache: false  # 네이티브 모듈은 로컬 캐시 사용

Yarn Berry만의 고유 기능

1. Zero Install

기존 방식:

git clone repo
cd repo
npm install  # ← 매번 수 분 소요
npm run dev

Yarn Berry Zero Install:

git clone repo  # .yarn/cache도 함께 clone
cd repo
yarn run dev  # 즉시 실행!

.gitignore 설정:

# .gitignore
.yarn/*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

효과:

  • CI/CD 시간 단축 (~70% 감소)
  • 네트워크 장애 시에도 개발 가능
  • 오프라인 환경 지원

단점:

  • Git 저장소 크기 증가 (100MB ~ 500MB)
  • Code review 시 의존성 변경 추적 어려움

2. 플러그인 시스템

공식 플러그인 예시:

# Workspace 도구
yarn plugin import workspace-tools
 
# 버전 관리
yarn plugin import version
 
# Interactive 도구
yarn plugin import interactive-tools

커스텀 플러그인 작성:

// .yarn/plugins/@my/plugin-auto-update.js
module.exports = {
  name: '@my/plugin-auto-update',
  factory: (require) => {
    const { BaseCommand } = require('@yarnpkg/cli');
 
    class AutoUpdateCommand extends BaseCommand {
      async execute() {
        // yarn auto-update 명령 구현
        this.context.stdout.write('Checking for updates...\n');
        // ...
      }
    }
 
    return {
      commands: [AutoUpdateCommand],
    };
  },
};

3. Constraints (제약 조건)

모노레포에서 패키지 간 일관성을 강제합니다.

% constraints.pro
gen_enforced_dependency(WorkspaceCwd, 'react', '18.2.0', DependencyType) :-
  workspace_has_dependency(WorkspaceCwd, 'react', _, DependencyType).
yarn constraints  # 제약 조건 검증

PNPM만의 고유 기능

1. Overrides vs Resolutions

PNPM의 세밀한 제어:

{
  "pnpm": {
    "overrides": {
      "lodash@<4.17.21": "^4.17.21",  # 특정 버전 범위만
      "express>body-parser": "^1.20.0"  # 특정 의존성 체인
    }
  }
}

Yarn Berry:

{
  "resolutions": {
    "lodash": "4.17.21"  # 전역 적용
  }
}

2. Workspace Protocol

// packages/app/package.json
{
  "dependencies": {
    "shared-lib": "workspace:*",  # 항상 로컬 버전 사용
    "utils": "workspace:^1.0.0"   # 범위 지정 가능
  }
}

3. 공유 Lockfile

모노레포에서 단일 pnpm-lock.yaml로 일관성 보장:

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'

실전 마이그레이션 가이드

npm/Yarn Classic → PNPM

1. 설치 및 초기화

npm install -g pnpm
pnpm import  # package-lock.json에서 pnpm-lock.yaml 생성

2. Scripts 변경 불필요

// package.json - 변경 없이 사용 가능
{
  "scripts": {
    "dev": "next dev",
    "build": "next build"
  }
}

3. CI/CD 설정

# .github/workflows/ci.yml
- name: Install pnpm
  uses: pnpm/action-setup@v2
  with:
    version: 8
 
- name: Install dependencies
  run: pnpm install --frozen-lockfile

4. 호환성 이슈 해결

# .npmrc
# Phantom dependency를 사용하는 레거시 패키지 대응
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

npm/Yarn Classic → Yarn Berry

1. 마이그레이션

yarn set version berry
yarn install

2. PnP 설정

# .yarnrc.yml
nodeLinker: pnp
 
# IDE 지원 설정
yarn dlx @yarnpkg/sdks vscode

3. PnP 호환성 패치

# 호환되지 않는 패키지 패치
yarn patch some-legacy-package
# 에디터 열림 → 수정 → 저장
yarn patch-commit -s /tmp/some-legacy-package

4. Zero Install 활성화

# .gitignore 업데이트
echo "!.yarn/cache" >> .gitignore
 
git add .yarn/cache
git commit -m "Enable Zero Install"

선택 가이드

PNPM을 선택해야 하는 경우

[권장] 추천:

  1. 기존 도구와의 호환성 중요

    • 레거시 패키지가 많은 프로젝트
    • Native addon 사용
  2. 모노레포 관리

    • Workspace 기능 우수
    • Turborepo와 통합 용이
  3. 점진적 마이그레이션

    • npm/Yarn Classic에서 최소한의 변경으로 전환
    • CI/CD 파이프라인 변경 최소화

코드 예시:

// PNPM은 node_modules 구조 유지 → 기존 코드 변경 불필요
import fs from 'fs';
const pkgPath = './node_modules/some-package/package.json';
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); // [권장] 동작

Yarn Berry를 선택해야 하는 경우

[권장] 추천:

  1. 최대 성능 필요

    • CI/CD 시간 최소화
    • Zero Install로 네트워크 비용 절감
  2. 신규 프로젝트

    • 레거시 제약 없음
    • 최신 생태계 활용
  3. 엔터프라이즈 환경

    • Constraints로 정책 강제
    • 플러그인으로 워크플로우 커스터마이징

예상 제약:

// PnP 모드에서는 파일 시스템 접근 제한
import fs from 'fs';
const pkgPath = './node_modules/some-package/package.json';
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); // [주의] 에러
 
// 해결책: require.resolve 사용
const pkgPath = require.resolve('some-package/package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); // [권장] 동작

트러블슈팅

PNPM 이슈

1. Peer Dependency 경고

# 자동 설치 활성화
pnpm config set auto-install-peers true

2. Symlink 미지원 환경 (Windows 일부)

# .npmrc
node-linker=hoisted

Yarn Berry 이슈

1. TypeScript 인식 문제

yarn dlx @yarnpkg/sdks vscode
# VSCode 재시작 → "Use Workspace Version" 선택

2. ESLint/Prettier PnP 미지원

# .yarnrc.yml
nodeLinker: node-modules  # 임시 해결책

또는 플러그인 사용:

yarn add -D @yarnpkg/pnpify
yarn pnpify eslint .

성능 최적화 팁

PNPM

1. 전역 저장소 정리

pnpm store prune  # 미사용 패키지 삭제

2. 병렬 설치 제한 (CI 환경)

# .npmrc
fetch-retries=5
network-concurrency=8  # 네트워크 부하 감소

Yarn Berry

1. 캐시 압축

# .yarnrc.yml
compressionLevel: 9  # 최대 압축 (느리지만 Git 저장소 크기 감소)

2. PnP Loose 모드

# .yarnrc.yml
pnpMode: loose  # 엄격한 검증 완화 (속도 향상)

결론

PNPM과 Yarn Berry는 각각 다른 철학으로 npm의 한계를 극복합니다.

핵심 권장사항:

  1. PNPM: 호환성과 안정성을 우선하는 대부분의 프로젝트

    • 모노레포 관리 우수
    • 디스크 효율성 75% 이상
    • 레거시 도구와 호환
  2. Yarn Berry: 최대 성능과 혁신을 추구하는 프로젝트

    • Zero Install로 CI/CD 시간 70% 단축
    • 플러그인 시스템으로 워크플로우 확장
    • PnP 호환성 이슈는 점진적 개선 중
  3. 점진적 도입:

    • PNPM: pnpm import로 즉시 전환 가능
    • Yarn Berry: nodeLinker: node-modules로 시작 → PnP 마이그레이션
  4. 모니터링:

    • 설치 시간 측정: time pnpm install
    • 디스크 사용량: du -sh node_modules
    • CI 빌드 시간 추적

두 도구 모두 npm 대비 획기적인 성능 향상을 제공하므로, 프로젝트 특성과 팀의 수용 능력에 맞게 선택하는 것이 중요합니다.

관련 아티클