PNPM v10 Breaking Changes: 호이스팅 정책 변경과 마이그레이션 전략
자동 호이스팅 제거와 Symlink 기반 의존성 격리 강화
Node.js 생태계에서 패키지 매니저를 선택할 때, Yarn Berry(Yarn 2+)와 PNPM은 차세대 솔루션으로 주목받고 있습니다. 두 도구 모두 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.8MB3. 느린 설치 속도
npm/Yarn Classic은 패키지를 네트워크에서 다운로드 후 압축 해제하여 node_modules에 복사하는 방식으로, I/O 오버헤드가 큽니다.
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
핵심 메커니즘:
~/Library/pnpm/store에 패키지를 content-addressable 방식으로 저장.pnpm 디렉토리에서 전역 저장소를 하드링크로 참조node_modules에서 .pnpm의 패키지를 심볼릭 링크로 연결장점:
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);
};핵심 메커니즘:
.yarn/cache에 저장.pnp.cjs에 모든 의존성 관계와 위치 정보 저장.pnp.cjs 참조장점:
.yarn/cache를 Git에 커밋하면 yarn install 생략 가능.pnp.cjs 맵에서 즉시 위치 확인# .yarnrc.yml
nodeLinker: pnp # 기본값, 권장1. pnp (Plug'n'Play)
# 구조
.yarn/cache/ # ZIP 파일
.pnp.cjs # 의존성 맵
# node_modules 없음!특징:
2. pnpm (PNPM 스타일)
# 구조
node_modules/
└── .bin/
# PNPM과 유사한 하드링크 방식3. node-modules (호환성 모드)
# 구조
node_modules/
├── react/
└── lodash/
# npm과 동일한 평탄화 구조레거시 도구와의 호환성을 위해 사용하지만, PnP의 이점을 포기합니다.
# .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+에서 실험적으로 지원
테스트 환경:
결과:
| 패키지 매니저 | 빈 캐시 | 캐시 있음 | 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 |
분석:
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 - 물리적으로 같은 파일)전통적인 모듈 해석 과정:
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번의 조회로 완료
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 vscode3. 네이티브 모듈
# .yarnrc.yml
enableGlobalCache: false # 네이티브 모듈은 로컬 캐시 사용기존 방식:
git clone repo
cd repo
npm install # ← 매번 수 분 소요
npm run devYarn 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
효과:
단점:
공식 플러그인 예시:
# 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],
};
},
};모노레포에서 패키지 간 일관성을 강제합니다.
% constraints.pro
gen_enforced_dependency(WorkspaceCwd, 'react', '18.2.0', DependencyType) :-
workspace_has_dependency(WorkspaceCwd, 'react', _, DependencyType).yarn constraints # 제약 조건 검증PNPM의 세밀한 제어:
{
"pnpm": {
"overrides": {
"lodash@<4.17.21": "^4.17.21", # 특정 버전 범위만
"express>body-parser": "^1.20.0" # 특정 의존성 체인
}
}
}Yarn Berry:
{
"resolutions": {
"lodash": "4.17.21" # 전역 적용
}
}// packages/app/package.json
{
"dependencies": {
"shared-lib": "workspace:*", # 항상 로컬 버전 사용
"utils": "workspace:^1.0.0" # 범위 지정 가능
}
}모노레포에서 단일 pnpm-lock.yaml로 일관성 보장:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'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-lockfile4. 호환성 이슈 해결
# .npmrc
# Phantom dependency를 사용하는 레거시 패키지 대응
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*1. 마이그레이션
yarn set version berry
yarn install2. PnP 설정
# .yarnrc.yml
nodeLinker: pnp
# IDE 지원 설정
yarn dlx @yarnpkg/sdks vscode3. PnP 호환성 패치
# 호환되지 않는 패키지 패치
yarn patch some-legacy-package
# 에디터 열림 → 수정 → 저장
yarn patch-commit -s /tmp/some-legacy-package4. Zero Install 활성화
# .gitignore 업데이트
echo "!.yarn/cache" >> .gitignore
git add .yarn/cache
git commit -m "Enable Zero Install"[권장] 추천:
기존 도구와의 호환성 중요
모노레포 관리
점진적 마이그레이션
코드 예시:
// PNPM은 node_modules 구조 유지 → 기존 코드 변경 불필요
import fs from 'fs';
const pkgPath = './node_modules/some-package/package.json';
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); // [권장] 동작[권장] 추천:
최대 성능 필요
신규 프로젝트
엔터프라이즈 환경
예상 제약:
// 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')); // [권장] 동작1. Peer Dependency 경고
# 자동 설치 활성화
pnpm config set auto-install-peers true2. Symlink 미지원 환경 (Windows 일부)
# .npmrc
node-linker=hoisted1. 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 .1. 전역 저장소 정리
pnpm store prune # 미사용 패키지 삭제2. 병렬 설치 제한 (CI 환경)
# .npmrc
fetch-retries=5
network-concurrency=8 # 네트워크 부하 감소1. 캐시 압축
# .yarnrc.yml
compressionLevel: 9 # 최대 압축 (느리지만 Git 저장소 크기 감소)2. PnP Loose 모드
# .yarnrc.yml
pnpMode: loose # 엄격한 검증 완화 (속도 향상)PNPM과 Yarn Berry는 각각 다른 철학으로 npm의 한계를 극복합니다.
핵심 권장사항:
PNPM: 호환성과 안정성을 우선하는 대부분의 프로젝트
Yarn Berry: 최대 성능과 혁신을 추구하는 프로젝트
점진적 도입:
pnpm import로 즉시 전환 가능nodeLinker: node-modules로 시작 → PnP 마이그레이션모니터링:
time pnpm installdu -sh node_modules두 도구 모두 npm 대비 획기적인 성능 향상을 제공하므로, 프로젝트 특성과 팀의 수용 능력에 맞게 선택하는 것이 중요합니다.
자동 호이스팅 제거와 Symlink 기반 의존성 격리 강화
Yarn Classic에서 Berry로의 진화와 PnP 메커니즘을 통한 의존성 관리 혁신 분석