pnpm에서 의존성을 관리하는 방법? (feat. Turborepo)
1. 들어가며
- pnpm이 다른 패키지 매니저와 비교했을 때, 더 효율적인 방식으로 패키지를 관리하고 빠른 설치 속도를 가지고 있는 것은 알고 있었습니다.
- 하지만 pnpm이 정확히 어떻게 의존성을 관리하는지에 대해서는 잘 모르는 상태로 사용하고 있었습니다.
- 이에 따라서 pnpm에 대해서 조금 연구해보려고 합니다.
2. pnpm
- pnpm은 Node.js 패키지 매니저 중 하나로, npm이나 yarn과 비슷한 역할을 하지만 더 효율적인 방식으로 패키지를 관리하는 것이 특징입니다.

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의 문제점
- npm과 yarn은 같은 패키지를 여러 프로젝트에서 사용하면, 각 프로젝트마다 중복으로 저장합니다.
- 예를 들어, 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에 다운로드한 패키지들을 복사
- Dependency resolution (의존성 분석)
- 이러한 과정은 패키지를 매번 새로 다운로드하고 저장해야 해서 시간이 오래 걸리고, 동일한 패키지를 여러 프로젝트에서 사용하면, 디스크 낭비가 발생합니다.
- npm의 설치 과정은 아래 순서와 같이 진행됩니다.
-
pnpm의 해결법
- pnpm 설치 과정은 아래 순서와 같이 진행됩니다.
- Dependency resolution (의존성 분석)
- package.json을 읽고 필요한 패키지를 확인
- Fetching (스토어에 패키지 저장)
- 패키지를 한 번만 다운로드해서 공유 저장소(content-addressable store)에 저장
- Linking dependencies (링크 생성)
- node_modules에는 하드 링크(파일 참조)만 생성
- 이러한 과정을 통해 패키지를 한 번만 다운로드하면 여러 프로젝트에서 공유 가능해집니다. 각 프로젝트의 node_modules에는 실제 파일이 아니라 링크만 존재하여 설치 속도가 빠릅니다.
3.3 안전한 의존성 관리
-
기존 npm/yarn의 문제점
- npm과 yarn은 모든 패키지를 node_modules 루트에 배치하는 평평한(flat) 구조를 사용합니다.
- 이 때문에 의존성이 없는데도, 우연히 다른 패키지의 의존성을 참조할 수 있는 문제가 발생할 수 있습니다.
- 예를들어, A 패키지가 B 패키지를 의존하지 않지만, C가 B를 의존하면 A에서도 B를 사용할 수 있는 문제가 생깁니다.
-
pnpm의 해결 방법
- pnpm은 node_modules/.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

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

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>
- 단계는 아래와 같습니다.
- 패키지 설치 및 하드 링크 생성
- 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에 저장됩니다.
- Symbolic Link 생성 및 종속성 연결
- 다음 단계로, express 모듈은 최상위 node_modules 폴더에 Symbolic Link 로 추가됩니다.
- express -> ./.pnpm/express@4.21.1/node_modules/express 와 같이 연결되어, 프로젝트가 직접 의존하고 있는 express를 쉽게 사용할 수 있게 됩니다.
- 의존성 Symbolic Link 구성
- express가 의존하고 있는 cookie 라이브러리는 .pnpm/express@4.21.1/node_modules 내에 생성된 Symbolic Link 를 통해 연결됩니다.
- 이 Symbolic Link는 cookie -> ../../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>
- 단계는 아래와 같습니다.
- 패키지 설치 및 각 패키지 별 피어 의존성 판별
- 패키지 설치 시, pnpm은 각 패키지의 피어 의존성을 판별합니다.
- styled-components의 경우, react와 react-dom을 피어 의존성으로 요구합니다.
- react-dom 역시 피어 의존성으로 react를 요구합니다.
- 설치된 패키지의 버전 조합을 통한 폴더 생성
- pnpm은 설치된 패키지의 버전 조합을 통해 피어 의존성을 가지는 패키지에 대한 폴더명을 생성합니다.
- 예시로 styled-components 의 경우 styled-components@6.1.13_react-dom@18.3.1_react@18.3.1__react@18.3.1 같은 형태로 폴더가 생성됩니다.
- 이는 해당 라이브러리가 피어 의존성을 가지는 다른 라이브러리들과 각각의 버전을 조합한 결과입니다.
- 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을 가리킵니다.
- 의존성 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)로 하드 링크 되어 있습니다.
- 같은 폴더 내에서 react와 react-dom은 각각 상위에서 설치된 버전을 가리키는 심볼릭 링크로 구성되어 있습니다.
- 이러한 구조는 모든 의존성이 정확한 버전을 참조하도록 보장하며, 설치된 패키지를 공유하여 중복을 줄임으로써 디스크 사용량을 절감하는 효과를 가집니다.
- 결론적으로, pnpm에서는 패키지 내 피어 의존성의 조합에 따라 별도의 폴더를 생성하기 때문에 각 라이브러리가 요구하는 버전에 맞는 패키지를 정확히 설치하도록 하고, 이를 공유하여 용량을 절감합니다.
5. CAS 에서 패키지를 관리하는 방식
5.1 CAS 에서 패키지을 저장하는 방법
- 패키지를 구성하는 파일 해싱 및 네이밍 부여
- 먼저 패키지를 구성하는 각 파일 별로 하위 리소스 무결성 (SRI) 의 암호로서 쓰이는 integrity 값을 가져옵니다.
- 이를 HEX 로 변환한 후 앞 두 자리는 디렉토리 이름으로, 나머지 문자열은 파일 이름으로 사용합니다.
- 아래 이미지는 Pnpm CAS 에 저장된 파일 중 일부를 캡쳐한 내용입니다.

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

