pnpm에서 의존성을 관리하는 방법? (feat. Turborepo)

1. 들어가며


  • pnpm이 다른 패키지 매니저와 비교했을 때, 더 효율적인 방식으로 패키지를 관리하고 빠른 설치 속도를 가지고 있는 것은 알고 있었습니다.
  • 하지만 pnpm이 정확히 어떻게 의존성을 관리하는지에 대해서는 잘 모르는 상태로 사용하고 있었습니다.
  • 이에 따라서 pnpm에 대해서 조금 연구해보려고 합니다.

2. pnpm


  • pnpmNode.js 패키지 매니저 중 하나로, npm이나 yarn과 비슷한 역할을 하지만 더 효율적인 방식으로 패키지를 관리하는 것이 특징입니다.
Fast, disk space efficient package manager | pnpm
Fast, disk space efficient package manager | pnpm
Fast, disk space efficient package manager

3. pnpm의 특징


  • 가상 저장소 사용
    • pnpm은 각 프로젝트에서 사용하는 모든 모듈을 가상 저장소 (CAS) 에 저장하여 관리합니다.
    • 이를 통해 동일한 의존성을 여러 프로젝트에서 공유하고, 디스크 공간을 절약합니다.
  • 하드 링크와 심볼릭 링크
    • pnpm은 의존성 모듈을 가상 저장소에 하드 링크로 저장하고, node_modules에서는 심볼릭 링크로 참조합니다.
    • 이를 통해 중복 설치를 방지하고 효율적인 저장 공간 사용을 가능하게 합니다.
      • 하드 링크: .pnpm 내부에서 CAS의 실제 패키지 파일을 가리킴 (같은 데이터를 가리키는 또 다른 파일의 이름)
      • 심볼릭 링크: node_modules 내부에서 .pnpm의 패키지 폴더를 가리킴 (원본 파일의 경로를 가리키는 링크 파일)
  • 피어 의존성 관리
    • 피어 의존성을 관리하기 위해 의존성의 버전 조합에 따라 고유한 폴더를 생성하여, 필요한 경우 충돌 없이 여러 버전의 피어 의존성을 지원합니다.
  • 효율성과 성능
    • 가상 저장소와 하드 링크 방식을 사용해 디스크 용량을 절약합니다.
    • 이미 설치된 모듈을 다시 설치하지 않도록 하여 의존성 세팅 속도를 향상시킵니다.

3.1 디스크 공간 절약


  • 기존 npm/yarn의 문제점

    • npmyarn은 같은 패키지를 여러 프로젝트에서 사용하면, 각 프로젝트마다 중복으로 저장합니다.
    • 예를 들어, lodash라는 패키지를 100개의 프로젝트에서 쓰면, 100번 다운로드해서 각각 저장해야 합니다.
  • pnpm의 해결법

    • pnpm중앙 저장소(content-addressable store)를 사용해서 패키지를 한 번만 저장합니다. 각 프로젝트에서는 하드링크를 이용해 이 파일을 공유합니다.
    • 버전이 다른 패키지도 효율적으로 관리 가능한데요, 예를 들어, lodash@4.17.20이 있고, lodash@4.17.21이 추가될 경우, npm은 새 버전을 아예 새롭게 저장합니다. 즉, 작은 변경이 있더라도 전체 패키지를 복사해서 저장하여 디스크가 낭비가 됩니다.
    • 이에 반해 pnpm은 변경된 파일만 추가 저장하고, 나머지는 기존 파일을 재사용합니다. lodash@4.17.20이 있고, lodash@4.17.21이 추가될 경우, 변경된 파일이 1개 뿐이라면 그 1개만 추가된 저장합니다. (pnpm update를 통해서 설정된 범위 내에서 패키지를 업데이트)
    • 따라서 기존 파일을 복사할 필요 없기에 프로젝트 개수와 패키지가 많을수록 더 큰 공간 절약 효과가 크고, 설치 속도가 빨라집니다.

