캐시가 있을 때 설치 시간 8.8초→1.4초, 같은 의존성 프로젝트 3개의 디스크 2,246MB→723MB. 직접 측정한 수치와 함께, 우리가 속도보다 중요하게 본 pnpm의 엄격함을 정리했습니다.
Ian4 min read6
lock 파일이 세 개
예전 프로젝트들은 npm으로 시작했습니다. 홈페이지, 관리자, API 세 앱으로 된 한 프로젝트는 앱마다 package-lock.json이 따로 있었습니다. 각각 7,474줄, 5,960줄, 1,524줄이었습니다. 의존성을 올리려면 세 폴더를 돌아다니며 따로 설치해야 했고, 같은 라이브러리가 앱마다 다른 버전으로 깔려 있어도 알아차리기 어려웠습니다.
이 프로젝트를 pnpm 워크스페이스로 옮기면서 lock 파일 세 개를 루트의 pnpm-lock.yaml 하나로 합쳤습니다. 지금은 플린랩스 홈페이지 모노레포와 포트폴리오 데모 25개를 모은 워크스페이스도 모두 pnpm을 씁니다. 바꾼 이유는 속도, 디스크, 그리고 엄격함입니다. 앞의 두 가지는 직접 재 봤습니다.
직접 재 본 설치 시간
플린랩스 홈페이지 모노레포(앱 3개, 공유 패키지 2개)의 의존성으로 npm과 pnpm을 비교했습니다. node:22-alpine 컨테이너에서 npm 10.9.9와 pnpm 11.5.3을 썼고, 두 도구 모두 lock 파일이 있는 상태에서 설치 스크립트를 빼고 3번씩 설치했습니다.
캐시가 비어 있을 때는 차이가 크지 않습니다. 둘 다 네트워크에서 패키지를 받아야 하기 때문입니다. 차이는 캐시가 있을 때 벌어집니다. npm은 8.8초, pnpm은 1.4초였습니다.
이유는 설치 방식에 있습니다. npm ci는 캐시에 패키지가 있어도 매번 압축을 풀어 node_modules에 파일을 새로 씁니다. pnpm은 패키지를 전역 store에 한 번만 풀어 두고, 프로젝트에는 그 파일을 가리키는 링크만 만듭니다. 브랜치를 바꾸거나 node_modules를 지우고 다시 설치하는 일이 하루에도 여러 번인 개발 환경에서는, 이 차이가 매번 체감됩니다.
디스크는 store 한 벌
같은 구조 덕분에 디스크도 아낍니다. store에는 파일 내용 기준으로 한 벌만 저장되고, 프로젝트의 node_modules는 하드링크로 그 파일을 공유합니다.
프로젝트 하나만 놓고 보면 npm 749MB, pnpm 635MB로 큰 차이가 없습니다. 하지만 같은 의존성을 쓰는 프로젝트를 세 개로 늘리면 npm은 2,246MB로 세 배가 되고, pnpm은 723MB에 그칩니다. 늘어난 부분은 링크 구조에 필요한 공간뿐입니다.
우리처럼 비슷한 스택의 프로젝트를 여러 개 동시에 진행하는 팀에게는 이 차이가 작지 않습니다.
참고로 macOS(APFS)에서는 pnpm이 하드링크 대신 파일 복제(clone)를 쓰기 때문에, du로 재면 절약이 보이지 않습니다. 그래서 이번 측정은 Linux 컨테이너에서 했습니다.
선언한 것만 쓸 수 있다
우리가 생각하는 pnpm의 가장 큰 장점은 사실 속도가 아니라 엄격함입니다.
npm은 모든 패키지를 node_modules 한 층에 펼쳐 놓습니다. 그래서 package.json에 적지 않은 패키지도, 다른 패키지의 의존성으로 깔려 있으면 그냥 import가 됩니다. 이른바 유령 의존성(phantom dependency)입니다. 지금은 동작하지만, 그 패키지를 끌고 온 라이브러리가 버전을 올리며 의존성을 빼는 순간 우리 코드가 깨집니다.
같은 모노레포의 web 앱 폴더에서 직접 확인해 봤습니다. scheduler는 web 앱이 선언하지 않은, react-dom의 의존성입니다.
bash
# npm으로 설치한 경우$ node -e "require.resolve('scheduler')" # 찾아짐# pnpm으로 설치한 경우$ node -e "require.resolve('scheduler')" # MODULE_NOT_FOUND
pnpm은 앱의 node_modules에 선언한 패키지만 링크로 둡니다. 나머지는 .pnpm 폴더 안, 각 패키지의 옆자리에만 있습니다. 실수로 쓴 의존성이 설치 단계에서 바로 드러나니, 나중에 운영에서 깨지는 것보다 훨씬 낫습니다.
워크스페이스와 병렬 실행
모노레포에서는 워크스페이스 기능이 편합니다. 내부 패키지는 workspace:*로 연결하고, 루트에서 필터로 앱을 골라 실행합니다.
json
{ "scripts": { "dev": "pnpm -r --parallel --stream run dev", "dev:web": "pnpm --filter web run dev" }}
-r --parallel은 모든 앱의 dev 서버를 한 번에 띄우고, --stream은 로그를 앱 이름과 함께 한 화면에 보여줍니다. 터미널을 앱 개수만큼 열 필요가 없습니다.
설치 스크립트는 허락받고
pnpm 10부터는 의존성의 설치 스크립트(postinstall 등)를 기본으로 실행하지 않습니다. 설치 스크립트는 공급망 공격의 대표적인 통로이기 때문입니다. pnpm 11에서는 어떤 패키지의 스크립트를 실행할지 allowBuilds에 하나씩 정합니다.
yaml
# pnpm-workspace.yamlallowBuilds: esbuild: true # 네이티브 바이너리 필요 sharp: true "@scarf/scarf": false # 설치 통계 수집용, 실행 불필요 swiper: false # 후원 안내 로그뿐
처음에는 번거롭습니다. 하지만 우리 프로젝트에서 설치할 때 어떤 코드가 실행되는지를 목록으로 갖게 된다는 점에서, 우리는 이 방식이 맞다고 봅니다.
바꿀 때 걸린 것
물론 그냥 바뀌지는 않았습니다. 엄격함은 그동안 숨어 있던 문제도 드러냅니다.
peer 의존성 누락: React 17, 18, 19 데모가 섞인 워크스페이스에서, 한 슬라이더 라이브러리가 react를 peer로 선언하지 않아 엉뚱한 버전의 react를 잡았습니다. 결과는 "Invalid hook call" 오류였습니다. packageExtensions로 peer 선언을 보태 해결했습니다.
타입 중복: @types/react 19.1과 19.2가 함께 깔려 JSX 타입이 충돌했습니다. overrides로 한 버전에 맞췄습니다.
Docker 빌드: 공유 UI 패키지의 의존성은 그 패키지 폴더의 node_modules에 링크됩니다. Dockerfile이 루트와 앱의 node_modules만 복사해서, 빌드 중에 Can't resolve 'clsx'가 났습니다. 패키지 폴더의 node_modules도 복사하게 고쳤습니다.
셋 모두 pnpm이 만든 문제라기보다 원래 있던 문제를 pnpm이 보여준 것입니다.
마치며
npm에서 pnpm으로 바꾼 이유를 하나만 꼽으라면, 우리는 속도보다 엄격함을 꼽겠습니다. 선언한 의존성만 쓸 수 있고, 설치 때 실행되는 코드를 직접 고르는 구조는 프로젝트가 커질수록 사고를 줄여 줍니다. 캐시가 있을 때 6배 빠른 설치와 프로젝트가 늘어도 거의 늘지 않는 디스크 사용량은 덤입니다.