pnpm vs npm 차이: node_modules 구조와 모노레포에서 고르는 기준

pnpm은 패키지 파일을 컴퓨터 한곳에 한 벌만 두고, 프로젝트가 `package.json`에 적은 패키지만 코드에서 불러올 수 있게 `node_modules`를 만든다.

JavaScript

npm은 node_modules를 어떻게 만드는가

https://www.npmjs.com

https://github.com/npm/cli

  1. Node.js는 require('axios')를 만나면 그 파일이 있는 폴더의 node_modules/axios를 찾고, 없으면 상위 폴더의 node_modules로 한 칸씩 올라가며 찾는다.

  2. npm은 이 탐색 규칙을 이용해, 의존성의 의존성까지 가능한 한 프로젝트 루트의 node_modules 한 층에 펼쳐 둔다.

  3. 이렇게 깊은 곳의 패키지를 위층으로 끌어올리는 일을 호이스팅(hoisting)이라 하고, npm의 기본 설치 전략 이름도 hoisted다.

  4. axios 하나만 설치해도 axios가 쓰는 form-data와 form-data가 쓰는 mime-types까지 27개 패키지가 루트에 나란히 놓인다.

  5. 같은 이름의 패키지가 서로 다른 버전으로 필요하면, 루트에는 한 버전만 둘 수 있으므로 나머지 버전은 그것을 쓰는 패키지 안의 node_modules에 넣는다.

  6. 이 배치는 package-lock.json에 기록되어, 의존성을 바꾸기 전까지는 다른 컴퓨터에서도 같은 구조로 다시 설치된다.

npm 구조에서 생기는 불편함

  1. 첫 번째 불편함은 package.json에 적지 않은 패키지도 require가 된다는 것이다.

  2. 앞의 예에서 프로젝트는 axios만 적었지만 루트에 form-data가 있으므로, 프로젝트 코드의 require('form-data')는 성공한다.

  3. 이렇게 적지 않았는데 쓰이는 의존성을 유령 의존성(phantom dependency)이라 부른다.

  4. 유령 의존성은 axios가 다음 버전에서 form-data를 빼거나, 다른 패키지 때문에 루트의 form-data 버전이 바뀌면 코드를 고치지 않았는데도 깨진다.

  5. 두 번째 불편함은 같은 버전의 패키지가 여러 벌 복사될 수 있다는 것이다.

  6. 루트 자리를 다른 버전이 이미 차지하면, 같은 버전을 원하는 패키지마다 자기 node_modules 안에 따로 한 벌씩 받는다.

  7. 벌마다 내용이 같은 파일이 디스크에 따로 쓰이므로, 중복된 벌 수만큼 디스크를 더 쓴다.

  8. 세 번째 불편함은 프로젝트마다 패키지 파일을 새로 써 넣는다는 것이다.

  9. npm은 내려받은 패키지 압축 파일을 컴퓨터의 캐시 폴더 ~/.npm/_cacache에 모아 두므로, 두 번째 프로젝트에서 같은 패키지를 설치할 때는 인터넷에서 다시 받지 않는다.

  10. 그래도 설치할 때마다 캐시의 압축 파일을 풀어 그 프로젝트의 node_modules에 파일을 새로 쓰므로, axios를 쓰는 프로젝트가 3개면 axios 파일도 디스크에 3벌 생긴다.

pnpm은 node_modules를 어떻게 만드는가

https://pnpm.io

https://github.com/pnpm/pnpm

  1. pnpm은 npm 구조의 세 불편함을 node_modules 배치 방식을 바꿔 푼다.

  2. axios, @types/node-fetch, form-data@3.0.4를 적은 같은 package.json을 두 도구로 설치하면 아래처럼 다른 구조가 생기고, axios와 @types/node-fetch는 둘 다 form-data@4.0.6을 원한다.

  3. 루트 node_modules에는 프로젝트의 package.json에 적은 패키지만 심볼릭 링크로 놓이므로, 프로젝트 코드가 적지 않은 패키지를 require하면 찾지 못한다.

  4. 그래서 axios만 적은 프로젝트에서 require('form-data')를 부르면, pnpm은 Error: Cannot find module 'form-data'를 던지고 멈춘다.

  5. 이 오류는 유령 의존성을 개발하는 그 자리에서 알려 준다.

  6. npm에서는 같은 코드가 오늘은 돌아가다가 axios를 올리거나 다른 패키지를 추가한 날 깨지고, 그때는 원인을 찾기 어렵다.

  7. pnpm에서는 form-data를 package.json에 적어야 코드가 돌아가므로, package.json이 코드가 실제로 쓰는 패키지 목록과 맞춰진다.

  8. 실제 패키지는 node_modules/.pnpm/<이름>@<버전>/node_modules/<이름>에 한 벌만 있고, pnpm은 이 .pnpm 폴더를 가상 저장소(virtual store)라 부른다.

  9. 가상 저장소의 각 패키지 옆에는 그 패키지가 적은 의존성이 링크로 놓이므로, axios 안의 require('form-data')는 그 링크를 따라 form-data@4.0.6을 찾는다.

  10. 가상 저장소의 파일은 컴퓨터 한곳에 있는 전역 저장소(content-addressable store)에서 가져온 것이고, 이 저장소는 파일을 내용의 해시로 구분해 같은 파일을 한 번만 보관한다.

  11. 파일을 가져올 때는 파일 시스템에 따라 하드 링크(같은 파일에 이름을 하나 더 붙이는 것)나 복제(내용을 바꾸기 전까지 디스크 공간을 함께 쓰는 복사)를 쓰고, 둘 다 안 되면 그냥 복사한다.

  12. 그래서 같은 패키지를 쓰는 두 번째 프로젝트를 설치할 때, npm은 캐시의 압축 파일을 풀어 파일을 다시 쓰고, pnpm은 하드 링크나 복제가 되는 환경이면 전역 저장소의 파일을 새로 쓰지 않고 연결만 한다.

  13. 하드 링크나 복제가 되는 환경이면, 여러 저장소를 클론해 일해도 pnpm은 같은 패키지 파일에 디스크 공간을 다시 쓰지 않는다.

