pnpm 모노레포 실전 — npm/yarn에서 전환한 이유
node_modules가 20GB를 차지할 때
모노레포의 node_modules가 20GB에 달했다. 깃 클론을 받는데 5분, 의존성 설치에 10분. npm install할 때마다 디스크 I/O 때문에 컴퓨터가 반응 불능 상태가 된다. 이건 개발 환경이 아니라 악몽이었다.
그때 pnpm으로 전환했다. 같은 모노레포가 4GB가 됐다. 설치 시간도 3분으로 단축됐다. 이제 pnpm 없이는 못 산다.
pnpm vs npm/yarn 비교
세 가지의 근본적인 차이:
<?xml version="1.0"?>
// npm/yarn의 방식: 중복 설치
node_modules/
react/
package.json
dist/
app1/
node_modules/
react/ (같은 버전인데 또 복사됨)
app2/
node_modules/
react/ (또 복사됨)
// pnpm의 방식: 심링크
node_modules/
.pnpm/
react@18.2.0/
node_modules/
react/
react -> .pnpm/react@18.2.0/node_modules/react (심링크)
pnpm은 같은 버전의 패키지는 한 번만 저장하고 심링크로 연결한다. 따라서 디스크 공간을 엄청 절약할 수 있다.
pnpm workspace 설정
모노레포 설정은 간단하다:
<?xml version="1.0"?>
// pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
// apps/web/package.json
{
"name": "@myapp/web",
"version": "1.0.0",
"dependencies": {
"@myapp/ui": "workspace:*",
"react": "^18.2.0"
}
}
// apps/api/package.json
{
"name": "@myapp/api",
"version": "1.0.0",
"dependencies": {
"@myapp/types": "workspace:*",
"express": "^4.18.0"
}
}
// packages/ui/package.json
{
"name": "@myapp/ui",
"version": "1.0.0",
"dependencies": {
"react": "^18.2.0"
}
}
"workspace:*"는 같은 워크스페이스 내의 패키지를 참조한다는 뜻이다. 빌드 시에는 실제 버전으로 변환된다.
의존성 설치 및 빌드
pnpm의 주요 명령어들:
<?xml version="1.0"?>
# 전체 워크스페이스 설치
$ pnpm install
# 특정 패키지에 의존성 추가
$ pnpm --filter @myapp/web add react
# 모든 패키지에서 테스트 실행
$ pnpm -r test
# 특정 패키지에서만 빌드
$ pnpm --filter @myapp/web build
# 의존성이 있는 순서대로 실행 (토폴로지 정렬)
$ pnpm -r --filter ...@myapp/web build
# 변경된 패키지만 빌드
$ pnpm --filter '...{./packages/**}[HEAD~1]' build
-r 플래그는 "recursive"를 의미한다. 모든 패키지에 명령을 적용한다.
Changeset로 버전 관리
여러 패키지의 버전을 일관되게 관리하려면 changeset을 쓴다:
<?xml version="1.0"?>
# changeset 초기화
$ pnpm exec changeset init
# 변경사항 기록
$ pnpm exec changeset
# 프롬프트가 나타남:
# - 영향받은 패키지 선택
# - 변경 타입 선택 (patch, minor, major)
# - 변경 설명 작성
# 결과: .changeset/[random-name].md 파일 생성
# 내용:
# ---
# '@myapp/web': minor
# '@myapp/ui': patch
# ---
#
# Add dark mode toggle to UI components
# PR이 merge 전에 changeset들을 모아서 버전 업데이트
$ pnpm exec changeset version
# 실제 배포
$ pnpm exec changeset publish
이 방식은 semantic versioning을 자동으로 따르고, 각 패키지의 CHANGELOG도 자동 생성된다.
성능 최적화
pnpm의 성능을 더 끌어내기:
<?xml version="1.0"?>
// .npmrc
strict-peer-dependencies=false
prefer-frozen-lockfile=true
enable-pre-post-scripts=false
# 설치 속도 향상
shamefully-hoist=false # pnpm의 엄격한 방식 유지
hoist-pattern=[] # 의존성 최소화
# CI 최적화
frozen-lockfile=true # lockfile 변경 금지
Turborepo와 함께 사용
pnpm + Turborepo는 최강 조합이다:
<?xml version="1.0"?>
// turbo.json
{
"extends": ["//"],
"globalDependencies": ["pnpm-lock.yaml"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"outputs": []
},
"lint": {
"outputs": []
}
},
"remoteCache": {
"signature": true,
"teamId": "..."
}
}
// package.json scripts
{
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint"
}
}
Turborepo는 빌드 결과를 캐싱해서, 변경되지 않은 패키지는 다시 빌드하지 않는다.
실전 예제: 모노레포 구조
<?xml version="1.0"?>
my-app/
pnpm-workspace.yaml
package.json
pnpm-lock.yaml
turbo.json
.github/
workflows/
ci.yml
apps/
web/
package.json
src/
api/
package.json
src/
admin/
package.json
src/
packages/
ui/
package.json
src/
dist/
types/
package.json
src/
utils/
package.json
src/
마이그레이션 팁
npm/yarn에서 pnpm으로 전환할 때:
- pnpm install-missing로 누락된 의존성 찾기
- --shamefully-hoist 플래그로 호환성 모드 실행
- 점진적으로 전환 (모든 패키지를 동시에 바꾸지 말 것)
- CI 설정도 함께 변경
성과
- 디스크 사용량: 20GB → 4GB (80% 감소)
- 설치 시간: 10분 → 3분
- 빌드 시간: 12분 → 5분 (Turborepo 캐싱 포함)
- 의존성 관리: 훨씬 깔끔하고 명확함
마무리
pnpm은 단순한 패키지 매니저가 아니라, 모노레포를 관리하는 완전히 새로운 방식이다. 개발자 경험이 크게 향상되고, CI 시간도 줄어든다. 모노레포를 진지하게 생각한다면, pnpm은 필수다.