3.2 설치 속도 향상


  • 기존 npm의 문제점

    • npm의 설치 과정은 아래 순서와 같이 진행됩니다.
      • Dependency resolution (의존성 분석)
        • package.json을 읽고 필요한 패키지를 확인
      • Fetching (패키지 다운로드)
        • 모든 패키지를 인터넷에서 다운로드
      • Writing to node_modules (파일 저장)
        • node_modules에 다운로드한 패키지들을 복사
    • 이러한 과정은 패키지를 매번 새로 다운로드하고 저장해야 해서 시간이 오래 걸리고, 동일한 패키지를 여러 프로젝트에서 사용하면, 디스크 낭비가 발생합니다.
  • pnpm의 해결법

    • pnpm 설치 과정은 아래 순서와 같이 진행됩니다.
    • Dependency resolution (의존성 분석)
      • package.json을 읽고 필요한 패키지를 확인
    • Fetching (스토어에 패키지 저장)
      • 패키지를 한 번만 다운로드해서 공유 저장소(content-addressable store)에 저장
    • Linking dependencies (링크 생성)
      • node_modules에는 하드 링크(파일 참조)만 생성
    • 이러한 과정을 통해 패키지를 한 번만 다운로드하면 여러 프로젝트에서 공유 가능해집니다. 각 프로젝트의 node_modules에는 실제 파일이 아니라 링크만 존재하여 설치 속도가 빠릅니다.

3.3 안전한 의존성 관리


  • 기존 npm/yarn의 문제점

    • npmyarn은 모든 패키지를 node_modules 루트에 배치하는 평평한(flat) 구조를 사용합니다.
    • 이 때문에 의존성이 없는데도, 우연히 다른 패키지의 의존성을 참조할 수 있는 문제가 발생할 수 있습니다.
    • 예를들어, A 패키지가 B 패키지를 의존하지 않지만, C가 B를 의존하면 A에서도 B를 사용할 수 있는 문제가 생깁니다.
  • pnpm의 해결 방법

    • pnpm은 node_modules/.pnpm 디렉토리를 만들어 패키지를 격리시키고, 프로젝트가 직접 의존하는 패키지만 루트에 심볼릭 링크로 연결합니다.
    • 이 방식 덕분에 의존성이 명확하게 분리되고, 예상치 못한 참조 문제가 줄어듭니다.
Motivation | pnpm
Motivation | pnpm
Saving disk space

4. pnpm 에서 의존성을 설치하는 방식


  • 특징에서 살펴보았지만, 더 자세하게 하나씩 살펴보겠습니다. 우선 pnpm은 어떻게 의존성을 설치할까요?

4.1 Content Addressable Store 이란?


  • pnpm 에서는 패키지에서 필요한 의존성들을 모두 보관하는 CAS (Content Addressable Store)을 사용 합니다.
  • 각 패키지는 필요한 의존성을 복사하지 않고, pnpm이 관리하는 CAS 에 저장된 모듈을 기반으로 Hard Link를 생성합니다.
  • Content Addressable Store 경로는 각 OS 에 따라 기본적으로 아래와 같이 세팅됩니다.
    • Windows: ~/AppData/Local/pnpm/store
    • macOS: ~/Library/pnpm/store
    • Linux: ~/.local/share/pnpm/store
cas

macOS에서의 Content Addressable Store

4.2 .pnpm 디렉토리


  • /node_modules 내부에는 해당 프로젝트에서 설치된 의존성 목록과 .pnpm 디렉토리가 생성됩니다.
  • /node_modules 에 존재하는 모듈은 모두 Symbolic Link로 구성되어 있으며, 참조하는 대상은 모두 .pnpm 에 위치한 패키지입니다.
cas

node_modules 디렉토리

4.3.pnpm 디렉토리


  • 아래는 레포지토리에 라이브러리(express)를 설치한 후의 모습입니다.
node_modules/
├── express (심볼릭 링크) ───▶ .pnpm/express@4.21.1/node_modules/express/
│
└── .pnpm/
    ├── express@4.21.1/
    │   └── node_modules/
    │       ├── express (하드 링크) ───▶ <Content-Addressable Store/express>
    │       └── cookie (심볼릭 링크) ───▶ ../../cookie@0.7.1/node_modules/cookie/
    └── cookie@0.7.1/
        └── node_modules/
            └── cookie (하드 링크) ───▶ <Content-Addressable Store/cookie>
  • 단계는 아래와 같습니다.
  1. 패키지 설치 및 하드 링크 생성
    • express@4.21.1 및 그 의존성인 cookie@0.7.1 은 모두 pnpm의 가상 저장소 (content-addressable store) 에서 Hard Link 형태로 node_modules/.pnpm 하위에 생성됩니다.
    • 예를 들어 express@4.21.1.pnpm/express@4.21.1/node_modules/express 경로에 하드 링크로 저장되고, cookie@0.7.1 도 마찬가지로 .pnpm/cookie@0.7.1/node_modules/cookie에 저장됩니다.
  2. Symbolic Link 생성 및 종속성 연결
    • 다음 단계로, express 모듈은 최상위 node_modules 폴더에 Symbolic Link 로 추가됩니다.
    • express -> ./.pnpm/express@4.21.1/node_modules/express 와 같이 연결되어, 프로젝트가 직접 의존하고 있는 express를 쉽게 사용할 수 있게 됩니다.
  3. 의존성 Symbolic Link 구성
    • express가 의존하고 있는 cookie 라이브러리는 .pnpm/express@4.21.1/node_modules 내에 생성된 Symbolic Link 를 통해 연결됩니다.
    • Symbolic Linkcookie -> ../../cookie@0.7.1/node_modules/cookie와 같이 참조 경로를 설정하여, express가 사용하는 cookie 모듈을 올바르게 가리킬 수 있도록 구성합니다.

