junyeokk
Blog
DevOps·2025. 11. 15

pnpm workspace

모노레포에서 여러 패키지를 관리할 때 가장 먼저 부딪히는 문제는 의존성이다. 패키지 A가 lodash@4.17.20을 쓰고, 패키지 B가 lodash@4.17.21을 쓰면 각각 별도로 설치해야 할까? 공통 의존성은 어떻게 공유할까? 내부 패키지끼리 참조할 때는 npm에 퍼블리시해야 할까?

npm이나 yarn classic으로 모노레포를 운영하면 이런 문제들이 하나씩 터진다. 특히 phantom dependency(유령 의존성) 문제가 치명적이다. node_modules의 호이스팅 때문에 package.json에 명시하지 않은 패키지를 import할 수 있고, 이게 배포 환경에서 갑자기 깨진다. pnpm workspace는 이 문제들을 구조적으로 해결한다.


pnpm이 다른 점: Content-Addressable Store

pnpm의 근본적인 차이는 패키지 저장 방식에 있다. npm과 yarn은 각 프로젝트의 node_modules에 패키지를 복사하지만, pnpm은 전역 Content-Addressable Store에 패키지를 한 번만 저장하고 심볼릭 링크로 연결한다.

text
~/.pnpm-store/
  v3/
    files/
      ab/cdef1234...  ← lodash의 실제 파일
      12/3456abcd...  ← react의 실제 파일

프로젝트/node_modules/
  .pnpm/
    lodash@4.17.21/node_modules/lodash → ~/.pnpm-store/...
  lodash → .pnpm/lodash@4.17.21/node_modules/lodash

이 구조의 장점은 두 가지다:

  1. 디스크 절약: 10개 프로젝트가 같은 lodash를 쓰면 실제 파일은 하나뿐이다. 나머지는 전부 하드 링크다.
  2. 엄격한 의존성: node_modules에는 package.json에 명시한 패키지만 심볼릭 링크된다. 명시하지 않은 패키지는 접근 자체가 불가능하다.

npm의 flat한 node_modules 구조에서는 require('lodash')가 성공하려면 lodash가 어딘가에 설치되어 있기만 하면 된다. 직접 의존하든, 다른 패키지가 끌어온 것이든 상관없다. 이게 phantom dependency 문제의 원인이다.

text
# npm의 flat node_modules
node_modules/
  express/
  body-parser/  ← express의 의존성인데 최상위에 호이스팅됨
  lodash/       ← 누군가의 의존성인데 아무나 import 가능

pnpm의 node_modules는 다르다. 각 패키지의 의존성은 .pnpm 디렉토리 안에서 개별적으로 관리되고, 최상위 node_modules에는 직접 의존하는 패키지만 링크된다. 이 구조 때문에 package.json에 없는 패키지를 import하면 즉시 에러가 난다.


Workspace 설정

pnpm workspace를 설정하는 건 간단하다. 프로젝트 루트에 pnpm-workspace.yaml 파일 하나만 만들면 된다.

