
npm에서 pnpm 마이그레이션 방법 — 체크리스트와 자주 깨지는 지점
npm에서 pnpm으로 전환할 때 pnpm import로 lockfile을 옮기는 순서부터 phantom dependency·빌드 스크립트 차단·pnpm 11의 설정 위치 변경까지, 실제로 발목 잡는 지점을 체크리스트로 정리했다.
질문 하나로 시작해 보자. 당신 노트북의 node_modules 폴더들이 전부 차지하는 용량, 지금 얼마쯤일까? 프로젝트 열 개를 굴리는 개발자라면 수십 기가가 우습게 나온다. 똑같은 react, 똑같은 lodash가 프로젝트마다 복사본으로 깔려 있어서다. pnpm은 바로 이 지점을 손본 패키지 매니저인데, "디스크 좀 아끼는 도구" 정도로만 알고 넘어가면 진짜 이득의 절반도 못 챙긴다.
전환 자체는 생각보다 싱겁게 끝난다. 명령 네 줄이면 된다. 진짜 일은 그 다음 날, CI가 빨갛게 뜨면서 시작된다.

왜 pnpm인가 — 디스크·속도는 보너스다
pnpm의 핵심은 전역 저장소(content-addressable store)다. 패키지를 디스크에 딱 한 번만 저장해 두고, 프로젝트의 node_modules에는 그 실체를 하드링크로 걸어 둔다. 복사가 아니라 링크라서 설치가 빠르고, 같은 버전을 쓰는 프로젝트가 많을수록 용량 절감 폭이 커진다. 모노레포라면 체감이 확 다르다.
그런데 개인적으로 더 중요하게 보는 건 세 번째 특징이다. pnpm은 node_modules를 평탄하게(flat) 깔지 않는다. npm은 모든 의존성을 루트로 끌어올려(hoisting) 한 덩어리로 펼쳐 놓는데, 이 구조에서는 내가 package.json에 적지도 않은 패키지를 코드에서 import 해도 그냥 동작해 버린다. 누군가의 의존성이 우연히 루트로 올라와 있기 때문이다. 이걸 유령 의존성(phantom dependency)이라고 부른다.
유령 의존성은 "내 PC에서는 되는데"의 단골 원인이다. 선언하지 않은 패키지에 기대고 있다가, 그 패키지가 트리에서 빠지는 순간 운영에서만 조용히 터진다.
pnpm은 선언한 의존성만 접근하게 막는다. 전환 직후 Cannot find module 이 쏟아진다면, 그건 pnpm이 고장 난 게 아니라 그동안 숨어 있던 설계 결함을 드러낸 것이다.
전환 전, 30초 점검
손대기 전에 딱 세 가지만 확인하자.
- Node 버전: 최신 LTS(Node 20·22)면 문제없다. 뒤에서 다루지만 Node 25부터 설치 방법이 달라진다.
- CI 스크립트:
npm ci/npm install이 어디어디서 불리는지 먼저 훑어 둔다. 전환 후 한 번에 바꿔야 할 지점 목록이 된다. - 모노레포 여부: npm workspaces를 쓰고 있다면
package.json의workspaces필드를 pnpm은 읽지 않는다.pnpm-workspace.yaml의packages항목으로 따로 옮겨야 한다.
실제 전환은 네 줄이면 끝난다
먼저 pnpm을 깔고, 팀 전체가 같은 버전을 쓰도록 package.json에 못 박는다. Node 24 이하는 corepack이 아직 번들돼 있어 이 한 줄이면 된다.
corepack enable pnpm
corepack use pnpm@12
corepack use는 package.json에 "packageManager": "pnpm@12.x.x" 필드를 써 준다. 이게 있으면 다른 팀원이나 CI가 제멋대로 다른 pnpm 버전을 쓰는 사고를 막는다.
이제 본론. 기존 package-lock.json의 정확한 버전을 그대로 들고 오는 게 핵심이다. pnpm import가 그 일을 한다.
pnpm import
rm -rf node_modules package-lock.json
pnpm install
pnpm import는 package-lock.json을 읽어 pnpm-lock.yaml을 만든다. 버전이 고정된 채로 넘어오니, 느닷없이 마이너 버전이 올라가 깨지는 일은 없다. 그 다음 옛 node_modules와 package-lock.json을 지우고 새로 설치하면 끝이다. 락파일을 먼저 변환하고 나서 지우는 순서가 중요하다 — 반대로 하면 버전 정보를 통째로 잃는다.
남은 건 손에 익은 명령어를 바꿔 쓰는 일인데, 대응은 거의 1:1이다.
| npm | pnpm |
|---|---|
npm install | pnpm install |
npm ci | pnpm install --frozen-lockfile |
npm install react | pnpm add react |
npm install -D vite | pnpm add -D vite |
npm run build | pnpm build |
npm uninstall lodash | pnpm remove lodash |
pnpm run build 대신 pnpm build처럼 run을 생략해도 되는 게 소소하게 편하다.

