pnpm 패키지 매니저: 심볼릭 링크 구조, 모노레포 워크스페이스, phantom dependency 해결

이 글의 핵심

pnpm은 패키지를 전역 저장소에 한 번만 두고 프로젝트에는 하드 링크로 연결해, 같은 버전을 여러 프로젝트가 써도 디스크에 한 벌만 남깁니다. 의존성을 선언한 패키지만 보이도록 node_modules를 구성해 phantom dependency를 막는 대신, 이를 가정한 오래된 도구에서는 호이스팅 설정이 필요합니다. 절감량과 속도는 프로젝트 구성과 캐시 상태에 따라 크게 달라지므로 이 글은 구조와 설정에 집중합니다.

이 글의 핵심

pnpm(performant npm)은 npm·yarn과 같은 package.json 생태계를 쓰면서, node_modules를 만드는 방식이 근본적으로 다릅니다. 패키지 바이트는 콘텐츠 주소 기반 전역 저장소에 한 번만 두며, 프로젝트 쪽은 하드 링크·심볼릭 링크로만 연결합니다. 그 덕에 설치가 빨라지고 디스크가 덜 사용되며, 직접 선언하지 않은 패키지를 import하는 유령 의존성(phantom dependency) 을 걸기 어려워집니다.

이 글에서는 구조를 이해한 뒤, Monorepo·.npmrc·CI 설정까지 실무에서 그대로 복사해 쓸 수 있는 수준으로 정리했습니다. 숫자는 공식 벤치마크나 제 환경에서의 체감을 함께 적었는데, 망·디스크·CPU에 따라 달라진다는 점만 먼저 짚고 갑니다.

목차에서 다루는 것

  • 심볼릭 링크와 콘텐츠 주소 저장소가 맞물리는 방식
  • npm·yarn(클래식)과의 성능·디스크 감각 (참고용 벤치마크)
  • pnpm-workspace로 Monorepo를 잡는 실전 예
  • 호이스팅 옵션이란 무엇인지, 언제 풀어야 하는지
  • peer dependencies·phantom dependencies로 터질 때
  • GitHub Actions 등 CI에서 캐시·락 파일 고정

pnpm이란?

pnpm은 2017년 Zoltan Kochan이 개발한 패키지 매니저이며, npm 레지스트리·package.json 문법·대부분의 CLI 플래그는 익숙한 그대로입니다. 다른 점은 의존성을 평면 한 겹의 node_modules에 “다 널브러뜨리지” 않는다는 쪽에 가깝습니다.

핵심만 정리

  • 디스크: 동일 머신에서 프로젝트를 여럿 쓰면, 같은 버전의 패키지는 전역 스토어에 파일이 한 벌만 존재하는 경우가 많습니다.
  • 속도: 네트워크로 받은 tarball은 스토어에 넣으며, 이후엔 링크·메타데이터 구성 비용이 중심이라 cold install이 반복될수록 이득이 큽니다.
  • 엄격함: package.json에 없는 패키지를 경로 꼼수로 끌어다 쓰기 어려워, 팀이 커질수록 “우연히 돌아가는 코드”를 줄입니다.

예전에 넣어 둔 다이어그램(개념용)

다음은 개념 설명용입니다. 실제 경로·버전·절약률은 프로젝트마다 달라집니다.

npm/yarn(평면적 설치):
node_modules/
├── react/           (한 프로젝트에 복사)
├── lodash/
└── express/

다른 프로젝트:
node_modules/
├── react/           (또 복사)
...

pnpm(전역 스토어 + 링크):
~/.local/share/pnpm/store  (또는 환경에 맞는 store 경로)
└── (콘텐츠 주소별로 보관)

프로젝트 node_modules/ 는 .pnpm 가상 스토어 + 링크 트리

pnpm의 핵심 아키텍처: 심볼릭 링크와 콘텐츠 주소 기반 저장소

콘텐츠 주소 기반 스토어(content-addressable store)