4.4 peerDependency 를 처리하는 방법


  • peerDependency의 경우 패키지 자체가 아닌, 이를 사용하는 상위의 패키지로부터 의존성을 해소합니다.
  • pnpm 에서는 동일한 프로젝트 내에서도 피어 의존성의 버전에 따라 서로 다른 종속성 세트를 가질 수 있습니다.
  • 아래는 레포지토리에 styled-components, react, react-dom 라이브러리를 설치한 후의 모습입니다.
node_modules/
├── styled-components (심볼릭 링크) ───▶ .pnpm/styled-components@6.1.13_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/styled-components/
├── react (심볼릭 링크) ───▶ .pnpm/react@18.3.1/node_modules/react/
├── react-dom (심볼릭 링크) ───▶ .pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/
│
└── .pnpm/
    ├── styled-components@6.1.13_react-dom@18.3.1_react@18.3.1__react@18.3.1/
    │   └── node_modules/
    │       ├── styled-components (하드 링크) ───▶ <Content-Addressable Store/styled-components>
    │       ├── react (심볼릭 링크) ───▶ ../../react@18.3.1/node_modules/react/
    │       └── react-dom (심볼릭 링크) ───▶ ../../react-dom@18.3.1_react@18.3.1/node_modules/react-dom/
    ├── react-dom@18.3.1_react@18.3.1/
    │   └── node_modules/
    │       ├── react-dom (하드 링크) ───▶ <Content-Addressable Store/react-dom>
    │       └── react (심볼릭 링크) ───▶ ../../react@18.3.1/node_modules/react/
    └── react@18.3.1/
        └── node_modules/
            └── react (하드 링크) ───▶ <Content-Addressable Store/react>
  • 단계는 아래와 같습니다.
  1. 패키지 설치 및 각 패키지 별 피어 의존성 판별
    • 패키지 설치 시, pnpm은 각 패키지의 피어 의존성을 판별합니다.
    • styled-components의 경우, reactreact-dom을 피어 의존성으로 요구합니다.
    • react-dom 역시 피어 의존성으로 react를 요구합니다.
  2. 설치된 패키지의 버전 조합을 통한 폴더 생성
    • pnpm은 설치된 패키지의 버전 조합을 통해 피어 의존성을 가지는 패키지에 대한 폴더명을 생성합니다.
    • 예시로 styled-components 의 경우 styled-components@6.1.13_react-dom@18.3.1_react@18.3.1__react@18.3.1 같은 형태로 폴더가 생성됩니다.
    • 이는 해당 라이브러리가 피어 의존성을 가지는 다른 라이브러리들과 각각의 버전을 조합한 결과입니다.
  3. Symbolic Link 생성 및 종속성 연결
    • node_modules에 각 패키지를 가리키는 심볼릭 링크가 생성됩니다.
    • styled-components.pnpm/styled-components@6.1.13_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/styled-components을 가리킵니다.
    • react.pnpm/react@18.3.1/node_modules/react을 가리킵니다.
    • react-dom.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom을 가리킵니다.
  4. 의존성 Symbolic Link 구성
    • .pnpm/styled-components@6.1.13_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules 내에는 styled-components가 가상 저장소(Content-Addressable Store)로 하드 링크 되어 있습니다.
    • 같은 폴더 내에서 reactreact-dom은 각각 상위에서 설치된 버전을 가리키는 심볼릭 링크로 구성되어 있습니다.
    • 이러한 구조는 모든 의존성이 정확한 버전을 참조하도록 보장하며, 설치된 패키지를 공유하여 중복을 줄임으로써 디스크 사용량을 절감하는 효과를 가집니다.
  • 결론적으로, pnpm에서는 패키지 내 피어 의존성의 조합에 따라 별도의 폴더를 생성하기 때문에 각 라이브러리가 요구하는 버전에 맞는 패키지를 정확히 설치하도록 하고, 이를 공유하여 용량을 절감합니다.