진짜 문제는 전환 다음 날 터진다
여기서부터가 블로그 글마다 대충 넘어가는 구간이다. 실제로 사람을 붙잡는 지점들만 추렸다.
유령 의존성으로 빌드가 깨진다. 앞서 말한 그 문제다. Cannot find module 'xxx'가 뜨면, 그 패키지를 package.json에 명시적으로 추가하는 게 정답이다(pnpm add xxx). 급하면 아래 탈출구도 있지만, 임시방편일 뿐 빚은 남는다.
호이스팅 설정 위치가 바뀌었다 — 이게 함정이다. 옛날 글들은 하나같이 .npmrc에 shamefully-hoist=true를 넣으라고 한다. pnpm 11부터는 이게 틀린 조언이다. node_modules 구조를 바꾸는 설정(nodeLinker, shamefullyHoist, publicHoistPattern)은 이제 .npmrc가 아니라 pnpm-workspace.yaml에 적어야 한다. .npmrc에 넣으면 경고만 뜨고 조용히 무시된다.
# pnpm-workspace.yaml (pnpm 11+부터 여기, .npmrc 아님)
shamefullyHoist: true
# npm처럼 완전히 평탄한 트리가 필요하면
nodeLinker: hoisted
빌드 스크립트가 기본적으로 차단된다. pnpm 10부터 보안상 의존성의 postinstall 같은 빌드 스크립트를 자동 실행하지 않는다. esbuild, sharp처럼 네이티브 바이너리를 설치 시점에 빌드하는 패키지가 조용히 멈춰 있다면 이것 때문이다. 승인해 줘야 돈다.
pnpm approve-builds
peer dependency에 더 엄격하다. npm이 조용히 넘어가던 peer 불일치를 pnpm은 경고로 또렷하게 띄운다. 처음엔 성가셔 보여도, 버전 충돌을 미리 잡아 주는 쪽에 가깝다. 플랫한 트리를 전제로 만든 일부 오래된 도구(특정 번들러나 RN 설정 등)가 말썽이면 nodeLinker: hoisted로 npm과 같은 모양을 흉내 낼 수 있다.
CI와 Dockerfile도 같이 바꿔야 한다
로컬만 고치고 CI를 놔두면 파이프라인이 그대로 깨진다. GitHub Actions라면 pnpm/action-setup으로 pnpm을 먼저 깔고 캐시를 pnpm으로 돌린다.
- uses: pnpm/action-setup@v4
with: { version: 12 }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
Dockerfile도 같은 원리다. 락파일과 package.json을 먼저 복사해 설치 레이어를 캐시에 태우는 패턴은 그대로 가져가되, pnpm을 켜 주기만 하면 된다.
FROM node:22-slim
RUN corepack enable pnpm
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
여기서 한 가지 꼭 짚고 싶은 함정이 있다. Node 25부터 corepack이 Node.js에서 빠졌다. 최신 Node 이미지를 쓰는 CI·Docker 환경이라면 corepack enable이 그냥 "command not found"로 죽는다. 이럴 땐 npm install -g corepack 으로 따로 깔거나, 아예 npm install -g pnpm으로 직접 설치하는 단계를 명시해야 한다. 국내 많은 레포가 Dockerfile을 node:latest 같은 태그로 느슨하게 박아 두는데, 이게 어느 날 Node 25로 올라가는 순간 빌드가 터진다. 버전을 node:22 처럼 못 박아 두는 습관이 이래서 필요하다.
전환을 고민하는 한국 개발팀에게 현실적인 조언을 하나 보태자면, pnpm의 진짜 가치는 디스크 절약이 아니라 의존성 선언을 정직하게 만드는 강제력에 있다. npm의 관대한 호이스팅은 당장은 편하지만, 선언하지 않은 패키지에 기대는 코드가 쌓이면 언젠가 운영에서 터진다. 모노레포를 굴리는 팀일수록 이 효과가 크다. 다만 전환 첫날의 Cannot find module 폭탄을 "pnpm이 까다롭다"로 받아들이고 shamefullyHoist로 덮어 버리면, 비싼 도구로 npm을 흉내 내는 셈이 된다. 깨진 선언을 하나씩 package.json에 채워 넣는 그 수고가 사실은 이번 전환이 주는 가장 큰 선물이다. 그리고 오래된 블로그를 보고 .npmrc에 호이스트 설정을 넣었다가 "왜 안 먹지" 하며 반나절 날리는 일만은 피하자. pnpm 11 이후로는 pnpm-workspace.yaml이다.
정리하면 전환 자체는 pnpm import → 정리 → pnpm install 네 줄이고, 승부는 그 뒤의 유령 의존성·빌드 스크립트 승인·설정 파일 위치 세 가지에서 난다. 여기까지 넘기면 설치 속도와 디스크는 덤으로 따라온다.
여러분 팀은 아직 npm인가, 아니면 pnpm·yarn·bun 중 어디로 넘어갔나? 전환하면서 가장 크게 데인 지점이 있다면 댓글로 공유해 달라. 특히 모노레포 전환 경험담이 궁금하다.
참고
댓글 0
댓글을 불러오는 중…