cas index 파일의 일부이다.
- 패키지 별 인덱스 파일을 생성함으로서 얻는 이점은?
- 잠금 파일(lockfile) 내의 integrity 값이 올바른 패키지를 참조하는지 검증 가능
- lockfile은 설치된 패키지의 정확한 버전과 그 상태를 기록하여 동일한 환경을 재현할 수 있도록 합니다.
- 만약 Git 충돌 해결 과정에서 lockfile 의 integrity 값이 꼬이더라도, 이를 이용하여 잘못된 참조를 차단할 수 있습니다.
- 동일한 콘텐츠가 서로 다른 패키지나 같은 패키지의 여러 버전에서 참조될 수 있도록 합니다.
- 일부 레지스트리에서는 동일한 파일 내용을 여러 패키지 이름이나 다른 버전으로 배포할 수 있습니다.
- 인덱스 파일은 콘텐츠의 고유 해시 값을 사용하기에 동일한 콘텐츠가 여러 패키지나 버전에서 재사용되어도 괜찮습니다.
- 잠금 파일(lockfile) 내의 integrity 값이 올바른 패키지를 참조하는지 검증 가능
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 파일을 찾는 과정은 다음과 같습니다.
- 패키지 메타데이터 가져오기
- https://registry.npmjs.org/cookie/0.7.1 URL에 요청하여 패키지의 메타데이터를 가져옵니다.
- 이 메타데이터는 해당 패키지의 의존성, 배포 정보 등이 포함되어 있습니다.
- 무결성 값 확인 dist.integrity
- 패키지 메타데이터 에서 dist.integrity 값을 확인합니다.
- 예시로, 현재 패키지에서는 sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== 로 확인됐습니다.
- 무결성 값을 16진수로 변환
- dist.integrity 값을 Base64에서 16진수 (HEX) 로 변환합니다.
- 예시로 변환된 결과는 e839c89e9c7b489d802b7f824d413f64cd2f59351ba1909e831842db1888c9d1d1f6336e4c001206b7c4a4786218e670fe75f9d5b1ede98425f23b06a38cbfd3 입니다.
- 이 값은 이후 pnpm 에서 생성한 해당 패키지의 인덱스 파일이 위치한 경로를 찾는데 쓰입니다.
- 인덱스 파일의 디렉토리와 파일명 추적
- 변환된 16진수 값에서 앞의 두 자리를 디렉토리 명으로 사용합니다.
- 이후 나머지 문자열과 index.json을 결합하여 파일명을 만듭니다. (e.g e8/나머지 문자열-index.json)
- 인덱스 파일 정보 확인
- 인덱스 파일에는 패키지를 구성하는 각 파일의 정보와 패키지 이름, 버전 등이 포함되어 있습니다.
- 아래는 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 } } } - 인덱스 파일 내 각 파일 별 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
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은 다음과 같은 빌드 단계를 거칩니다.
- Builder 단계
- turbo prune --docker 명령어를 통해 타겟과 관련된 패키지들을 /out 디렉토리로 추출합니다.
- Installer 단계
- /json 폴더 내의 package.json 파일을 이용해 필요한 의존성을 설치합니다.
- 이때, /var/pnpm/store 를 pnpm store 디렉토리로 지정하고 Cache Mount 하는 전략을 사용합니다.
- 이후 turbo run build:prod 태스크를 실행하여 애플리케이션 빌드를 진행합니다.
- 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 이후에는 빌드된 dist 와 node_modules 폴더만 별도로 추출하면 되기에 괜찮을 것으로 보입니다.
7.5 pnpm deploy
- 그렇다면 위에서 언급한 해결방법인 pnpm deploy란 무엇일까요?
- 여러 애플리케이션이 혼재하는 모노레포에서, 특정 애플리케이션에 대해 독립적인 의존성 파일을 생성하려면 pnpm deploy 명령어를 사용하여 이를 분리할 수 있습니다.
- pnpm deploy 명령어는 대상 패키지의 모든 의존성 (워크스페이스 의존성 포함) 을 독립된 node_modules 디렉터리에 설치합니다.
- Deploy 환경 에서는 심볼릭 링크 대신 실제 의존성 파일들이 모두 .pnpm 디렉토리 내부에 복사되어 들어가며, 내부 패키지가 마치 외부 의존성인 것처럼 취급됩니다.
- 예를들어 아래와 같은 구조에서 app1이 pnpm 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)
- Turbo 및 pnpm 설치
- Builder 단계 - 소스 코드 준비
- COPY . . 를 통해 전체 프로젝트 복사
- turbo prune 실행, 해당 애플리케이션에 필요한 최소한의 파일만 남김
- Installer 단계 - 의존성 설치 및 빌드
- Turbo Prune 결과물에서 package.json 관련 정보 복사
- pnpm install 실행 (캐시를 활용하여 빠르게 설치)
- 전체 소스 코드 복사 후 빌드 실행 (pnpm run build:prod)
- pnpm deploy 실행, 의존성 패키지를 node_modules 내부에 심볼릭 링크 없이 복사
- Runner 단계 - 최종 실행 환경
- node_modules 및 dist 폴더 복사
- ENV NODE_ENV=production 설정
- EXPOSE 8080 설정 (포트 8080 개방)
- CMD ["node", "dist/main.js"] 실행 (애플리케이션 실행)
8. 마치며
- 이렇게 pnpm에 대해서 알아보고 Turborepo와 함께 모노레포를 빌드해보았는데요. 확실히 npm이나 yarn보다 러닝커브가 있는 것 같습니다.
- 하지만 기능들과 개념들을 살펴보면서 효율성을 위해서 많이 힘썼다는 것이 느껴졌습니다. 앞으로도 pnpm을 더 잘 쓰기 위해서 꾸준히 공부해야겠습니다.