yaml
packages:
  - apps/*
  - packages/*

이 설정은 apps/ 하위의 모든 디렉토리와 packages/ 하위의 모든 디렉토리를 workspace 패키지로 인식하겠다는 뜻이다. 각 디렉토리에 package.json이 있어야 한다.

일반적인 모노레포 구조는 이렇다:

text
my-monorepo/
├── pnpm-workspace.yaml
├── package.json          ← 루트 (private: true)
├── apps/
│   ├── admin/            ← @app/admin
│   ├── backend/          ← @app/backend
│   ├── kiosk-web/        ← @app/kiosk-web
│   └── kiosk-shell/      ← Electron 앱
└── packages/
    ├── ui/               ← @repo/ui (공유 컴포넌트)
    ├── eslint-config/    ← @repo/eslint-config
    └── typescript-config/ ← @repo/typescript-config

루트 package.json의 "private": true는 필수다. 루트 패키지를 실수로 npm에 퍼블리시하는 걸 방지한다.


workspace: 프로토콜

workspace 내 패키지끼리 참조할 때는 workspace: 프로토콜을 사용한다. 이게 pnpm workspace의 핵심이다.

json
{
  "name": "admin",
  "dependencies": {
    "@repo/ui": "workspace:*",
    "react": "^19.0.0"
  },
  "devDependencies": {
    "@repo/eslint-config": "workspace:*",
    "@repo/typescript-config": "workspace:*"
  }
}

workspace:*는 "같은 workspace에 있는 @repo/ui 패키지를 사용하겠다"는 의미다. npm 레지스트리에서 가져오는 게 아니라 로컬 패키지를 직접 참조한다.

버전 범위 지정

workspace: 뒤에 오는 값에 따라 동작이 달라진다:

json
{
  "@repo/ui": "workspace:*",      // 어떤 버전이든 OK (가장 일반적)
  "@repo/ui": "workspace:^1.0.0", // ^1.0.0 범위에 맞는 버전만
  "@repo/ui": "workspace:~1.0.0"  // ~1.0.0 범위에 맞는 버전만
}

모노레포 내부에서만 쓸 패키지라면 workspace:*로 충분하다. 패키지를 npm에 퍼블리시할 계획이 있다면 구체적인 버전 범위를 지정하는 게 좋다. 퍼블리시 시점에 workspace:^1.0.0은 실제 버전(예: ^1.2.3)으로 자동 변환된다.

퍼블리시 시 변환

pnpm은 패키지를 퍼블리시할 때 workspace: 프로토콜을 자동으로 변환한다:

text
workspace:*     → 현재 버전 (예: 1.2.3)
workspace:^1.0.0 → ^1.2.3 (실제 버전으로 대체)
workspace:~1.0.0 → ~1.2.3

이 덕분에 개발 중에는 로컬 참조의 편리함을 누리면서, 퍼블리시할 때는 정확한 버전 범위가 package.json에 들어간다.


의존성 설치와 관리

특정 패키지에 의존성 추가

bash
# apps/admin에 axios 추가
pnpm add axios --filter admin

# packages/ui에 개발 의존성 추가
pnpm add -D vitest --filter @repo/ui

# 루트에 개발 도구 추가
pnpm add -D prettier -w

--filter로 대상 패키지를 지정한다. -w (또는 --workspace-root)는 루트 package.json에 추가할 때 사용한다. 루트에는 보통 prettier, husky 같은 프로젝트 전체에서 쓰는 개발 도구를 설치한다.

내부 패키지 참조 추가

bash
# admin에서 @repo/ui를 의존성으로 추가
pnpm add @repo/ui --filter admin --workspace

--workspace 플래그를 붙이면 npm 레지스트리 대신 workspace에서 패키지를 찾아 workspace: 프로토콜로 추가한다.

전체 설치

bash
pnpm install

루트에서 pnpm install을 실행하면 모든 workspace 패키지의 의존성이 한 번에 설치된다. pnpm의 Content-Addressable Store 덕분에 중복 패키지는 한 번만 다운로드되고, 나머지는 링크로 처리된다.


.npmrc 설정

pnpm의 동작을 제어하는 설정 파일이다. 모노레포에서 자주 쓰는 설정들을 보자.

ini
# 피어 의존성 자동 설치
auto-install-peers = true

# 호이스팅 패턴 제어
shamefully-hoist = false     # true면 npm처럼 flat하게 호이스팅 (비추천)
public-hoist-pattern[] = *eslint*
public-hoist-pattern[] = *prettier*

# 엄격 모드
strict-peer-dependencies = false

auto-install-peers

React 생태계에서는 거의 필수 설정이다. 많은 라이브러리가 peerDependencies로 react를 요구하는데, pnpm은 기본적으로 피어 의존성을 자동 설치하지 않는다. 이 옵션을 켜면 피어 의존성을 자동으로 해결해준다.

shamefully-hoist

이름부터 "수치스러운 호이스팅"이다. pnpm의 엄격한 의존성 구조를 포기하고 npm처럼 flat하게 호이스팅한다. phantom dependency 문제가 다시 발생할 수 있으므로 꼭 필요한 경우가 아니면 쓰지 않는 게 좋다. 레거시 패키지가 pnpm의 심볼릭 링크 구조에서 동작하지 않을 때 임시 방편으로 사용한다.

public-hoist-pattern

특정 패키지만 선택적으로 호이스팅한다. ESLint, Prettier 같은 도구는 플러그인을 최상위 node_modules에서 찾는 경우가 있어서 이 설정이 필요할 수 있다.


ignoredBuiltDependencies와 onlyBuiltDependencies

네이티브 바이너리를 빌드하는 패키지들(sharp, electron, esbuild 등)은 설치 시 postinstall 스크립트를 실행한다. 이 빌드 과정이 느리거나 불필요한 경우가 있다.

yaml
# pnpm-workspace.yaml
ignoredBuiltDependencies:
  - '@ffprobe-installer/darwin-arm64'
  - electron
  - sharp
  - esbuild

onlyBuiltDependencies:
  - electron-winstaller
  - fs-xattr

ignoredBuiltDependencies는 빌드를 건너뛸 패키지 목록이다. CI에서 불필요한 플랫폼의 바이너리 빌드를 스킵할 때 유용하다. onlyBuiltDependencies는 반대로, 명시한 패키지만 빌드를 허용한다. 두 설정을 조합하면 설치 시간을 크게 줄일 수 있다.


--filter의 다양한 사용법

--filter는 pnpm workspace에서 가장 많이 쓰는 옵션이다. 특정 패키지를 대상으로 명령을 실행할 때 사용한다.

bash
# 패키지 이름으로 필터
pnpm --filter admin dev
pnpm --filter @repo/ui build

# glob 패턴
pnpm --filter "./apps/*" build    # apps 하위 모든 패키지 빌드
pnpm --filter "./packages/*" lint  # packages 하위 모든 패키지 린트

# 의존성 기반 필터
pnpm --filter "admin..." build    # admin과 admin이 의존하는 모든 패키지
pnpm --filter "...@repo/ui" build # @repo/ui와 @repo/ui에 의존하는 모든 패키지

# 변경된 패키지만
pnpm --filter "...[origin/main]" build  # main 대비 변경된 패키지만 빌드

의존성 기반 필터가 특히 강력하다. admin...은 admin 패키지와 그 의존 패키지를 모두 포함한다. admin이 @repo/ui를 의존하면 @repo/ui도 빌드된다. 반대로 ...@repo/ui는 @repo/ui에 의존하는 모든 패키지를 포함한다. @repo/ui를 수정했을 때 영향받는 패키지를 전부 빌드하고 싶을 때 유용하다.

[origin/main] 같은 git diff 기반 필터는 CI에서 변경된 패키지만 빌드할 때 효과적이다. 전체 빌드 대신 변경된 부분만 빌드해서 CI 시간을 줄인다.


스크립트 실행

루트에서 전체 실행

bash
# 모든 패키지의 build 스크립트 실행
pnpm -r build

# 모든 패키지의 test 스크립트 실행 (없으면 건너뜀)
pnpm -r --if-present test

-r (또는 --recursive)은 모든 workspace 패키지에서 스크립트를 실행한다. --if-present를 붙이면 해당 스크립트가 없는 패키지는 에러 없이 건너뛴다.

병렬 실행

bash
pnpm -r --parallel dev

--parallel은 모든 패키지의 dev 서버를 동시에 시작한다. 의존성 순서를 무시하고 병렬로 실행하므로, 빌드처럼 순서가 중요한 작업에는 쓰면 안 된다. dev 서버처럼 독립적으로 실행되는 long-running 프로세스에 적합하다.


Turborepo와의 조합

pnpm workspace는 패키지 관리와 의존성 해결을 담당하고, Turborepo는 빌드 오케스트레이션을 담당한다. 역할이 다르기 때문에 함께 사용하는 게 일반적이다.

json
// 루트 package.json
{
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint"
  }
}

pnpm의 -r build와 turbo의 turbo run build는 비슷해 보이지만 차이가 크다:

pnpm -r buildturbo run build
의존성 순서 빌드✅ (토폴로지 순서)✅ (dependsOn 기반)
캐싱✅ (콘텐츠 해시 기반)
병렬성제한적최대 병렬 실행
원격 캐시✅ (Vercel Remote Cache)

pnpm은 "어떤 패키지가 있고, 의존성이 뭔지"를 관리하고, Turborepo는 "어떤 순서로, 어떻게 효율적으로 빌드할지"를 관리한다. 두 도구가 겹치는 영역은 거의 없다.


npm, yarn과의 비교

npm workspaces

npm 7+에서 workspaces를 지원한다. package.json의 workspaces 필드로 설정한다.

json
{
  "workspaces": ["apps/*", "packages/*"]
}

npm workspace의 가장 큰 문제는 flat한 node_modules 구조다. 호이스팅 때문에 phantom dependency가 발생하고, 이게 개발 환경에서는 잘 되다가 프로덕션에서 터지는 최악의 상황을 만든다.

yarn workspaces (v1)

yarn classic도 workspaces를 지원하지만, npm과 같은 호이스팅 문제가 있다. yarn berry(v2+)는 Plug'n'Play(PnP)로 node_modules 자체를 없앴는데, 호환성 문제가 많아서 생태계 지원이 pnpm보다 뒤처진다.

왜 pnpm인가

pnpm을 선택하는 이유를 정리하면:

  1. 엄격한 의존성: phantom dependency 원천 차단
  2. 디스크 효율: Content-Addressable Store로 중복 없는 저장
  3. 설치 속도: 심볼릭 링크 기반이라 파일 복사가 없음
  4. 호환성: node_modules 구조를 유지하면서 엄격함을 더함 (yarn PnP처럼 생태계를 깨뜨리지 않음)
  5. workspace 프로토콜: workspace:*로 내부 패키지 참조가 명확함

실전 팁

루트 package.json에 engines 명시

json
{
  "packageManager": "pnpm@9.15.0",
  "engines": {
    "node": ">=20"
  }
}

packageManager 필드는 Corepack과 함께 쓰면 pnpm 버전을 팀 전체에서 통일할 수 있다. corepack enable을 실행하면 package.json에 명시된 버전의 pnpm을 자동으로 사용한다.

workspace에서 자주 하는 실수

1. workspace: 프로토콜 빼먹기

json
// ❌ npm 레지스트리에서 찾으려 함
"@repo/ui": "^1.0.0"

// ✅ workspace에서 찾음
"@repo/ui": "workspace:*"

내부 패키지인데 workspace:를 안 붙이면 npm 레지스트리에서 찾으려다 설치 실패가 난다.

2. 루트에서 직접 의존성 추가

bash
# ❌ 에러: -w 없이 루트에 설치 시도
pnpm add lodash

# ✅ 명시적으로 루트에 설치
pnpm add lodash -w

pnpm은 의도치 않게 루트에 패키지를 설치하는 걸 방지한다. -w 플래그로 명시적으로 루트를 지정해야 한다.

3. 패키지 이름 충돌

json
// packages/ui/package.json
// ❌ 너무 일반적인 이름
{ "name": "ui" }

// ✅ 스코프 사용
{ "name": "@repo/ui" }

@repo/ 같은 스코프를 사용하면 npm 레지스트리의 패키지와 이름이 충돌하지 않고, 내부 패키지임을 명확히 알 수 있다.


정리

  • pnpm의 Content-Addressable Store와 심볼릭 링크 구조가 phantom dependency를 원천 차단하고 디스크를 절약한다
  • workspace:* 프로토콜로 내부 패키지를 명시적으로 참조하며, 퍼블리시 시 실제 버전으로 자동 변환된다
  • --filter의 의존성 기반 필터(admin..., ...@repo/ui)와 git diff 필터([origin/main])로 CI 빌드 범위를 정밀하게 제어할 수 있다

관련 문서