5. CAS 에서 패키지를 관리하는 방식


5.1 CAS 에서 패키지을 저장하는 방법


  • 패키지를 구성하는 파일 해싱 및 네이밍 부여
    • 먼저 패키지를 구성하는 각 파일 별로 하위 리소스 무결성 (SRI) 의 암호로서 쓰이는 integrity 값을 가져옵니다.
    • 이를 HEX 로 변환한 후 앞 두 자리는 디렉토리 이름으로, 나머지 문자열은 파일 이름으로 사용합니다.
    • 아래 이미지는 Pnpm CAS 에 저장된 파일 중 일부를 캡쳐한 내용입니다.
    cas

    cas 파일의 일부이다.

  • 패키지의 정보를 담은 index 파일 저장
    • pnpm 에서는 패키지 내 파일 정보 및, 패키지 명, 버전, 빌드 필요 여부 등을 담은 index 파일을 별도로 저장합니다.
    • index 파일의 경우 패키지의 Metadata 에 정의된 dist.integrity 값을 해싱한 이름에 -index.json 접미사가 붙습니다.
    cas

    cas index 파일의 일부이다.

  • 패키지 별 인덱스 파일을 생성함으로서 얻는 이점은?
    • 잠금 파일(lockfile) 내의 integrity 값이 올바른 패키지를 참조하는지 검증 가능
      • lockfile은 설치된 패키지의 정확한 버전과 그 상태를 기록하여 동일한 환경을 재현할 수 있도록 합니다.
      • 만약 Git 충돌 해결 과정에서 lockfile 의 integrity 값이 꼬이더라도, 이를 이용하여 잘못된 참조를 차단할 수 있습니다.
    • 동일한 콘텐츠가 서로 다른 패키지나 같은 패키지의 여러 버전에서 참조될 수 있도록 합니다.
      • 일부 레지스트리에서는 동일한 파일 내용을 여러 패키지 이름이나 다른 버전으로 배포할 수 있습니다.
      • 인덱스 파일은 콘텐츠의 고유 해시 값을 사용하기에 동일한 콘텐츠가 여러 패키지나 버전에서 재사용되어도 괜찮습니다.
pnpm/store/cafs/src/getFilePathInCafs.ts at 3a90ec1f8f47576c96d8a7b2ec99dabe6685d48a · pnpm/pnpm
pnpm/store/cafs/src/getFilePathInCafs.ts at 3a90ec1f8f47576c96d8a7b2ec99dabe6685d48a · pnpm/pnpm
Fast, disk space efficient package manager. Contribute to pnpm/pnpm development by creating an account on GitHub.