pnpm이 받아 온 패키지 tarball을 풀어 저장할 때, 이름+버전만이 아니라 내용의 해시에 가까운 키로 스토어에 둡니다. 따라서 같은 바이트면 레지스트리에 어떤 태그가 붙어 있든 한 번 내려받은 파일 덩어리를 재사용하기 쉽습니다. 로컬에 이미 있으면 다운로드 대신 링크만 잡는 일이 늘어납니다.

스토어 위치는 OS·설정에 따라 다르고, pnpm store path로 확인할 수 있습니다. 팀이 Docker나 CI 캐시를 쓸 때는 “이 경로를 통째로 캐싱해도 되는가”를 스토어 경로 기준으로 잡는 편이 안전합니다.

node_modules는 왜 이상해 보이는가

프로젝트의 node_modules 아래에는 .pnpm 디렉터리에 가상의 저장 구조가 있으며, 패키지마다 node_modules가 중첩된 형태로 자기 dependencies만 볼 수 있게 잠겨 있습니다. 그 위에 심볼릭 링크로 루트 node_modules에 “진입점”을 노출합니다.

이렇게 하면 A 패키지가 B를 쓰며, C도 B를 쓰더라도 B의 중복 설치를 피하면서, 동시에 A가 C의 B 버전에 실수로 의존하는 식의 평면형 트리 문제를 완화합니다. 익숙한 npm 트리와 달라서 처음엔 거슬리는데, 왜 pnpm이 엄격한지는 이 구조를 보면 이해됩니다.

하드 링크 vs 심볼릭 링크(실무 감각)

  • 하드 링크: 같은 inode를 가리키는 또 다른 경로. 디스크 상 바이트는 한 벌.
  • 심볼릭 링크: 다른 경로로 가는 “바로가기”. 윈도우에선 권한·개발자 모드·팀 정책에 따라 쓸 때 주의가 필요한 경우가 있습니다(대부분의 최신 툴체인은 문제없이 동작).

npm·yarn과의 성능 비교(참고 벤치마크)

공식 사이트와 커뮤니티에서 자주 인용하는 1000개 부근 패키지 시나리오에서, 대략 아래와 같은 순서가 나오는 경우가 많습니다(버전·OS·캐시 유무·레지스트리 지연에 따라 달라짐).

도구(대략)cold install(캐시 없음)캐시 warm
npm느린 편중간
Yarn classicnpm보다 나은 경우多중간~빠름
pnpm병렬·스토어 덕에 유리한 경우多매우 빠른 경우多

공식 벤치마크 페이지(pnpm.io/benchmarks)는 캐시·락 파일 유무 조합별로 npm·Yarn·pnpm의 설치 시간을 공개하고 있으니, 수치가 필요하면 그 표를 버전과 함께 인용하는 것이 정확합니다. 체감 속도는 캐시와 저장소가 이미 채워져 있는지에 따라 크게 달라집니다. 반대로 초기 pnpm 스토어가 비어 있는 CI에선, 한 번 캐시를 채우기 전에는 npm과 엇비슷하게 나오는 걸 봤습니다. 즉 “빠르다/느리다”는 캐시·네트워크·동시에 돌리는 잡을 같이 써야 이야기할 수 있습니다.

디스크

같은 머신에 비슷한 의존성을 쓰는 저장소가 여러 개 있으면, pnpm은 전역 스토어를 공유하므로 두 번째 저장소부터는 파일을 새로 쓰지 않고 하드 링크만 만듭니다. 반대로 프로젝트가 하나뿐이면 절감은 크지 않습니다. 하드 링크는 같은 파일 시스템 안에서만 만들 수 있어서, 스토어와 프로젝트가 다른 드라이브(예: Windows의 C:와 D:)에 있으면 pnpm은 파일을 복사하고 절감 효과가 사라집니다. pnpm store path로 스토어 위치를 확인하고, 필요하면 store-dir을 프로젝트와 같은 드라이브로 옮깁니다.


pnpm 설치

# npm으로 설치
npm install -g pnpm

# Homebrew (macOS)
brew install pnpm

# Standalone Script (권장)
curl -fsSL https://get.pnpm.io/install.sh | sh -

