pnpm 완전 가이드 | npm보다 3배 빠르고 디스크 절약하는 패키지 매니저
이 글의 핵심
npm을 대체하는 차세대 패키지 매니저 pnpm. 심볼릭 링크로 디스크 공간 70% 절약, 설치 속도는 3배 빠르며, 유령 의존성을 원천 차단해 더 안전합니다. Vue·Nuxt·Vite가 공식 채택했습니다.
이 글의 핵심
pnpm(performant npm)은 npm·yarn과 같은 package.json 생태계를 쓰면서, node_modules를 만드는 방식이 근본적으로 다릅니다. 패키지 바이트는 콘텐츠 주소 기반 전역 저장소에 한 번만 두고, 프로젝트 쪽은 하드 링크·심볼릭 링크로만 연결합니다. 그 덕에 설치가 빨라지고 디스크가 덜 쓰이며, 직접 선언하지 않은 패키지를 import하는 유령 의존성(phantom dependency) 을 걸기 어려워집니다.
이 글에서는 구조를 이해한 뒤, Monorepo·.npmrc·CI 설정까지 실무에서 그대로 복사해 쓸 수 있는 수준으로 정리했습니다. 숫자는 공식 벤치마크나 제 환경에서의 체감을 함께 적었는데, 망·디스크·CPU에 따라 달라진다는 점만 먼저 짚고 갑니다.
목차에서 다루는 것
- 심볼릭 링크와 콘텐츠 주소 저장소가 맞물리는 방식
- npm·yarn(클래식)과의 성능·디스크 감각 (참고용 벤치마크)
pnpm-workspace로 Monorepo를 잡는 실전 예- 호이스팅 옵션이란 무엇인지, 언제 풀어야 하는지
- peer dependencies·phantom dependencies로 터질 때
- GitHub Actions 등 CI에서 캐시·락 파일 고정
pnpm이란?
pnpm은 2017년 Zoltan Kochan이 개발한 패키지 매니저이고, npm 레지스트리·package.json 문법·대부분의 CLI 플래그는 익숙한 그대로입니다. 다른 점은 의존성을 평면 한 겹의 node_modules에 “다 널브러뜨리지” 않는다는 쪽에 가깝습니다.
핵심만 정리
- 디스크: 동일 머신에서 프로젝트를 여럿 쓰면, 같은 버전의 패키지는 전역 스토어에 파일이 한 벌만 존재하는 경우가 많습니다.
- 속도: 네트워크로 받은 tarball은 스토어에 넣고, 이후엔 링크·메타데이터 구성 비용이 중심이라 cold install이 반복될수록 이득이 큽니다.
- 엄격함:
package.json에 없는 패키지를 경로 꼼수로 끌어다 쓰기 어려워, 팀이 커질수록 “우연히 돌아가는 코드”를 줄입니다.
예전에 넣어 둔 다이어그램(개념용)
다음은 개념 설명용입니다. 실제 경로·버전·절약률은 프로젝트마다 달라집니다.
npm/yarn(평면적 설치):
node_modules/
├── react/ (한 프로젝트에 복사)
├── lodash/
└── express/
다른 프로젝트:
node_modules/
├── react/ (또 복사)
...
pnpm(전역 스토어 + 링크):
~/.local/share/pnpm/store (또는 환경에 맞는 store 경로)
└── (콘텐츠 주소별로 보관)
프로젝트 node_modules/ 는 .pnpm 가상 스토어 + 링크 트리
pnpm의 핵심 아키텍처: 심볼릭 링크와 콘텐츠 주소 기반 저장소
콘텐츠 주소 기반 스토어(content-addressable store)
pnpm이 받아 온 패키지 tarball을 풀어 저장할 때, 이름+버전만이 아니라 내용의 해시에 가까운 키로 스토어에 둡니다. 그래서 같은 바이트면 레지스트리에 어떤 태그가 붙어 있든 한 번 내려받은 파일 덩어리를 재사용하기 쉽습니다. 로컬에 이미 있으면 다운로드 대신 링크만 잡는 일이 늘어납니다.
스토어 위치는 OS·설정에 따라 다르고, pnpm store path로 확인할 수 있습니다. 팀이 Docker나 CI 캐시를 쓸 때는 “이 경로를 통째로 캐싱해도 되는가”를 스토어 경로 기준으로 잡는 편이 안전합니다.
node_modules는 왜 이상해 보이는가
프로젝트의 node_modules 아래에는 .pnpm 디렉터리에 가상의 저장 구조가 있고, 패키지마다 node_modules가 중첩된 형태로 자기 dependencies만 볼 수 있게 잠겨 있습니다. 그 위에 심볼릭 링크로 루트 node_modules에 “진입점”을 노출합니다.
이렇게 하면 A 패키지가 B를 쓰고, C도 B를 쓰더라도 B의 중복 설치를 피하면서, 동시에 A가 C의 B 버전에 실수로 의존하는 식의 평면형 트리 문제를 완화합니다. 익숙한 npm 트리와 달라서 처음엔 거슬리는데, 왜 pnpm이 엄격한지는 이 구조를 보면 이해됩니다.
하드 링크 vs 심볼릭 링크(실무 감각)
- 하드 링크: 같은 inode를 가리키는 또 다른 경로. 디스크 상 바이트는 한 벌.
- 심볼릭 링크: 다른 경로로 가는 “바로가기”. 윈도우에선 권한·개발자 모드·팀 정책에 따라 쓸 때 주의가 필요한 경우가 있습니다(대부분의 최신 툴체인은 문제없이 동작).
npm·yarn과의 성능 비교(참고 벤치마크)
공식 사이트와 커뮤니티에서 자주 인용하는 1000개 부근 패키지 시나리오에서, 대략 아래와 같은 순서가 나오는 경우가 많습니다(버전·OS·캐시 유무·레지스트리 지연에 따라 달라짐).
| 도구(대략) | cold install(캐시 없음) | 캐시 warm |
|---|---|---|
| npm | 느린 편 | 중간 |
| Yarn classic | npm보다 나은 경우多 | 중간~빠름 |
| pnpm | 병렬·스토어 덕에 유리한 경우多 | 매우 빠른 경우多 |
제 로컬(NVMe, 회사망)에서는 중규모 프론트엔드 모노레포에서 rm -rf node_modules 뒤 처음부터 깔 때 npm 대비 pnpm이 2~3배 빠른 날이 많았고, 캐시가 살아 있는 날엔 격차가 더 벌어졌습니다. 반대로 초기 pnpm 스토어가 비어 있는 CI에선, 한 번 캐시를 채우기 전에는 npm과 엇비슷하게 나오는 걸 봤습니다. 즉 “빠르다/느리다”는 캐시·네트워크·동시에 돌리는 잡을 같이 써야 이야기할 수 있습니다.
디스크
같은 머신에 레포를 여섯 갈래 클론해 두는 팀에서는, pnpm이 전역 스토어를 공유하므로 실제 바이트 점유가 눈에 띄게 줄었습니다. 반면 단일 프로젝트만 쓰는 랩탑이면, 절감 체감은 중복이 많을수록 큽니다.
pnpm 설치
# npm으로 설치
npm install -g pnpm
# Homebrew (macOS)
brew install pnpm
# Standalone Script (권장)
curl -fsSL https://get.pnpm.io/install.sh | sh -
# Windows (PowerShell)
iwr https://get.pnpm.io/install.ps1 -useb | iex
# 버전 확인
pnpm --version
corepack enable 뒤 package.json의 packageManager 필드로 버전을 박아 두는 팀도 많습니다(아래 CI 절 참고).
기본 명령어
패키지 설치
pnpm install
pnpm i
pnpm add react react-dom
pnpm add -D typescript @types/react
pnpm add -g typescript
pnpm add [email protected]
제거·업데이트
pnpm remove react
pnpm remove -g typescript
pnpm update
pnpm update react
pnpm update --latest
스크립트
pnpm run dev
pnpm dev
pnpm run --parallel dev test
pnpm dlx는 예전 npx에 대응합니다. pnpm dlx create-vite 같은 식으로 쓰면 됩니다.
Monorepo 구축 실전: 디렉터리와 package.json
아래는 한 저장소에서 앱·공용 UI·공용 유틸을 나누는 가장 흔한 뼈대입니다. 이름은 팀 취향대로 바꾸면 됩니다.
acme/
├── package.json
├── pnpm-workspace.yaml
├── apps/
│ └── web/
│ ├── package.json # "name": "@acme/web"
│ └── src/
├── packages/
│ ├── ui/
│ │ └── package.json # "name": "@acme/ui"
│ └── utils/
│ └── package.json # "name": "@acme/utils"
루트 package.json에는 공통 스크립트·공통 devDependencies·린트/포맷터를 몰아넣는 경우가 많습니다.
{
"name": "acme",
"private": true,
"scripts": {
"build": "pnpm -r run build",
"dev:web": "pnpm --filter @acme/web dev"
}
}
apps/web이 UI와 유틸을 쓰게 하려면:
{
"name": "@acme/web",
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"react": "^18.2.0"
}
}
workspace:* 는 “이 모노레포 안에 있는 동일 이름 패키지의 현재 버전”으로 묶이므로, 팀 내부 패키지끼리 연결할 때 씁니다. 배포·버저닝 정책(Changesets 등)은 별도로 잡는 게 일반적입니다.
로컬에서 자주 쓰는 명령:
pnpm install
pnpm --filter @acme/web add dayjs
pnpm -r run build
pnpm-workspace.yaml 상세 설정
최소 형태는 이렇게 끝납니다.
packages:
- 'apps/*'
- 'packages/*'
글롭·예외
특정 폴더는 워크스페이스에서 빼고 싶다면 ! 를 씁니다.
packages:
- 'packages/*'
- '!packages/_template'
catalog(버전을 한곳에)
pnpm 8+ 에서 쓰는 팀은 pnpm-workspace.yaml에 catalog 를 두고, 여러 패키지가 같은 react 버전을 공유하도록 묶기도 합니다(정확한 키 문법은 사용 중인 pnpm 메이저 문서를 확인).
실전 팁
- 깊이:
apps/*대신apps/**를 쓰지 않는 이유는, 빌드 산출물·임시 앱이 섞이면package.json없는 데를 스캔하다 실수한다는 경험 때문입니다. 구조를 얕게 유지하거나, 글롭을 좁힙니다. - 루트 private: 루트
package.json에"private": true를 넣는 습관은 npm/yarn 때와 같습니다. 실수로 공개될 일을 막습니다.
패키지 호이스팅(hoisting) 전략
pnpm의 기본은 “평면으로 다 올려 붙이지 않는다” 쪽에 가깝습니다. 그런데 낡은 툴이 node_modules/패키지이름 을 직접 찾는 경우, pnpm에선 깨질 수 있습니다. 그때 쓰는 완충이 호이스팅 관련 옵션입니다.
shamefully-hoist
.npmrc에 다음을 켜면, npm에 가깝게 끌어올려 올려 줍니다.
shamefully-hoist=true
이름이 “부끄럽다”는 뜻인 이유는, 유령 의존성이 다시 느슨해질 수 있기 때문입니다. “당장은 빌드가 살고, 팀이 시간 날 때 import를 정리하자” 식의 이행 단계로 쓰는 경우를 봤습니다.
public-hoist-pattern
특정 패턴만 루트로 올리고 싶다면 이 쪽이 낫습니다.
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
ESLint·Prettier·PostCSS가 내부에서 서브 패키지를 require하는데 경로를 못 찾는 류의 이슈에 타깃만 쓰면 됩니다.
node-linker
node-linker=hoisted
완전 npm 스타일에 가깝게 올리고 싶다면(레거시 호환 최우선), hoisted 를 쓰는 팀도 있습니다. pnpm의 장점(엄격한 격리)이 줄어드는 trade-off 는 팀이 합의해야 합니다.
실무 트러블슈팅: peer dependencies와 phantom dependencies
peer dependency가 쌓이며 설치가 실패할 때
React·TypeScript·ESLint 같이 “같이 깔아야 peer가 만족”하는 조합이면, pnpm이 엄하게 잡아줍니다. .npmrc에 다음을 켜는 팀이 많습니다(반대로 엄격 모드를 끄는 팀도 있어서 팀 룰과 맞출 것).
auto-install-peers=true
그래도 안 되면, 어느 패키지가 peer를 요구하는지를 보려고 pnpm why <패키지> 를 먼저 칩니다. 루트에 react 를 올릴지, UI 패키지 쪽에 둘지는 Monorepo 정책 문제라서, 한 번 합의해 두면 이후 PR이 조용해집니다.
phantom dependency(유령 의존성)
package.json에 없는데, 예전엔 import "lodash" 가 됐다 — 이런 코드는 pnpm에선 깨질 수 있습니다. 이건 pnpm이 나쁜 게 아니라, 원래 잘못 짠 코드가 드러난 케이스에 가깝습니다.
대응은 단순합니다. 의존성에 명시합니다.
pnpm add lodash
# 또는
pnpm --filter @acme/web add lodash
팀이 많을수록, 이걸 “버그”가 아니라 품질 게이트로 받아들이는 편이 덜 힘듭니다.
흔한 이슈: 바이너리가 node_modules/.bin 밖을 본다
스크립트·플러그인이 상대 경로로 깨지면, public hoist나 shamefully-hoist 를 최소 범위로 잠깐 켜서 원인을 좁힌 뒤, 근본적으로는 플러그인/설정을 고칩니다. 저는 PostCSS 7/8 뒤엉킨 늙은 툴체인에서 이 패턴을 본 기억이 있습니다.
CI/CD 최적화 팁
락 파일 고정
CI에서는 의도하지 않은 버전 떠다니기를 막기 위해 frozen-lockfile 를 켭니다.
pnpm install --frozen-lockfile
GitHub Actions 예(액션·캐시)
pnpm/action-setup 과 actions/setup-node의 cache: pnpm 조합이 흔합니다. 중요한 건 락 파일(pnpm-lock.yaml)을 커밋하고, 워크플로에 Node 버전을 로컬과 맞추는 것입니다.
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
Docker
멀티 스테이지 빌드에서 pnpm install 앞뒤로 락 파일만 먼저 COPY해 캐시 레이어를 쪼개는 패턴이 효율적입니다. 스토어 캐시를 이미지 밖에 마운트하는 팀도 있습니다(러너/플랫폼에 따라).
corepack
Node 20+ 환경이면 corepack enable 후 package.json에:
"packageManager": "[email protected]"
처럼 고정해 두면, 개발자·CI가 같은 pnpm을 쓰기 쉬워집니다.
npm → pnpm 마이그레이션(경험담)
제가 여러 저장소를 옮겼을 때 공통으로 나온 순서는 아래와 같습니다.
node_modules와package-lock.json또는yarn.lock을 제거한다.pnpm import(가능한 경우)로 락을 옮기거나, 그냥pnpm install로pnpm-lock.yaml을 새로 만든다.- 첫
pnpm build에서 터지면, 대부분 phantom import이거나 peer이었다.package.json에 누락 dep를 채웠다. - 여전히 낡은 webpack 플러그인이 루트
node_modules만 본다면,public-hoist-pattern으로 최소한만 끌어올려서 빌드 통과 → 여유될 때 플러그인 교체나node_modules직접 탐색 제거. - CI에
frozen-lockfile+cache: pnpm적용. 첫 파이프라인은 캐시가 비어 있어 느릴 수 있으니, 두 번째 PR부터 체감을 본다.
한 번은 Storybook+구형 PostCSS 체인에서 “모듈을 못 찾는다”가 났는데, 원인은 의존성 누락이 아니라 호이스팅 가정이었고, public-hoist-pattern에 postcss 를 넣는 식으로 당장은 넘기고, 나중에 PostCSS 8로 올릴 때 옵션을 걷어냈습니다. 이런 단계적 이행이 리얼한 마이그레이션의 상당 부분을 차지합니다.
package.json에 npm 사용을 막는 스크립트는 여전히 유용합니다.
{
"scripts": {
"preinstall": "npx only-allow pnpm"
}
}
(위 JSON은 package.json에 주석이 없다고 가정한 예시입니다. 주석이 필요하면 별도 문서에 적습니다.)
CI/CD 예시(이전에 문서에 있던 형태, 버전은 프로젝트에 맞게)
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
- run: pnpm install
- run: pnpm test
- run: pnpm build
.npmrc 설정(업데이트)
# 엄격한 peer(팀에 따라 off)
strict-peer-dependencies=true
auto-install-peers=true
# 격리 유지(기본에 가깝게)
shamefully-hoist=false
# 필요 시: 특정 툴 체인만
# public-hoist-pattern[]=*eslint*
registry=https://registry.npmjs.org/
loglevel=info
핵심 정리
- 스토어 + 링크로 바이트를 재사용하고, 설치 단계의 상당 부분을 I/O에서 링크/메타데이터로 바꾼다.
- 빠르다/느리다는 캐시·네트워크·초기 스토어 상태에 좌우되니, CI 한 번으로 결론내리지 말 것.
- Monorepo는
pnpm-workspace.yaml+workspace:*+pnpm --filter조합이 정석에 가깝다. - 호이스팅은 호환을 위한 완충이고,
public-hoist-pattern으로 범위를 좁히는 것이 장기적으로 낫다. - peer/phantom은 “버그”라기보다 의존성 그래프를 맞출 기회로 보면 덜 괴롭다.
더 읽을 거리
처음엔 node_modules가 낯설고 peer 에러에 짜증이 날 수 있습니다. 다만 팀이 커질수록, 누가 뭘 import했는지가 명확한 쪽이 밤에 전화를 덜 옵니다. 그 기준에서 pnpm을 한 번 길게 써볼 만하다는 게 제 결론입니다.