CAS 에서 패키지 구성 파일을 찾는 법


  • 예시로 cookie@0.7.1 패키지에 대한 index 파일을 찾는 과정은 다음과 같습니다.
  1. 패키지 메타데이터 가져오기
    • https://registry.npmjs.org/cookie/0.7.1 URL에 요청하여 패키지의 메타데이터를 가져옵니다.
    • 이 메타데이터는 해당 패키지의 의존성, 배포 정보 등이 포함되어 있습니다.
  2. 무결성 값 확인 dist.integrity
    • 패키지 메타데이터 에서 dist.integrity 값을 확인합니다.
    • 예시로, 현재 패키지에서는 sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== 로 확인됐습니다.
  3. 무결성 값을 16진수로 변환
    • dist.integrity 값을 Base64에서 16진수 (HEX) 로 변환합니다.
    • 예시로 변환된 결과는 e839c89e9c7b489d802b7f824d413f64cd2f59351ba1909e831842db1888c9d1d1f6336e4c001206b7c4a4786218e670fe75f9d5b1ede98425f23b06a38cbfd3 입니다.
    • 이 값은 이후 pnpm 에서 생성한 해당 패키지의 인덱스 파일이 위치한 경로를 찾는데 쓰입니다.
  4. 인덱스 파일의 디렉토리와 파일명 추적
    • 변환된 16진수 값에서 앞의 두 자리를 디렉토리 명으로 사용합니다.
    • 이후 나머지 문자열과 index.json을 결합하여 파일명을 만듭니다. (e.g e8/나머지 문자열-index.json)
  5. 인덱스 파일 정보 확인
    • 인덱스 파일에는 패키지를 구성하는 각 파일의 정보와 패키지 이름, 버전 등이 포함되어 있습니다.
    • 아래는 cookie@0.7.1 에 대해 pnpm 에서 생성한 인덱스 파일의 내용입니다.
    {
        "name": "cookie",
        "version": "0.7.1",
        "requiresBuild": false,
        "files": {
            "LICENSE": {
                "checkedAt": 1731377987152,
                "integrity": "sha512-Scr4piRfVq7q+vQQBTpyWGusX5lDBTzo+GtrnyDcyNnVU8Xu2mkHXKKVHU+knl9ox1PB8G3VVpgjBzU0eBh8EA==",
                "mode": 420,
                "size": 1175
            },
            "index.js": {
                "checkedAt": 1731377987152,
                "integrity": "sha512-dLZUX8QxZ/pvnpr8vclSdv6OiouJOYdcZ2A5ND4K2Fv03AKVZ0J/L1Tv6V+ikdvIUH16d8MMZc/4grpa/Pm0PA==",
                "mode": 420,
                "size": 8103
            },
            "package.json": {
                "checkedAt": 1731377987153,
                "integrity": "sha512-28LYzZyC0XTKxEhj/Xb8irIN4LjExPS25v/FsbtaaZOi7WyNnE9bLUdufkuJNwGbW7veF1552mnfnbPWZ6pTXg==",
                "mode": 420,
                "size": 1092
            },
            "README.md": {
                "checkedAt": 1731377987153,
                "integrity": "sha512-j2+giYjKvB2Q9lI9PKLQnoliKuy/LpVOZyQaB29D09sOyjuxayjNAlnoUTBWMfjcQimaeKpADOBiOskLfFbCGA==",
                "mode": 420,
                "size": 11769
            },
            "SECURITY.md": {
                "checkedAt": 1731377987154,
                "integrity": "sha512-WqBYt4zqvYsMm5RTAjcGyRbnJ3xILs02Ic49o2I3Avv1QBFNLpBDAf8wvzbDjFr10plAAh88Ctm8Jn1NKGFoHA==",
                "mode": 420,
                "size": 1180
            }
        }
    }
    
  6. 인덱스 파일 내 각 파일 별 integrity 값을 기반으로 파일 탐색
    • 인덱스 파일에서 files 배열 내 각 파일의 integrity 값을 활용하여 2,3 그리고 4번의 두 자리를 디렉토리 명으로 사용하는 과정을 반복하여 파일을 찾습니다.

6. 모노레포에서는?


6.1 모노레포 내 구성 package 간의 의존성 관리


  • pnpm workspace 에서 관리되는 패키지에 대한 의존성을 가질 경우, 해당 패키지 경로로 Symbolic Link 를 생성합니다.
  • 즉, 별도의 의존성 패키지로 설치되는 것이 아닌 패키지 폴더 자체를 Link 함으로서 패키지에 대한 의존성을 해결합니다.
  • 아래는 현재 구상하고 있는 서버의 모노레포 구조를 트리 구조로 도식화한 내용입니다.
my-server/
├── apps/
│   └── app-1/
│       └── node_modules/
│           └── @my-server/
│               ├── apm-node (심볼릭 링크) ───▶ ../../../packages/apm-node
│               ├── configuration (심볼릭 링크) ───▶ ../../../packages/configuration
│               ├── database (심볼릭 링크) ───▶ ../../../packages/database
│               ├── elastic-search (심볼릭 링크) ───▶ ../../../packages/elastic-search
│               ├── kafka (심볼릭 링크) ───▶ ../../../packages/kafka
│               ├── logger (심볼릭 링크) ───▶ ../../../packages/logger
│               └── swagger (심볼릭 링크) ───▶ ../../../packages/swagger
└── packages/
    ├── apm-node
    ├── configuration
    ├── database
    ├── elastic-search
    ├── kafka
    ├── logger
    └── swagger

7. 모노레포에서 앱을 도커로 빌드해보자!


7.1 Turbo Prune


  • turbo prune 은 모노레포에서 특정 애플리케이션과 의존성을 가진 하위 패키지들을 추출해 별도의 모노레포로 구축하는 기능을 제공합니다.
  • 이를 통해 특정 패키지와 필요한 의존성만을 별도로 추출하여 경량화된 환경에서 애플리케이션을 빌드할 수 있습니다.
  • 예를 들어 @my-server/app-1 애플리케이션에 대해 turbo prune 을 수행하면, 해당 애플리케이션과 의존성 관계에 있는 패키지만이 /out 디렉토리에 추출됩니다.
    out/
    ├── apps/
    │   └── app-1
    ├── packages/
    │   ├── apm-node
    │   ├── configuration
    │   ├── database
    │   ├── eslint-config
    │   ├── logger
    │   ├── swagger
    │   └── typescript-config
    ├── package.json
    ├── pnpm-lock.json
    └── pnpm-workspace.json
    