# Windows (PowerShell)
iwr https://get.pnpm.io/install.ps1 -useb | iex

# 버전 확인
pnpm --version

corepack enable 뒤 package.json의 packageManager 필드로 버전을 박아 두는 팀도 많습니다(아래 CI 절 참고).


기본 명령어

패키지 설치

pnpm install
pnpm i

pnpm add react react-dom
pnpm add -D typescript @types/react
pnpm add -g typescript
pnpm add [email protected]

제거·업데이트

pnpm remove react
pnpm remove -g typescript

pnpm update
pnpm update react
pnpm update --latest

스크립트

pnpm run dev
pnpm dev
pnpm run --parallel dev test

pnpm dlx는 예전 npx에 대응합니다. pnpm dlx create-vite 같은 식으로 쓰면 됩니다.


Monorepo 구축 실전: 디렉터리와 package.json

아래는 한 저장소에서 앱·공용 UI·공용 유틸을 나누는 가장 흔한 뼈대입니다. 이름은 팀 취향대로 바꾸면 됩니다.

acme/
├── package.json
├── pnpm-workspace.yaml
├── apps/
│   └── web/
│       ├── package.json   # "name": "@acme/web"
│       └── src/
├── packages/
│   ├── ui/
│   │   └── package.json   # "name": "@acme/ui"
│   └── utils/
│       └── package.json   # "name": "@acme/utils"

루트 package.json에는 공통 스크립트·공통 devDependencies·린트/포맷터를 몰아넣는 경우가 많습니다.

{
  "name": "acme",
  "private": true,
  "scripts": {
    "build": "pnpm -r run build",
    "dev:web": "pnpm --filter @acme/web dev"
  }
}

apps/web이 UI와 유틸을 쓰게 하려면:

{
  "name": "@acme/web",
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/utils": "workspace:*",
    "react": "^18.2.0"
  }
}

workspace:* 는 “이 모노레포 안에 있는 동일 이름 패키지의 현재 버전”으로 묶이므로, 팀 내부 패키지끼리 연결할 때 씁니다. 배포·버저닝 정책(Changesets 등)은 별도로 잡는 게 일반적입니다.

로컬에서 자주 쓰는 명령:

pnpm install
pnpm --filter @acme/web add dayjs
pnpm -r run build

pnpm-workspace.yaml 상세 설정

최소 형태는 이렇게 끝납니다.

packages:
  - 'apps/*'
  - 'packages/*'

글롭·예외

특정 폴더는 워크스페이스에서 빼고 싶다면 ! 를 씁니다.

packages:
  - 'packages/*'
  - '!packages/_template'

catalog(버전을 한곳에)

pnpm 8+ 에서 쓰는 팀은 pnpm-workspace.yaml에 catalog 를 두며, 여러 패키지가 같은 react 버전을 공유하도록 묶기도 합니다(정확한 키 문법은 사용 중인 pnpm 메이저 문서를 확인).

