pnpm vs npm 차이: node_modules 구조와 모노레포에서 고르는 기준
pnpm은 패키지 파일을 컴퓨터 한곳에 한 벌만 두고, 프로젝트가 `package.json`에 적은 패키지만 코드에서 불러올 수 있게 `node_modules`를 만든다.
npm은 node_modules를 어떻게 만드는가
-
Node.js는
require('axios')를 만나면 그 파일이 있는 폴더의node_modules/axios를 찾고, 없으면 상위 폴더의node_modules로 한 칸씩 올라가며 찾는다. -
npm은 이 탐색 규칙을 이용해, 의존성의 의존성까지 가능한 한 프로젝트 루트의
node_modules한 층에 펼쳐 둔다.
-
이렇게 깊은 곳의 패키지를 위층으로 끌어올리는 일을 호이스팅(hoisting)이라 하고, npm의 기본 설치 전략 이름도
hoisted다. -
axios하나만 설치해도axios가 쓰는form-data와form-data가 쓰는mime-types까지 27개 패키지가 루트에 나란히 놓인다. -
같은 이름의 패키지가 서로 다른 버전으로 필요하면, 루트에는 한 버전만 둘 수 있으므로 나머지 버전은 그것을 쓰는 패키지 안의
node_modules에 넣는다. -
이 배치는
package-lock.json에 기록되어, 의존성을 바꾸기 전까지는 다른 컴퓨터에서도 같은 구조로 다시 설치된다.
npm 구조에서 생기는 불편함
-
첫 번째 불편함은
package.json에 적지 않은 패키지도require가 된다는 것이다. -
앞의 예에서 프로젝트는
axios만 적었지만 루트에form-data가 있으므로, 프로젝트 코드의require('form-data')는 성공한다.
-
이렇게 적지 않았는데 쓰이는 의존성을 유령 의존성(phantom dependency)이라 부른다.
-
유령 의존성은
axios가 다음 버전에서form-data를 빼거나, 다른 패키지 때문에 루트의form-data버전이 바뀌면 코드를 고치지 않았는데도 깨진다.
-
두 번째 불편함은 같은 버전의 패키지가 여러 벌 복사될 수 있다는 것이다.
-
루트 자리를 다른 버전이 이미 차지하면, 같은 버전을 원하는 패키지마다 자기
node_modules안에 따로 한 벌씩 받는다.
-
벌마다 내용이 같은 파일이 디스크에 따로 쓰이므로, 중복된 벌 수만큼 디스크를 더 쓴다.
-
세 번째 불편함은 프로젝트마다 패키지 파일을 새로 써 넣는다는 것이다.
-
npm은 내려받은 패키지 압축 파일을 컴퓨터의 캐시 폴더
~/.npm/_cacache에 모아 두므로, 두 번째 프로젝트에서 같은 패키지를 설치할 때는 인터넷에서 다시 받지 않는다. -
그래도 설치할 때마다 캐시의 압축 파일을 풀어 그 프로젝트의
node_modules에 파일을 새로 쓰므로,axios를 쓰는 프로젝트가 3개면axios파일도 디스크에 3벌 생긴다.
pnpm은 node_modules를 어떻게 만드는가
-
pnpm은 npm 구조의 세 불편함을
node_modules배치 방식을 바꿔 푼다.
-
axios,@types/node-fetch,form-data@3.0.4를 적은 같은package.json을 두 도구로 설치하면 아래처럼 다른 구조가 생기고,axios와@types/node-fetch는 둘 다form-data@4.0.6을 원한다.
-
루트
node_modules에는 프로젝트의package.json에 적은 패키지만 심볼릭 링크로 놓이므로, 프로젝트 코드가 적지 않은 패키지를require하면 찾지 못한다. -
그래서
axios만 적은 프로젝트에서require('form-data')를 부르면, pnpm은Error: Cannot find module 'form-data'를 던지고 멈춘다. -
이 오류는 유령 의존성을 개발하는 그 자리에서 알려 준다.
-
npm에서는 같은 코드가 오늘은 돌아가다가
axios를 올리거나 다른 패키지를 추가한 날 깨지고, 그때는 원인을 찾기 어렵다. -
pnpm에서는
form-data를package.json에 적어야 코드가 돌아가므로,package.json이 코드가 실제로 쓰는 패키지 목록과 맞춰진다. -
실제 패키지는
node_modules/.pnpm/<이름>@<버전>/node_modules/<이름>에 한 벌만 있고, pnpm은 이.pnpm폴더를 가상 저장소(virtual store)라 부른다.
-
가상 저장소의 각 패키지 옆에는 그 패키지가 적은 의존성이 링크로 놓이므로,
axios안의require('form-data')는 그 링크를 따라form-data@4.0.6을 찾는다. -
가상 저장소의 파일은 컴퓨터 한곳에 있는 전역 저장소(content-addressable store)에서 가져온 것이고, 이 저장소는 파일을 내용의 해시로 구분해 같은 파일을 한 번만 보관한다.
-
파일을 가져올 때는 파일 시스템에 따라 하드 링크(같은 파일에 이름을 하나 더 붙이는 것)나 복제(내용을 바꾸기 전까지 디스크 공간을 함께 쓰는 복사)를 쓰고, 둘 다 안 되면 그냥 복사한다.
-
그래서 같은 패키지를 쓰는 두 번째 프로젝트를 설치할 때, npm은 캐시의 압축 파일을 풀어 파일을 다시 쓰고, pnpm은 하드 링크나 복제가 되는 환경이면 전역 저장소의 파일을 새로 쓰지 않고 연결만 한다.
-
하드 링크나 복제가 되는 환경이면, 여러 저장소를 클론해 일해도 pnpm은 같은 패키지 파일에 디스크 공간을 다시 쓰지 않는다.
모노레포에서는 무엇이 다른가
-
모노레포는 여러
package.json을 한 저장소에 두는 구조이고, npm과 pnpm 모두 그 하나하나를 워크스페이스(workspace)라 부른다. -
이 글은 사용자 앱
apps/user, 관리자 앱apps/admin, 두 앱이 함께 쓰는 버튼과 입력창 같은 화면 부품을 모은 공용 패키지packages/ui로 이루어진 저장소를 예로 든다.
-
두 앱의
package.json에 적은 패키지 이름은 각각user와admin이고,user는ui와axios를 쓰며admin은ui만 쓴다. -
두 앱은
ui가 저장소 안의packages/ui로 연결되도록 적었다고 하고, 그렇게 적는 방법은 뒤의workspace:소제목에서 다룬다. -
npm은 루트
package.json의workspaces필드로, pnpm은 루트의pnpm-workspace.yaml로 이 세 폴더를 워크스페이스로 등록한다.# pnpm-workspace.yaml packages: - apps/* # apps/user, apps/admin을 워크스페이스로 등록한다 - packages/* # packages/ui를 워크스페이스로 등록한다 -
모노레포에서 두 도구가 가장 크게 다른 점은, 한 앱이 다른 앱의 패키지를 몰래 가져다 쓸 수 있느냐다.
-
npm은 모든 앱의 의존성을 가능한 한 루트
node_modules한곳에 모으므로,apps/user가 적은axios가 루트에 놓이고apps/admin안에는node_modules가 생기지 않는다. -
그래서
apps/admin코드가axios를 적지 않고require('axios')를 불러도, 상위 탐색이 루트의axios를 찾아 성공한다.
-
apps/admin이axios를 쓰는 것 자체는 괜찮지만, 쓰면서 자기package.json에 적지 않은 것이 문제다. -
apps/admin/package.json만 봐서는 관리자 앱이axios를 사용하고 있다는 사실이 보이지 않기 때문이다. -
그래서 사용자 앱에서
axios를 지우면, 관리자 앱 코드는 아무도 건드리지 않았는데 관리자 앱이 깨진다. -
관리자 앱만 따로 배포하려고
apps/admin/package.json만 보고 의존성을 설치해도,axios가 설치되지 않아 배포 서버에서 깨진다.
-
pnpm은 앱마다
node_modules를 따로 만들고 그 앱이 적은 패키지만 넣으므로,apps/admin의require('axios')는Cannot find module 'axios'로 실패한다. -
이 오류 덕분에 개발하는 그 자리에서
apps/admin/package.json에axios를 적게 되고, 앱마다package.json만 보면 그 앱이 쓰는 패키지를 모두 알 수 있다.
workspace: 프로토콜은 왜 쓰는가
-
두 앱은 공용 패키지
ui를 쓰려고 각자package.json의dependencies에ui를 적는다. -
이때
ui옆에는 보통"^1.0.0"같은 버전 범위를 적는데, 버전 범위에는 이ui를 어디서 가져올지가 적혀 있지 않다. -
npm은 저장소 안의
ui버전이 범위에 맞으면 그것을 쓰고, 맞지 않으면 npm 레지스트리(누구나 패키지를 올리는 공개 저장소)에서ui라는 이름을 찾는다. -
레지스트리에는 다른 개발자가
ui라는 같은 이름으로 올려 둔, 우리 공용 패키지와 상관없는 패키지가 이미 있다. -
그래서 저장소 안의
ui는1.0.0인데apps/admin에"ui": "^0.2.0"으로 잘못 적으면, npm은 그 상관없는ui@0.2.4를 내려받아 설치하고 오류 없이 끝난다.
-
관리자 앱은 우리 버튼 대신 엉뚱한 패키지를 불러오게 되고, 이 사실은 앱을 실행해 보기 전까지 드러나지 않는다.
-
pnpm은 기본 설정(
linkWorkspacePackages: false)에서 버전 범위만 적은ui를 저장소 안에서 찾지 않고 늘 레지스트리에서 찾는다. -
즉,
workspace:는 이 패키지를 저장소 안에서 찾으라고package.json에 적는 표기다.// apps/admin/package.json { "dependencies": { "ui": "workspace:*" // 저장소 안의 packages/ui에 연결한다, 레지스트리는 보지 않는다 } } -
"ui": "workspace:*"로 적으면 pnpm은 버전과 상관없이 저장소 안의packages/ui에 연결한다. -
버전까지 확인하고 싶으면
"ui": "workspace:^1.0.0"처럼 범위를 붙이고, 저장소 안의ui가 범위에 맞지 않으면 pnpm은 설치를 멈추고 알려 준다. -
workspace:로 적은 공용 패키지를pnpm publish로 레지스트리에 올릴 때는 실제 버전 번호로 바뀌어 올라가므로, 나중에 공용 패키지를 공개해도 따로 고칠 필요가 없다. -
물론 npm에서도
"ui": "file:../../packages/ui"처럼 폴더 경로를 적으면 레지스트리를 보지 않고 저장소 안의ui에 연결된다. -
다만 경로로 적으면 버전 범위를 함께 검사하지 못하므로, 저장소 안에서만 찾으면서 버전까지 확인하는 방식은 pnpm의
workspace:에만 있다.
catalog: 프로토콜은 왜 쓰는가
-
이번에는 관리자 앱도
axios를 쓰게 되어, 두 앱이 모두package.json에axios를 적었다고 하자. -
각 앱이 버전을 자기
package.json에 따로 적으므로, 한 앱만 버전을 올리면 두 앱의axios버전이 어긋난다. -
예를 들어
apps/user에"axios": "1.20.0",apps/admin에"axios": "1.7.9"가 적혀 있으면, 저장소에axios가 두 버전 설치되고 버전을 올릴 때마다 앱 수만큼package.json을 찾아 고쳐야 한다. -
catalog:는 버전을pnpm-workspace.yaml한 곳에 적어 두고, 각 앱은 버전 대신 그 목록에 적힌 버전을 쓰라고만 적는 표기다.# pnpm-workspace.yaml catalog: axios: 1.20.0 # 모든 앱이 쓸 axios 버전을 여기 한 곳에만 적는다// apps/user/package.json과 apps/admin/package.json에 똑같이 적는다 { "dependencies": { "axios": "catalog:" // 버전 대신 catalog에 적힌 1.20.0을 쓴다 } } -
이렇게 하면
pnpm-workspace.yaml의 한 줄만 고쳐도 두 앱의axios가 함께 바뀌고, 앱마다 버전이 어긋나지 않는다.
-
npm에는 이 표기가 없으므로, 같은 패키지를 쓰는 앱마다 같은 버전을 적고 올릴 때도 함께 고쳐야 한다.
여러 앱을 한 번에 빌드할 때의 순서
-
두 앱이 공용 패키지
ui를 빌드한 결과물을 가져다 쓰는 구성이면,ui가 두 앱보다 먼저 빌드되어야 한다. -
npm run build --workspaces는 루트package.json의workspaces에 적힌 순서대로 빌드하므로,["apps/*", "packages/*"]로 적었다면admin,user,ui순서로 돌아 앱이ui보다 먼저 빌드된다. -
대신
pnpm -r run build를 쓰면, pnpm이 각 앱의 package.json에서 누가 누구를 쓰는지 읽어 ui를 먼저 빌드하고 ui를 쓰는 앱을 나중에 빌드한다.
-
참고로
pnpm --filter admin... run build처럼--filter를 쓰면 관리자 앱과 관리자 앱이 쓰는 워크스페이스 패키지만 골라 같은 순서로 빌드한다.
언제 무엇을 고르는가
- 여러 앱과 공용 패키지를 한 저장소에 묶는다면 pnpm을 쓴다.
- 앱마다 쓰는 패키지가 섞이지 않고,
workspace:와catalog:로 공용 패키지 연결과 버전 맞추기를 해 주며, 공용 패키지부터 빌드해 주기 때문이다.
- 앱마다 쓰는 패키지가 섞이지 않고,
- 여러 저장소를 한 컴퓨터에서 오가며 일한다면 pnpm을 쓴다.
- 같은 패키지 파일을 전역 저장소 한 벌로 함께 써서, 내려받기를 줄이고 하드 링크나 복제가 되는 환경이면 디스크도 아끼기 때문이다.
- 패키지 하나짜리 프로젝트라면 npm으로 충분하다.
- npm v12도
.npmrc에install-strategy=linked를 적으면 유령 의존성을 막고, 의존성의 설치 스크립트는 기본으로 막기 때문이다.
- npm v12도
- pnpm을 따로 설치하기 어려운 환경이라면 npm을 쓴다.
- npm은 Node.js를 설치하면 함께 설치되기 때문이다.