pnpm/store/cafs/src/getFilePathInCafs.ts at 3a90ec1f8f47576c96d8a7b2ec99dabe6685d48a · pnpm/pnpm
pnpm/store/cafs/src/getFilePathInCafs.ts at 3a90ec1f8f47576c96d8a7b2ec99dabe6685d48a · pnpm/pnpm
Fast, disk space efficient package manager. Contribute to pnpm/pnpm development by creating an account on GitHub.
  • 하지만, 이러한 방식은 Docker 빌드를 진행할 때 문제가 발생합니다.
  • 루트 디렉토리의 package.json 파일이 변경되면 타겟 애플리케이션과는 무관한 의존성 변경도 포함됩니다.
  • 이는 빌드 시에 Cache Miss 를 발생시키는 원인이 되며, 불필요한 의존성 설치로 인해 빌드 시간이 길어지는 문제를 야기합니다.

7.2. Turbo Prune의 --docker 옵션


이러한 문제를 해결하기 위해 turbo prune--docker 옵션이 별도로 등장했습니다.

  • --docker 옵션을 사용하면 /out 디렉토리 내에 /full/json 폴더를 별도로 생성합니다.
  • /full 디렉토리에는 타겟을 빌드하는 데 필요한 내부 패키지들의 소스 코드 전체가 복사됩니다.
  • /json 디렉토리에는 각 패키지의 package.json 파일만 별도로 복사됩니다.
out/
├── full/
│   ├── apps/
│   │   └── app-1
│   └── packages/
│       ├── apm-node
│       ├── configuration
│       ├── database
│       ├── eslint-config
│       ├── logger
│       ├── swagger
│       └── typescript-config
└── json/
    ├── apps/
    │   └── app-1/
    │       └── package.json
    └── packages/
        ├── apm-node/
        │   └── package.json
        ├── configuration/
        │   └── package.json
        ├── database/
        │   └── package.json
        ├── eslint-config/
        │   └── package.json
        ├── logger/
        │   └── package.json
        ├── swagger/
        │   └── package.json
        └── typescript-config/
            └── package.json
  • --docker 옵션은 기존과 다르게 Docker 빌드 과정에서 캐시 전략을 더욱 효율적으로 만들어 줍니다.
  • 의존성 설치에 필요한 lockfile 만 별도로 추출하여, 추출된 대상과 연관된 패키지들에 대한 의존성 설치가 정확하게 이루어집니다.
  • 이를 통해 애플리케이션 별로 의존성을 분리해 관리할 수 있으며, 관련 없는 패키지의 변경으로 인해 발생할 수 있는 Cache Miss 방지 및 불필요한 의존성 재설치 시간을 줄일 수 있습니다.

7.3 Dockerfile 구성 및 빌드 프로세스


  • my-server 디렉토리에서 --docker 옵션을 활용한 빌드를 위해 Dockerfile 을 아래와 같이 구성했습니다.
FROM node:22-alpine AS base
RUN npm i -g pnpm

ARG APPLICATION_NAME
ENV APPLICATION=${APPLICATION_NAME}

FROM base AS builder
WORKDIR /app

RUN npm i -g turbo@^2
COPY . .
RUN turbo prune --scope="@my-server/${APPLICATION}" --docker

FROM base AS installer
WORKDIR /app
# turbo prune 으로 나온 결과 중, 해당 애플리케이션을 구성하기 위한 의존성 설치
COPY --from=builder /app/out/json .

# /var/pnpm/store 디렉토리에 pnpm store 를 설정하고 이를 Cache Mount 진행
RUN --mount=type=cache,id=pnmcache,target=/var/pnpm/store \
    pnpm config set store-dir /var/pnpm/store && \
    pnpm config set package-import-method copy && \
    pnpm install --prefer-offline --ignore-scripts --frozen-lockfile

# turbo prune 으로 해당 애플리케이션과 의존 관계를 가진 패키지를 추출하여 빌드 진행
COPY --from=builder /app/out/full/ .
RUN pnpm run build:prod --filter="@my-server/${APPLICATION}..."

FROM base AS runner
WORKDIR /app