실전 팁

  • 깊이: apps/* 대신 apps/** 를 쓰지 않는 이유는, 빌드 산출물·임시 앱이 섞이면 package.json 없는 데를 스캔하다 실수한다는 경험 때문입니다. 구조를 얕게 유지하거나, 글롭을 좁힙니다.
  • 루트 private: 루트 package.json에 "private": true 를 넣는 습관은 npm/yarn 때와 같습니다. 실수로 공개될 일을 막습니다.

패키지 호이스팅(hoisting) 전략

pnpm의 기본은 “평면으로 다 올려 붙이지 않는다” 쪽에 가깝습니다. 그런데 낡은 툴이 node_modules/패키지이름 을 직접 찾는 경우, pnpm에선 깨질 수 있습니다. 그때 쓰는 완충이 호이스팅 관련 옵션입니다.

shamefully-hoist

.npmrc에 다음을 켜면, npm에 가깝게 끌어올려 올려 줍니다.

shamefully-hoist=true

이름이 “부끄럽다”는 뜻인 이유는, 유령 의존성이 다시 느슨해질 수 있기 때문입니다. “당장은 빌드가 살고, 팀이 시간 날 때 import를 정리하자” 식의 이행 단계로 쓰는 경우를 봤습니다.

public-hoist-pattern

특정 패턴만 루트로 올리고 싶다면 이것이 낫습니다.

public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

ESLint·Prettier·PostCSS가 내부에서 서브 패키지를 require하는데 경로를 못 찾는 류의 이슈에 타깃만 쓰면 됩니다.

node-linker

node-linker=hoisted

완전 npm 스타일에 가깝게 올리고 싶다면(레거시 호환 최우선), hoisted 를 쓰는 팀도 있습니다. pnpm의 장점(엄격한 격리)이 줄어드는 trade-off 는 팀이 합의해야 합니다.


실무 트러블슈팅: peer dependencies와 phantom dependencies

peer dependency가 쌓이며 설치가 실패할 때

React·TypeScript·ESLint 같이 “같이 깔아야 peer가 만족”하는 조합이면, pnpm이 엄하게 잡아줍니다. .npmrc에 다음을 켜는 팀이 많습니다(반대로 엄격 모드를 끄는 팀도 있어서 팀 룰과 맞출 것).

auto-install-peers=true

그래도 안 되면, 어느 패키지가 peer를 요구하는지를 보려고 pnpm why <패키지> 를 먼저 칩니다. 루트에 react 를 올릴지, UI 패키지 쪽에 둘지는 Monorepo 정책 문제라서, 한 번 합의해 두면 이후 PR이 조용해집니다.

phantom dependency(유령 의존성)

package.json에 없는데, 예전엔 import "lodash" 가 됐다 — 이런 코드는 pnpm에선 깨질 수 있습니다. 이건 pnpm이 나쁜 게 아니라, 원래 잘못 짠 코드가 드러난 케이스에 가깝습니다.

대응은 단순합니다. 의존성에 명시합니다.

pnpm add lodash
# 또는
pnpm --filter @acme/web add lodash

팀이 많을수록, 이것을 “버그”가 아니라 품질 게이트로 받아들이는 편이 덜 힘듭니다.

흔한 이슈: 바이너리가 node_modules/.bin 밖을 본다

스크립트·플러그인이 상대 경로로 깨지면, public hoist나 shamefully-hoist 를 최소 범위로 잠깐 켜서 원인을 좁힌 뒤, 근본적으로는 플러그인/설정을 고칩니다. 저는 PostCSS 7/8 뒤엉킨 늙은 툴체인에서 이 패턴을 본 기억이 있습니다.


CI/CD 최적화 팁

락 파일 고정

CI에서는 의도하지 않은 버전 떠다니기를 막기 위해 frozen-lockfile 를 켭니다.

pnpm install --frozen-lockfile

GitHub Actions 예(액션·캐시)

pnpm/action-setup 과 actions/setup-node의 cache: pnpm 조합이 흔합니다. 중요한 건 락 파일(pnpm-lock.yaml)을 커밋하며, 워크플로에 Node 버전을 로컬과 맞추는 것입니다.

- uses: pnpm/action-setup@v4
  with:
    version: 9
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build

Docker

멀티 스테이지 빌드에서 pnpm install 앞뒤로 락 파일만 먼저 COPY해 캐시 레이어를 쪼개는 패턴이 효율적입니다. 스토어 캐시를 이미지 밖에 마운트하는 팀도 있습니다(러너/플랫폼에 따라).

corepack

Node 20+ 환경이면 corepack enable 후 package.json에:

"packageManager": "[email protected]"

처럼 고정해 두면, 개발자·CI가 같은 pnpm을 쓰기 쉬워집니다.


npm → pnpm 마이그레이션 순서

저장소를 옮길 때 보통 거치는 순서는 아래와 같습니다.

  1. node_modules와 package-lock.json 또는 yarn.lock 을 제거합니다.
  2. pnpm import (가능한 경우)로 락을 옮기거나, 그냥 pnpm install 로 pnpm-lock.yaml 을 새로 만듭니다.
  3. 첫 pnpm build에서 터지면, 대부분 phantom import이거나 peer이었다. package.json에 누락 dep를 채웠다.
  4. 여전히 낡은 webpack 플러그인이 루트 node_modules만 본다면, public-hoist-pattern으로 최소한만 끌어올려서 빌드 통과 → 여유될 때 플러그인 교체나 node_modules 직접 탐색 제거.
  5. CI에 frozen-lockfile + cache: pnpm 적용. 첫 파이프라인은 캐시가 비어 있어 느릴 수 있으니, 두 번째 PR부터 체감을 본다.

구형 Storybook·PostCSS·ESLint 플러그인 체인처럼 루트 node_modules에 모든 게 평평하게 있다고 가정하는 도구에서 “모듈을 못 찾는다”가 나면, 원인은 의존성 누락이 아니라 이 호이스팅 가정인 경우가 많습니다. public-hoist-pattern에 해당 패키지(예: *postcss*)만 넣어 당장 통과시키고, 도구를 올릴 때 옵션을 걷어내는 식으로 단계적으로 옮기는 것이 안전합니다. 이런 단계적 이행이 리얼한 마이그레이션의 상당 부분을 차지합니다.

package.json에 npm 사용을 막는 스크립트는 여전히 유용합니다.

{
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}

(위 JSON은 package.json에 주석이 없다고 가정한 예시입니다. 주석이 필요하면 별도 문서에 적습니다.)

CI/CD 예시(이전에 문서에 있던 형태, 버전은 프로젝트에 맞게)

name: CI

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: pnpm
      - run: pnpm install
      - run: pnpm test
      - run: pnpm build

고급 기능: overrides, patch, Turborepo

하위 의존성 버전 강제(overrides) — 보안 패치된 버전으로 전이 의존성을 올려야 할 때 씁니다.

{
  "pnpm": {
    "overrides": {
      "foo": "^1.0.0",
      "bar@^2.1.0": "3.0.0",
      "baz>qux": "^1.0.0"
    }
  }
}

"baz>qux"는 “baz가 의존하는 qux만” 바꾸는 선택자입니다. 전체를 바꾸는 "qux"와 구분해서 쓰고, 적용 후에는 pnpm why qux로 실제로 해결된 버전을 확인합니다.

node_modules 패치(patch) — 라이브러리 버그를 포크 없이 임시로 고칠 때 씁니다.

pnpm patch [email protected]          # 수정용 임시 디렉터리 경로를 출력
# 그 디렉터리에서 파일 수정 후
pnpm patch-commit <출력된 경로>     # patches/[email protected] 생성 + package.json에 등록

패치는 정확한 버전에 묶이므로, 라이브러리를 올리면 패치가 적용되지 않거나 충돌합니다. 업스트림에 수정이 들어가면 패치를 지우는 것을 잊지 않도록 이슈 링크를 패치 파일 옆에 남겨 둡니다.

Turborepo와 함께 쓰기 — pnpm 워크스페이스는 패키지 설치·링크를, Turborepo는 작업 순서와 캐시를 맡습니다.

{
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
    "test":  { "dependsOn": ["build"] },
    "dev":   { "cache": false, "persistent": true }
  }
}

Turborepo 2부터 turbo.json의 최상위 키가 pipeline에서 tasks 로 바뀌었습니다. 인터넷의 예제 대부분은 v1 형식(pipeline)이라 그대로 복사하면 v2에서 설정 오류가 나므로, 버전을 확인하고 npx @turbo/codemod migrate로 옮깁니다. ^build는 “내가 의존하는 워크스페이스 패키지의 build를 먼저”라는 뜻입니다.

.npmrc 설정(업데이트)

# 엄격한 peer(팀에 따라 off)
strict-peer-dependencies=true
auto-install-peers=true

# 격리 유지(기본에 가깝게)
shamefully-hoist=false

# 필요 시: 특정 툴 체인만
# public-hoist-pattern[]=*eslint*

registry=https://registry.npmjs.org/
loglevel=info

같이 보면 좋은 글