node_modules 지옥에서 탈출하기
프로젝트 3개를 동시에 작업하다 보니 맥북 용량이 빠듯해졌다. 디스크 분석기를 돌려봤더니 각 프로젝트의 node_modules가 1.2GB씩 차지하고 있었다. 같은 라이브러리를 쓰는데도 프로젝트마다 따로 설치되어 있었다. React 18.2.0을 세 번, lodash를 네 번, TypeScript를 다섯 번 설치한 셈이다.
더 짜증 나는 건 속도였다. npm install을 실행하면 커피 한 잔 마시고 와야 했다. CI/CD 파이프라인에서도 패키지 설치만 5분이 걸렸다. 빌드 시간의 절반 이상이 의존성 설치였다.
그러던 중 동료가 pnpm을 추천했다. "npm이랑 똑같은데 훨씬 빠르고 용량도 적게 먹어"라는 말에 반신반의하며 시도해봤는데, 결과는 충격적이었다. 설치 시간 5분 → 1분 30초, node_modules 크기 1.2GB → 400MB. 마법 같았다.
결국 "중복 제거"였다
pnpm의 핵심은 단순하다. 같은 패키지를 여러 번 저장하지 않는다는 것. 대신 한 곳에 저장해두고 필요할 때마다 링크로 연결한다.
도서관 비유로 이해하기
npm/yarn을 쓰는 건 이런 상황과 같다:
- 학생 A가 "해리포터 1권"을 빌리려고 하면, 도서관이 그 책을 복사해서 학생 A의 책가방에 넣어준다
- 학생 B도 같은 책이 필요하면, 또 복사해서 학생 B의 책가방에 넣어준다
- 학생 100명이 같은 책이 필요하면 100권을 복사한다
pnpm은 이렇게 작동한다:
- 도서관 중앙 서고에 "해리포터 1권"을 딱 한 권만 보관한다
- 학생들 책가방에는 "해리포터는 중앙 서고 3번 서가 2층에 있습니다"라는 메모만 넣어준다
- 100명이 필요해도 실제 책은 1권만 존재한다
이게 바로 content-addressable store의 개념이다.
Content-Addressable Store의 실제 구조
pnpm은 모든 패키지를 ~/.pnpm-store라는 글로벌 저장소에 보관한다. 각 패키지는 해시값으로 식별된다:
~/.pnpm-store/v3/files/
├── 00/
│ ├── 1a2b3c... (react@18.2.0의 실제 파일들)
│ └── 4d5e6f... (lodash@4.17.21의 실제 파일들)
└── 01/
└── 7g8h9i...
프로젝트의 node_modules는 이렇게 생겼다:
node_modules/
├── .pnpm/
│ ├── react@18.2.0/
│ │ └── node_modules/
│ │ └── react -> ~/.pnpm-store/.../1a2b3c...
│ └── lodash@4.17.21/
│ └── node_modules/
│ └── lodash -> ~/.pnpm-store/.../4d5e6f...
├── react -> .pnpm/react@18.2.0/node_modules/react
└── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash
실제 파일은 글로벌 스토어에 하나만 있고, 프로젝트에는 링크만 있다.
Hard Link와 Symlink, 어렵지 않다
pnpm을 이해하려면 두 가지 링크 개념을 알아야 한다. 처음엔 어렵게 느껴졌지만, 파일 시스템의 기본 원리만 알면 쉽다.
Hard Link: 같은 데이터를 가리키는 여러 이름
파일 시스템에서 파일은 두 부분으로 나뉜다:
- 실제 데이터 (inode에 저장됨)
- 파일 이름 (디렉토리 엔트리)
Hard link는 같은 실제 데이터를 가리키는 또 다른 이름이다:
# 원본 파일
echo "Hello" > original.txt
# Hard link 생성
ln original.txt hardlink.txt
# 둘은 완전히 같은 데이터를 가리킴
ls -li
# 12345678 -rw-r--r-- 2 user staff 6 original.txt
# 12345678 -rw-r--r-- 2 user staff 6 hardlink.txt
# ↑ inode 번호가 같다!
original.txt를 지워도 hardlink.txt는 여전히 데이터에 접근할 수 있다. 왜냐하면 실제 데이터는 모든 hard link가 사라질 때까지 지워지지 않기 때문이다.
pnpm은 글로벌 스토어에서 프로젝트로 패키지를 복사할 때 hard link를 사용한다. 실제 파일 데이터는 한 번만 디스크에 존재하지만, 여러 프로젝트에서 접근할 수 있다.
Symlink: 다른 파일을 가리키는 포인터
Symlink(심볼릭 링크)는 "바로가기"다. 실제 데이터가 아니라 다른 파일의 경로만 저장한다:
# Symlink 생성
ln -s /path/to/original.txt symlink.txt
# symlink.txt를 읽으면 자동으로 original.txt로 리다이렉트됨
ls -l
# lrwxr-xr-x 1 user staff 20 symlink.txt -> /path/to/original.txt
pnpm은 프로젝트의 node_modules에서 .pnpm 디렉토리로 symlink를 건다:
// 코드에서는 이렇게 import하지만
import React from 'react';
// 실제로는 이런 경로를 따라간다:
// node_modules/react (symlink)
// → node_modules/.pnpm/react@18.2.0/node_modules/react (hard link)
// → ~/.pnpm-store/v3/files/.../react (실제 파일)
왜 두 가지를 섞어 쓸까?
- Hard link: 글로벌 스토어 → 프로젝트 (디스크 공간 절약)
- Symlink: 프로젝트 내부 구조 (의존성 관리)
Hard link만 쓰면 의존성 트리를 표현하기 어렵고, symlink만 쓰면 디스크 공간을 절약할 수 없다. 둘을 조합해야 pnpm의 마법이 완성된다.
Phantom Dependency 지옥에서 구해준 엄격한 구조
npm/yarn을 쓰다가 이런 경험 없었나? 분명 package.json에 없는 패키지인데 import가 되는 상황. 이게 바로 phantom dependency 문제다.
npm의 Flat 구조가 만든 혼란
npm v3부터는 의존성을 평평하게(flat) 설치한다:
# package.json에 express만 있어도
{
"dependencies": {
"express": "^4.18.0"
}
}
# node_modules는 이렇게 됨
node_modules/
├── express/
├── body-parser/ # express의 의존성
├── cookie-parser/ # express의 의존성
└── debug/ # 여러 패키지가 공통으로 쓰는 의존성
이 구조에서는 body-parser를 직접 import할 수 있다:
// package.json에 없는데도 작동함!
import bodyParser from 'body-parser';
문제는 나중에 express가 body-parser를 제거하거나 버전을 바꾸면 내 코드가 깨진다는 것. 나는 body-parser에 의존하는지도 몰랐는데 말이다.
pnpm의 엄격한 격리
pnpm은 각 패키지를 완전히 격리한다:
node_modules/
├── .pnpm/
│ ├── express@4.18.0/
│ │ └── node_modules/
│ │ ├── express/
│ │ ├── body-parser/
│ │ └── cookie-parser/
│ └── body-parser@1.20.0/
│ └── node_modules/
│ └── body-parser/
└── express -> .pnpm/express@4.18.0/node_modules/express
node_modules 최상위에는 package.json에 명시된 패키지만 존재한다. body-parser는 .pnpm/express@4.18.0/node_modules/ 안에 갇혀 있어서 직접 접근할 수 없다.
// 에러 발생!
import bodyParser from 'body-parser';
// Error: Cannot find module 'body-parser'
처음엔 불편했다. 기존 프로젝트를 pnpm으로 마이그레이션할 때 숨어있던 phantom dependency들이 줄줄이 터졌다. 하지만 이게 오히려 좋았다. 실제로 사용하는 의존성을 명확히 알게 됐고, package.json이 진짜 의존성 목록이 됐다.
npm에서 pnpm으로 갈아타기
실제로 프로젝트를 마이그레이션하면서 겪은 과정을 정리했다.
1단계: pnpm 설치
가장 간단한 방법은 Corepack을 사용하는 것:
# Node.js 16.13+ 부터는 Corepack이 기본 포함됨
corepack enable
# pnpm 버전 지정 (선택사항)
corepack prepare pnpm@latest --activate
# 설치 확인
pnpm --version
또는 직접 설치:
npm install -g pnpm
# 또는
curl -fsSL https://get.pnpm.io/install.sh | sh -
2단계: 기존 파일 정리
# node_modules와 lock 파일 제거
rm -rf node_modules
rm package-lock.json # npm
rm yarn.lock # yarn
3단계: 의존성 설치
pnpm install
이때 phantom dependency 에러가 발생할 수 있다:
Error: Cannot find module 'some-package'
해결 방법은 간단하다. 실제로 사용하는 패키지를 명시적으로 추가:
pnpm add some-package
4단계: .npmrc 설정 (필요시)
일부 레거시 패키지나 peer dependency 문제가 있을 때:
# .npmrc 파일 생성
echo "shamefully-hoist=true" > .npmrc
shamefully-hoist=true는 npm과 비슷하게 패키지를 평평하게 만든다. 완벽한 해결책은 아니지만 급한 불은 끈다.
실제 마이그레이션 결과
내 Next.js 프로젝트 기준:
| 항목 | npm | pnpm | 개선 |
|---|---|---|---|
| 설치 시간 (캐시 없음) | 4분 32초 | 1분 18초 | 71% ↓ |
| 설치 시간 (캐시 있음) | 38초 | 12초 | 68% ↓ |
| node_modules 크기 | 1.2GB | 380MB | 68% ↓ |
| 설치된 파일 수 | 89,432개 | 31,847개 | 64% ↓ |
Monorepo에서 진가를 발휘하는 pnpm Workspaces
여러 패키지를 하나의 레포지토리에서 관리하는 monorepo에서 pnpm은 특히 빛난다.
pnpm-workspace.yaml 설정
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- 'tools/*'
프로젝트 구조
my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│ ├── web/ # Next.js 앱
│ │ └── package.json
│ └── api/ # Express API
│ └── package.json
└── packages/
├── ui/ # 공유 UI 컴포넌트
│ └── package.json
└── utils/ # 공유 유틸리티
└── package.json
워크스페이스 간 의존성
apps/web/package.json:
{
"name": "web",
"dependencies": {
"@my-org/ui": "workspace:*",
"@my-org/utils": "workspace:*",
"next": "^14.0.0"
}
}
workspace:* 프로토콜을 사용하면 로컬 패키지를 참조한다. 심볼릭 링크로 연결되어 실시간으로 변경사항이 반영된다.
필터링으로 특정 패키지만 작업
# web 앱만 빌드
pnpm --filter web build
# ui 패키지와 그걸 사용하는 모든 패키지 테스트
pnpm --filter @my-org/ui... test
# api를 제외한 모든 패키지 설치
pnpm install --filter=!api
의존성 공유의 마법
monorepo에서 여러 앱이 같은 라이브러리를 쓸 때, pnpm은 딱 한 번만 설치한다:
# apps/web과 apps/api 둘 다 react@18.2.0을 쓰면
# ~/.pnpm-store에 react는 1개만 존재하고
# 각 앱은 그걸 링크로만 참조함
# 이전에 Lerna + yarn을 쓸 때:
# apps/web/node_modules/react (500MB)
# apps/api/node_modules/react (500MB)
# 총 1GB
# pnpm으로 바꾼 후:
# ~/.pnpm-store/react (500MB)
# apps/web/node_modules/react -> 링크
# apps/api/node_modules/react -> 링크
# 총 500MB
벤치마크: 숫자로 보는 성능 차이
실제 프로젝트에서 측정한 결과들.
테스트 환경
- MacBook Pro M1 (16GB RAM)
- Next.js 14 프로젝트
- 의존성 패키지: 347개
- Node.js 20.10.0
초기 설치 (캐시 없음)
# 모든 캐시 삭제 후
rm -rf node_modules ~/.npm ~/.pnpm-store ~/.yarn
# npm
time npm install
# 4분 32초
# yarn (classic)
time yarn install
# 2분 18초
# pnpm
time pnpm install
# 1분 18초
재설치 (캐시 있음)
# node_modules만 삭제
# npm
time npm install
# 38초
# yarn
time yarn install
# 24초
# pnpm
time pnpm install
# 12초
CI/CD에서의 성능
GitHub Actions에서 측정:
# .github/workflows/ci.yml
- name: Install dependencies
run: pnpm install --frozen-lockfile
| 패키지 매니저 | 평균 설치 시간 | 캐시 적중률 |
|---|---|---|
| npm | 2분 47초 | 65% |
| yarn | 1분 52초 | 72% |
| pnpm | 54초 | 88% |
pnpm의 압도적인 승리. 캐시 효율이 높아서 같은 패키지를 반복 설치할 때 특히 빠르다.
Corepack으로 팀 전체에 pnpm 적용하기
프로젝트에 pnpm을 도입할 때 가장 큰 장벽은 "팀원들도 pnpm을 설치해야 한다"는 것이었다. Corepack이 이 문제를 해결했다.
Corepack이란?
Node.js 16.13+에 기본 포함된 패키지 매니저 관리 도구. package.json에 명시된 패키지 매니저를 자동으로 설치하고 사용한다.
설정 방법
// package.json
{
"packageManager": "pnpm@8.15.0"
}
# Corepack 활성화 (한 번만)
corepack enable
# 이제 pnpm 명령이 자동으로 작동
pnpm install
팀원이 npm이나 yarn을 실행하면 경고가 뜬다:
$ npm install
Usage Error: This project is configured to use pnpm
CI/CD 설정
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
Corepack 덕분에 별도로 pnpm을 설치할 필요가 없다.
주의할 점과 해결책
pnpm으로 전환하면서 마주친 문제들과 해결 방법.
1. Strict node_modules 구조로 인한 에러
문제: 기존 코드가 phantom dependency에 의존하고 있을 때
// package.json에 없는데 import했음
import _ from 'lodash';
// Error: Cannot find module 'lodash'
해결:
# 명시적으로 추가
pnpm add lodash
2. 네이티브 모듈 빌드 문제
문제: node-gyp로 빌드하는 패키지가 symlink를 따라가지 못함
# sharp, canvas 같은 패키지 설치 시 에러
해결: .npmrc에 추가
node-linker=hoisted
또는 특정 패키지만:
public-hoist-pattern[]=*sharp*
3. Next.js의 SWC와 충돌
문제: Next.js 13+에서 가끔 SWC가 심볼릭 링크를 제대로 처리 못함
해결: next.config.js에 추가
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
// pnpm symlink 지원
esmExternals: 'loose',
},
}
module.exports = nextConfig
4. Docker에서 pnpm 사용
문제: 심볼릭 링크가 Docker 레이어 캐싱과 충돌
해결: Multi-stage build 패턴
# Dockerfile
FROM node:20-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
5. peer dependency 경고 폭탄
문제: pnpm이 peer dependency를 엄격하게 체크해서 경고가 많음
WARN @typescript-eslint/parser requires a peer of eslint@^8.0.0 but none is installed.
해결: 실제로 필요한 것만 설치하거나, .npmrc로 경고 무시
# 엄격한 peer dependency 체크 비활성화
strict-peer-dependencies=false
정리: pnpm을 써야 하는 이유
6개월간 pnpm을 사용하면서 느낀 점을 정리하면:
확실한 장점
- 디스크 공간 절약: 프로젝트 10개를 작업해도 각 라이브러리는 한 번만 저장됨
- 설치 속도: npm보다 평균 3배 빠름
- 엄격한 의존성 관리: Phantom dependency 문제가 원천 차단됨
- Monorepo 지원: Workspace 기능이 Lerna/Turborepo와 완벽 호환
- 호환성: npm/yarn 프로젝트를 그대로 마이그레이션 가능
아직 불편한 점
- 생태계: 일부 오래된 패키지는 pnpm과 궁합이 안 맞음
- 학습 곡선: Hard link/Symlink 개념을 이해해야 트러블슈팅이 가능
- 문서화: npm에 비해 한국어 자료가 적음
정리하면
새 프로젝트라면 무조건 pnpm을 추천한다. 기존 프로젝트도 마이그레이션 비용이 크지 않다면 전환할 가치가 충분하다.
특히 이런 상황이라면 당장 시도해봐야 한다:
- CI/CD에서 의존성 설치가 너무 오래 걸릴 때
- 디스크 공간이 부족할 때
- Monorepo를 운영할 때
- Phantom dependency 때문에 고생한 경험이 있을 때
npm install이 끝나기를 기다리며 커피를 마시던 시간을, 이제는 실제 개발에 쓸 수 있게 됐다. 그것만으로도 충분한 가치다.
English Version
Escaping node_modules Hell
Working on three projects simultaneously, my MacBook's storage got tight. Running a disk analyzer revealed each project's node_modules consuming 1.2GB. The same libraries were installed separately for each project. React 18.2.0 three times, lodash four times, TypeScript five times.
What frustrated me more was the speed. Running npm install meant grabbing a coffee and waiting. Even in CI/CD pipelines, package installation alone took 5 minutes. More than half the build time went to dependency installation.
Then a colleague recommended pnpm. "It's just like npm but way faster and uses less space," they said. Skeptical but curious, I tried it. The results were shocking. Installation time: 5 minutes → 1 minute 30 seconds. node_modules size: 1.2GB → 400MB. Pure magic.
It All Comes Down to "Deduplication"
pnpm's core principle is simple: never store the same package twice. Instead, store it once and link to it when needed.
Understanding Through a Library Metaphor
Using npm/yarn is like this scenario:
- Student A wants to borrow "Harry Potter Volume 1", so the library copies the book and puts it in Student A's backpack
- Student B needs the same book, so they copy it again for Student B's backpack
- If 100 students need it, they make 100 copies
pnpm works like this:
- The library keeps exactly one copy of "Harry Potter Volume 1" in the central archive
- Each student's backpack gets a note saying "Harry Potter is in central archive, shelf 3, level 2"
- Even if 100 students need it, only one physical book exists
This is the concept of a content-addressable store.
The Actual Structure of Content-Addressable Store
pnpm stores all packages in a global store at ~/.pnpm-store. Each package is identified by its hash:
~/.pnpm-store/v3/files/
├── 00/
│ ├── 1a2b3c... (actual files of react@18.2.0)
│ └── 4d5e6f... (actual files of lodash@4.17.21)
└── 01/
└── 7g8h9i...
The project's node_modules looks like this:
node_modules/
├── .pnpm/
│ ├── react@18.2.0/
│ │ └── node_modules/
│ │ └── react -> ~/.pnpm-store/.../1a2b3c...
│ └── lodash@4.17.21/
│ └── node_modules/
│ └── lodash -> ~/.pnpm-store/.../4d5e6f...
├── react -> .pnpm/react@18.2.0/node_modules/react
└── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash
The actual files exist only once in the global store, while projects contain only links.
Hard Links and Symlinks Made Simple
Understanding pnpm requires grasping two linking concepts. Initially daunting, but straightforward once you understand basic filesystem principles.
Hard Link: Multiple Names for the Same Data
In a filesystem, a file consists of two parts:
- Actual data (stored in an inode)
- File name (directory entry)
A hard link is another name pointing to the same actual data:
# Original file
echo "Hello" > original.txt
# Create hard link
ln original.txt hardlink.txt
# Both point to identical data
ls -li
# 12345678 -rw-r--r-- 2 user staff 6 original.txt
# 12345678 -rw-r--r-- 2 user staff 6 hardlink.txt
# ↑ Same inode number!
Even if you delete original.txt, hardlink.txt still accesses the data. The actual data isn't deleted until all hard links disappear.
pnpm uses hard links when copying packages from the global store to projects. The actual file data exists only once on disk, but multiple projects can access it.
Symlink: A Pointer to Another File
A symlink (symbolic link) is a "shortcut". It stores only the path to another file, not the actual data:
# Create symlink
ln -s /path/to/original.txt symlink.txt
# Reading symlink.txt automatically redirects to original.txt
ls -l
# lrwxr-xr-x 1 user staff 20 symlink.txt -> /path/to/original.txt
pnpm creates symlinks from a project's node_modules to the .pnpm directory:
// Code imports like this
import React from 'react';
// But actually follows this path:
// node_modules/react (symlink)
// → node_modules/.pnpm/react@18.2.0/node_modules/react (hard link)
// → ~/.pnpm-store/v3/files/.../react (actual files)
Why Mix Both?
- Hard link: Global store → Project (saves disk space)
- Symlink: Project internal structure (dependency management)
Using only hard links makes dependency trees difficult to represent. Using only symlinks doesn't save disk space. Combining both completes pnpm's magic.
Saved from Phantom Dependency Hell with Strict Structure
Ever experienced this with npm/yarn? Importing a package that's definitely not in package.json, yet it works. That's the phantom dependency problem.
npm's Flat Structure Created Chaos
Since npm v3, dependencies are installed flat:
# Even with only express in package.json
{
"dependencies": {
"express": "^4.18.0"
}
}
# node_modules becomes this
node_modules/
├── express/
├── body-parser/ # express's dependency
├── cookie-parser/ # express's dependency
└── debug/ # common dependency shared by multiple packages
In this structure, you can directly import body-parser:
// Works even though not in package.json!
import bodyParser from 'body-parser';
The problem: if express later removes body-parser or changes versions, your code breaks. You didn't even know you depended on body-parser.
pnpm's Strict Isolation
pnpm completely isolates each package:
node_modules/
├── .pnpm/
│ ├── express@4.18.0/
│ │ └── node_modules/
│ │ ├── express/
│ │ ├── body-parser/
│ │ └── cookie-parser/
│ └── body-parser@1.20.0/
│ └── node_modules/
│ └── body-parser/
└── express -> .pnpm/express@4.18.0/node_modules/express
Only packages explicitly declared in package.json exist at the node_modules top level. body-parser is trapped inside .pnpm/express@4.18.0/node_modules/ and cannot be accessed directly.
// Error thrown!
import bodyParser from 'body-parser';
// Error: Cannot find module 'body-parser'
Initially inconvenient. Migrating existing projects to pnpm exposed hidden phantom dependencies one after another. But this turned out beneficial. I gained clarity on actual dependencies, and package.json became the true dependency list.
Migrating from npm to pnpm
Here's the process I went through migrating actual projects.
Step 1: Install pnpm
The simplest method uses Corepack:
# Node.js 16.13+ includes Corepack by default
corepack enable
# Specify pnpm version (optional)
corepack prepare pnpm@latest --activate
# Verify installation
pnpm --version
Or install directly:
npm install -g pnpm
# or
curl -fsSL https://get.pnpm.io/install.sh | sh -
Step 2: Clean Up Existing Files
# Remove node_modules and lock files
rm -rf node_modules
rm package-lock.json # npm
rm yarn.lock # yarn
Step 3: Install Dependencies
pnpm install
Phantom dependency errors might occur:
Error: Cannot find module 'some-package'
The solution is simple. Explicitly add packages you actually use:
pnpm add some-package
Step 4: Configure .npmrc (If Needed)
For legacy packages or peer dependency issues:
# Create .npmrc file
echo "shamefully-hoist=true" > .npmrc
shamefully-hoist=true flattens packages similarly to npm. Not a perfect solution, but puts out immediate fires.
Actual Migration Results
Based on my Next.js project:
| Metric | npm | pnpm | Improvement |
|---|---|---|---|
| Install time (no cache) | 4m 32s | 1m 18s | 71% ↓ |
| Install time (with cache) | 38s | 12s | 68% ↓ |
| node_modules size | 1.2GB | 380MB | 68% ↓ |
| Installed files | 89,432 | 31,847 | 64% ↓ |
pnpm Workspaces Shine in Monorepos
In monorepos managing multiple packages in one repository, pnpm particularly excels.
pnpm-workspace.yaml Configuration
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- 'tools/*'
Project Structure
my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│ ├── web/ # Next.js app
│ │ └── package.json
│ └── api/ # Express API
│ └── package.json
└── packages/
├── ui/ # Shared UI components
│ └── package.json
└── utils/ # Shared utilities
└── package.json
Dependencies Between Workspaces
apps/web/package.json:
{
"name": "web",
"dependencies": {
"@my-org/ui": "workspace:*",
"@my-org/utils": "workspace:*",
"next": "^14.0.0"
}
}
Using the workspace:* protocol references local packages. They're connected via symbolic links, so changes reflect in real-time.
Filtering to Work on Specific Packages
# Build only the web app
pnpm --filter web build
# Test the ui package and all packages that use it
pnpm --filter @my-org/ui... test
# Install all packages except api
pnpm install --filter=!api
The Magic of Dependency Sharing
In a monorepo when multiple apps use the same library, pnpm installs it exactly once:
# If both apps/web and apps/api use react@18.2.0
# Only 1 copy of react exists in ~/.pnpm-store
# Each app only references it via link
# Previously with Lerna + yarn:
# apps/web/node_modules/react (500MB)
# apps/api/node_modules/react (500MB)
# Total: 1GB
# After switching to pnpm:
# ~/.pnpm-store/react (500MB)
# apps/web/node_modules/react -> link
# apps/api/node_modules/react -> link
# Total: 500MB
Benchmarks: Performance by Numbers
Results measured from actual projects.
Test Environment
- MacBook Pro M1 (16GB RAM)
- Next.js 14 project
- Dependency packages: 347
- Node.js 20.10.0
Initial Installation (No Cache)
# After clearing all caches
rm -rf node_modules ~/.npm ~/.pnpm-store ~/.yarn
# npm
time npm install
# 4m 32s
# yarn (classic)
time yarn install
# 2m 18s
# pnpm
time pnpm install
# 1m 18s
Reinstallation (With Cache)
# Only delete node_modules
# npm
time npm install
# 38s
# yarn
time yarn install
# 24s
# pnpm
time pnpm install
# 12s
Performance in CI/CD
Measured in GitHub Actions:
# .github/workflows/ci.yml
- name: Install dependencies
run: pnpm install --frozen-lockfile
| Package Manager | Average Install Time | Cache Hit Rate |
|---|---|---|
| npm | 2m 47s | 65% |
| yarn | 1m 52s | 72% |
| pnpm | 54s | 88% |
pnpm's overwhelming victory. High cache efficiency makes it especially fast when repeatedly installing the same packages.
Applying pnpm Team-Wide with Corepack
The biggest barrier when introducing pnpm to a project was "teammates also need to install pnpm." Corepack solved this problem.
What is Corepack?
A package manager management tool included by default in Node.js 16.13+. It automatically installs and uses the package manager specified in package.json.
Configuration Method
// package.json
{
"packageManager": "pnpm@8.15.0"
}
# Enable Corepack (once only)
corepack enable
# Now pnpm commands work automatically
pnpm install
If teammates run npm or yarn, they get a warning:
$ npm install
Usage Error: This project is configured to use pnpm
CI/CD Configuration
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
Thanks to Corepack, no need to separately install pnpm.
Gotchas and Solutions
Problems encountered while switching to pnpm and their solutions.
1. Errors from Strict node_modules Structure
Problem: When existing code depends on phantom dependencies
// Imported without being in package.json
import _ from 'lodash';
// Error: Cannot find module 'lodash'
Solution:
# Add explicitly
pnpm add lodash
2. Native Module Build Issues
Problem: Packages building with node-gyp can't follow symlinks
# Errors when installing packages like sharp, canvas
Solution: Add to .npmrc
node-linker=hoisted
Or for specific packages only:
public-hoist-pattern[]=*sharp*
3. Conflicts with Next.js SWC
Problem: Next.js 13+ occasionally has SWC not properly handling symbolic links
Solution: Add to next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
// pnpm symlink support
esmExternals: 'loose',
},
}
module.exports = nextConfig
4. Using pnpm in Docker
Problem: Symbolic links conflict with Docker layer caching
Solution: Multi-stage build pattern
# Dockerfile
FROM node:20-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
5. Peer Dependency Warning Overload
Problem: pnpm strictly checks peer dependencies, causing many warnings
WARN @typescript-eslint/parser requires a peer of eslint@^8.0.0 but none is installed.
Solution: Install only what's actually needed, or ignore warnings via .npmrc
# Disable strict peer dependency checking
strict-peer-dependencies=false