COPY --from=installer /app/apps/${APPLICATION}/dist ./

ENV NODE_ENV=production
CMD ["node", "dist/main.js"]
  • 위 Dockerfile은 다음과 같은 빌드 단계를 거칩니다.
  1. Builder 단계
    • turbo prune --docker 명령어를 통해 타겟과 관련된 패키지들을 /out 디렉토리로 추출합니다.
  2. Installer 단계
    • /json 폴더 내의 package.json 파일을 이용해 필요한 의존성을 설치합니다.
    • 이때, /var/pnpm/store 를 pnpm store 디렉토리로 지정하고 Cache Mount 하는 전략을 사용합니다.
    • 이후 turbo run build:prod 태스크를 실행하여 애플리케이션 빌드를 진행합니다.
  3. Runner 단계:
    • 빌드된 결과물이 저장된 /dist 디렉토리로부터 애플리케이션 실행에 필요한 코드를 가져옵니다.
    • 이후 node dist/main.js 명령어를 실행하여 NestJS 애플리케이션을 실행합니다.

7.4 하지만, 생겼던 문제점


  • 애플리케이션 실행에 필요한 패키지들이 node_modules 내에서 패키지 폴더와 Symbolic Link 를 맺고 있어 애플리케이션 부팅 시 패키지 모듈을 찾지 못하는 문제가 발생했습니다.
  • 기존 문제 정리
    • 기존 빌드 프로세스는 패키지 빌드 → 애플리케이션 빌드 → 애플리케이션 내 dist 및 node_modules 추출 순서였다.
    • 하지만 node_modules 내 패키지들이 실제로는 packages/ 경로를 참조하는 Symbolic Link로 존재했다.
    • 따라서 빌드 과정에서 링크가 참조했던 패키지 폴더가 전부 사라져 애플리케이션 실행 시 모듈을 찾지 못하는 이슈가 발생했다.
  • 이를 해결하기 위해서는 pnpm deploy 를 통해 배포 모드로 애플리케이션을 추출해야 합니다.
    • 이 과정에서 workspace 내에 존재하는 패키지들도 별도의 격리된 node_module 에 설치되어 문제가 해결됩니다.
    • 따라서 turbo prune을 실행하고하고, 이후 이를 기반으로 pnpm deploy를 실행하여 앱을 추출하도록 했습니다.
  • Deploy 이후에는 빌드된 distnode_modules 폴더만 별도로 추출하면 되기에 괜찮을 것으로 보입니다.

7.5 pnpm deploy


  • 그렇다면 위에서 언급한 해결방법인 pnpm deploy란 무엇일까요?
  • 여러 애플리케이션이 혼재하는 모노레포에서, 특정 애플리케이션에 대해 독립적인 의존성 파일을 생성하려면 pnpm deploy 명령어를 사용하여 이를 분리할 수 있습니다.
  • pnpm deploy 명령어는 대상 패키지의 모든 의존성 (워크스페이스 의존성 포함) 을 독립된 node_modules 디렉터리에 설치합니다.
  • Deploy 환경 에서는 심볼릭 링크 대신 실제 의존성 파일들이 모두 .pnpm 디렉토리 내부에 복사되어 들어가며, 내부 패키지가 마치 외부 의존성인 것처럼 취급됩니다.
  • 예를들어 아래와 같은 구조에서 app1pnpm deploy 명령어로 배포될 애플리케이션이라고 가정하겠습니다.
root/
├── package.json
├── pnpm-lock.yaml
├── node_modules/
│   ├── .pnpm/
│   ├── react/
│   └── other-packages/
├── apps/
│   └── app-1/
│       ├── package.json
│       ├── node_modules/
│       └── src/
└── workspace/
    ├── package.json
    └── other-packages/
  • pnpm deploy 명령어를 apps/app-1 디렉토리에서 실행하면, apps/app-1/node_modules/ 디렉토리에 모든 의존성 파일들이 실제 파일로 복사됩니다.
apps/
└── app1/
    ├── node_modules/
    │   ├── .pnpm/
    │   │   ├── react/
    │   │   └── other-packages/
    │   ├── react/                  # 실제 react 패키지 파일들
    │   ├── other-package/          # 실제 의존성 패키지 파일들
    │   └── ...
    ├── package.json                # 앱의 의존성 및 설정
    ├── src/
    └── dist/
  • 이제 node_modules/ 안에 있는 의존성들은 실제 파일들로 복사된 형태입니다. 예를 들어, react 패키지의 실제 파일들이 node_modules/react/로 복사되어 있고, 심볼릭 링크 대신 해당 파일들이 사용됩니다.
  • 배포된 디렉토리에서는 .pnpm 디렉토리 내의 의존성들이 실제 파일로 node_modules에 복사되어, 애플리케이션은 로컬 설치된 의존성처럼 동작합니다.
  • 이렇게 복사된 파일들 덕분에, 배포된 환경에서도 모든 의존성이 독립적으로 존재하며, 이 환경은 외부 의존성처럼 취급됩니다.