모노레포에서는 무엇이 다른가

  1. 모노레포는 여러 package.json을 한 저장소에 두는 구조이고, npm과 pnpm 모두 그 하나하나를 워크스페이스(workspace)라 부른다.

  2. 이 글은 사용자 앱 apps/user, 관리자 앱 apps/admin, 두 앱이 함께 쓰는 버튼과 입력창 같은 화면 부품을 모은 공용 패키지 packages/ui로 이루어진 저장소를 예로 든다.

  3. 두 앱의 package.json에 적은 패키지 이름은 각각 user와 admin이고, user는 ui와 axios를 쓰며 admin은 ui만 쓴다.

  4. 두 앱은 ui가 저장소 안의 packages/ui로 연결되도록 적었다고 하고, 그렇게 적는 방법은 뒤의 workspace: 소제목에서 다룬다.

  5. npm은 루트 package.json의 workspaces 필드로, pnpm은 루트의 pnpm-workspace.yaml로 이 세 폴더를 워크스페이스로 등록한다.

    # pnpm-workspace.yaml
    packages:
      - apps/*      # apps/user, apps/admin을 워크스페이스로 등록한다
      - packages/*  # packages/ui를 워크스페이스로 등록한다
  6. 모노레포에서 두 도구가 가장 크게 다른 점은, 한 앱이 다른 앱의 패키지를 몰래 가져다 쓸 수 있느냐다.

  7. npm은 모든 앱의 의존성을 가능한 한 루트 node_modules 한곳에 모으므로, apps/user가 적은 axios가 루트에 놓이고 apps/admin 안에는 node_modules가 생기지 않는다.

  8. 그래서 apps/admin 코드가 axios를 적지 않고 require('axios')를 불러도, 상위 탐색이 루트의 axios를 찾아 성공한다.

  9. apps/admin이 axios를 쓰는 것 자체는 괜찮지만, 쓰면서 자기 package.json에 적지 않은 것이 문제다.

  10. apps/admin/package.json만 봐서는 관리자 앱이 axios 를 사용하고 있다는 사실이 보이지 않기 때문이다.

  11. 그래서 사용자 앱에서 axios를 지우면, 관리자 앱 코드는 아무도 건드리지 않았는데 관리자 앱이 깨진다.

  12. 관리자 앱만 따로 배포하려고 apps/admin/package.json만 보고 의존성을 설치해도, axios가 설치되지 않아 배포 서버에서 깨진다.

  13. pnpm은 앱마다 node_modules를 따로 만들고 그 앱이 적은 패키지만 넣으므로, apps/admin의 require('axios')는 Cannot find module 'axios'로 실패한다.

  14. 이 오류 덕분에 개발하는 그 자리에서 apps/admin/package.json에 axios를 적게 되고, 앱마다 package.json만 보면 그 앱이 쓰는 패키지를 모두 알 수 있다.

workspace: 프로토콜은 왜 쓰는가

  1. 두 앱은 공용 패키지 ui를 쓰려고 각자 package.json의 dependencies에 ui를 적는다.

  2. 이때 ui 옆에는 보통 "^1.0.0" 같은 버전 범위를 적는데, 버전 범위에는 이 ui를 어디서 가져올지가 적혀 있지 않다.

  3. npm은 저장소 안의 ui 버전이 범위에 맞으면 그것을 쓰고, 맞지 않으면 npm 레지스트리(누구나 패키지를 올리는 공개 저장소)에서 ui라는 이름을 찾는다.

  4. 레지스트리에는 다른 개발자가 ui라는 같은 이름으로 올려 둔, 우리 공용 패키지와 상관없는 패키지가 이미 있다.

  5. 그래서 저장소 안의 ui는 1.0.0인데 apps/admin에 "ui": "^0.2.0"으로 잘못 적으면, npm은 그 상관없는 ui@0.2.4를 내려받아 설치하고 오류 없이 끝난다.

  6. 관리자 앱은 우리 버튼 대신 엉뚱한 패키지를 불러오게 되고, 이 사실은 앱을 실행해 보기 전까지 드러나지 않는다.

  7. pnpm은 기본 설정(linkWorkspacePackages: false)에서 버전 범위만 적은 ui를 저장소 안에서 찾지 않고 늘 레지스트리에서 찾는다.

  8. 즉, workspace:는 이 패키지를 저장소 안에서 찾으라고 package.json에 적는 표기다.

    // apps/admin/package.json
    {
      "dependencies": {
        "ui": "workspace:*" // 저장소 안의 packages/ui에 연결한다, 레지스트리는 보지 않는다
      }
    }
  9. "ui": "workspace:*"로 적으면 pnpm은 버전과 상관없이 저장소 안의 packages/ui에 연결한다.

  10. 버전까지 확인하고 싶으면 "ui": "workspace:^1.0.0"처럼 범위를 붙이고, 저장소 안의 ui가 범위에 맞지 않으면 pnpm은 설치를 멈추고 알려 준다.

  11. workspace:로 적은 공용 패키지를 pnpm publish로 레지스트리에 올릴 때는 실제 버전 번호로 바뀌어 올라가므로, 나중에 공용 패키지를 공개해도 따로 고칠 필요가 없다.

  12. 물론 npm에서도 "ui": "file:../../packages/ui"처럼 폴더 경로를 적으면 레지스트리를 보지 않고 저장소 안의 ui에 연결된다.

  13. 다만 경로로 적으면 버전 범위를 함께 검사하지 못하므로, 저장소 안에서만 찾으면서 버전까지 확인하는 방식은 pnpm의 workspace:에만 있다.

catalog: 프로토콜은 왜 쓰는가

  1. 이번에는 관리자 앱도 axios를 쓰게 되어, 두 앱이 모두 package.json에 axios를 적었다고 하자.

  2. 각 앱이 버전을 자기 package.json에 따로 적으므로, 한 앱만 버전을 올리면 두 앱의 axios 버전이 어긋난다.

  3. 예를 들어 apps/user에 "axios": "1.20.0", apps/admin에 "axios": "1.7.9"가 적혀 있으면, 저장소에 axios가 두 버전 설치되고 버전을 올릴 때마다 앱 수만큼 package.json을 찾아 고쳐야 한다.

  4. 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을 쓴다
      }
    }
  5. 이렇게 하면 pnpm-workspace.yaml의 한 줄만 고쳐도 두 앱의 axios가 함께 바뀌고, 앱마다 버전이 어긋나지 않는다.

  6. npm에는 이 표기가 없으므로, 같은 패키지를 쓰는 앱마다 같은 버전을 적고 올릴 때도 함께 고쳐야 한다.

여러 앱을 한 번에 빌드할 때의 순서

  1. 두 앱이 공용 패키지 ui를 빌드한 결과물을 가져다 쓰는 구성이면, ui가 두 앱보다 먼저 빌드되어야 한다.

  2. npm run build --workspaces는 루트 package.json의 workspaces에 적힌 순서대로 빌드하므로, ["apps/*", "packages/*"]로 적었다면 admin, user, ui 순서로 돌아 앱이 ui보다 먼저 빌드된다.

  3. 대신 pnpm -r run build를 쓰면, pnpm이 각 앱의 package.json에서 누가 누구를 쓰는지 읽어 ui를 먼저 빌드하고 ui를 쓰는 앱을 나중에 빌드한다.

  4. 참고로 pnpm --filter admin... run build처럼 --filter를 쓰면 관리자 앱과 관리자 앱이 쓰는 워크스페이스 패키지만 골라 같은 순서로 빌드한다.

언제 무엇을 고르는가

  1. 여러 앱과 공용 패키지를 한 저장소에 묶는다면 pnpm을 쓴다.
    • 앱마다 쓰는 패키지가 섞이지 않고, workspace:와 catalog:로 공용 패키지 연결과 버전 맞추기를 해 주며, 공용 패키지부터 빌드해 주기 때문이다.
  2. 여러 저장소를 한 컴퓨터에서 오가며 일한다면 pnpm을 쓴다.
    • 같은 패키지 파일을 전역 저장소 한 벌로 함께 써서, 내려받기를 줄이고 하드 링크나 복제가 되는 환경이면 디스크도 아끼기 때문이다.
  3. 패키지 하나짜리 프로젝트라면 npm으로 충분하다.
    • npm v12도 .npmrc에 install-strategy=linked를 적으면 유령 의존성을 막고, 의존성의 설치 스크립트는 기본으로 막기 때문이다.
  4. pnpm을 따로 설치하기 어려운 환경이라면 npm을 쓴다.
    • npm은 Node.js를 설치하면 함께 설치되기 때문이다.

댓글