7.6 최종 DockerFile


FROM node:22-alpine AS base
WORKDIR /app

ARG APPLICATION_NAME=operation
ARG PNPM_STORE_DIR=/var/pnpm/store

RUN npm i -g pnpm turbo@^2

FROM base AS builder
WORKDIR /app

COPY . . 
RUN turbo prune --scope="@my-server/${APPLICATION_NAME}" --docker

FROM base AS installer
WORKDIR /app

COPY --from=builder /app/out/json .
RUN --mount=type=cache,id=pnmcache,target=${PNPM_STORE_DIR} \
    pnpm config set store-dir ${PNPM_STORE_DIR} && \
    pnpm install --prefer-offline --ignore-scripts --frozen-lockfile

COPY --from=builder /app/out/full/ .
RUN pnpm run build:prod --filter="@my-server/${APPLICATION_NAME}"

RUN --mount=type=cache,id=pnmcache,target=${PNPM_STORE_DIR} \
    pnpm --filter="@my-server/${APPLICATION_NAME}" --prod deploy out

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=installer /app/out/node_modules ./node_modules
COPY --from=installer /app/out/dist ./dist


EXPOSE 8080
CMD ["node", "dist/main.js"]
  • DockerFile을 하나씩 살펴보면 아래와 같은 순서로 동작합니다.
graph TD; A[Base 단계] -->|설정| B[node:22-alpine 사용] B -->|설정| C[WORKDIR /app 설정] C -->|설정| D[ARG 설정: 애플리케이션 이름 및 PNPM 저장소] D -->|설정| E[Turbo 및 pnpm 설치] F[Builder 단계] -->|복사| G[COPY . .] G -->|Prune 실행| H[turbo prune: 필요한 파일만 남김] I[Installer 단계] -->|복사| J[Turbo Prune 결과물에서 package.json 복사] J -->|설치| K[pnpm install 실행] K -->|복사| L[전체 소스 코드 복사 후 빌드 실행] L -->|설정| M[pnpm deploy 실행 - node_modules 복사] N[Runner 단계] -->|복사| O[node_modules 및 dist 폴더 복사] O -->|설정| P[ENV NODE_ENV=production] P -->|설정| Q[EXPOSE 8080] Q -->|실행| R[CMD node dist/main.js]
  • Base 단계 - 공통 환경
    • node:22-alpine 이미지를 사용
    • WORKDIR /app 설정
    • ARG를 활용하여 애플리케이션 이름pnpm 저장소 디렉토리 경로 지정 (PNPM_STORE_DIR=/var/pnpm/store)
    • Turbopnpm 설치
  • Builder 단계 - 소스 코드 준비
    • COPY . . 를 통해 전체 프로젝트 복사
    • turbo prune 실행, 해당 애플리케이션에 필요한 최소한의 파일만 남김
  • Installer 단계 - 의존성 설치 및 빌드
    • Turbo Prune 결과물에서 package.json 관련 정보 복사
    • pnpm install 실행 (캐시를 활용하여 빠르게 설치)
    • 전체 소스 코드 복사 후 빌드 실행 (pnpm run build:prod)
    • pnpm deploy 실행, 의존성 패키지를 node_modules 내부에 심볼릭 링크 없이 복사
  • Runner 단계 - 최종 실행 환경
    • node_modulesdist 폴더 복사
    • ENV NODE_ENV=production 설정
    • EXPOSE 8080 설정 (포트 8080 개방)
    • CMD ["node", "dist/main.js"] 실행 (애플리케이션 실행)

8. 마치며


  • 이렇게 pnpm에 대해서 알아보고 Turborepo와 함께 모노레포를 빌드해보았는데요. 확실히 npm이나 yarn보다 러닝커브가 있는 것 같습니다.
  • 하지만 기능들과 개념들을 살펴보면서 효율성을 위해서 많이 힘썼다는 것이 느껴졌습니다. 앞으로도 pnpm을 더 잘 쓰기 위해서 꾸준히 공부해야겠습니다.