pnpm 모노레포 실전 — npm/yarn에서 전환한 이유

게시일: 2025년 6월 17일 · 13분 읽기

node_modules가 20GB를 차지할 때

모노레포의 node_modules가 20GB에 달했다. 깃 클론을 받는데 5분, 의존성 설치에 10분. npm install할 때마다 디스크 I/O 때문에 컴퓨터가 반응 불능 상태가 된다. 이건 개발 환경이 아니라 악몽이었다.

그때 pnpm으로 전환했다. 같은 모노레포가 4GB가 됐다. 설치 시간도 3분으로 단축됐다. 이제 pnpm 없이는 못 산다.

pnpm vs npm/yarn 비교

세 가지의 근본적인 차이:

<?xml version="1.0"?>
// npm/yarn의 방식: 중복 설치
node_modules/
  react/
    package.json
    dist/
  app1/
    node_modules/
      react/  (같은 버전인데 또 복사됨)
  app2/
    node_modules/
      react/  (또 복사됨)

// pnpm의 방식: 심링크
node_modules/
  .pnpm/
    react@18.2.0/
      node_modules/
        react/
  react -> .pnpm/react@18.2.0/node_modules/react  (심링크)

pnpm은 같은 버전의 패키지는 한 번만 저장하고 심링크로 연결한다. 따라서 디스크 공간을 엄청 절약할 수 있다.

pnpm workspace 설정

모노레포 설정은 간단하다:

<?xml version="1.0"?>
// pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

// apps/web/package.json
{
  "name": "@myapp/web",
  "version": "1.0.0",
  "dependencies": {
    "@myapp/ui": "workspace:*",
    "react": "^18.2.0"
  }
}

// apps/api/package.json
{
  "name": "@myapp/api",
  "version": "1.0.0",
  "dependencies": {
    "@myapp/types": "workspace:*",
    "express": "^4.18.0"
  }
}

// packages/ui/package.json
{
  "name": "@myapp/ui",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0"
  }
}

"workspace:*"는 같은 워크스페이스 내의 패키지를 참조한다는 뜻이다. 빌드 시에는 실제 버전으로 변환된다.

의존성 설치 및 빌드

pnpm의 주요 명령어들:

<?xml version="1.0"?>
# 전체 워크스페이스 설치
$ pnpm install

# 특정 패키지에 의존성 추가
$ pnpm --filter @myapp/web add react

# 모든 패키지에서 테스트 실행
$ pnpm -r test

# 특정 패키지에서만 빌드
$ pnpm --filter @myapp/web build

# 의존성이 있는 순서대로 실행 (토폴로지 정렬)
$ pnpm -r --filter ...@myapp/web build

# 변경된 패키지만 빌드
$ pnpm --filter '...{./packages/**}[HEAD~1]' build

-r 플래그는 "recursive"를 의미한다. 모든 패키지에 명령을 적용한다.

Changeset로 버전 관리

여러 패키지의 버전을 일관되게 관리하려면 changeset을 쓴다:

<?xml version="1.0"?>
# changeset 초기화
$ pnpm exec changeset init

# 변경사항 기록
$ pnpm exec changeset

# 프롬프트가 나타남:
# - 영향받은 패키지 선택
# - 변경 타입 선택 (patch, minor, major)
# - 변경 설명 작성

# 결과: .changeset/[random-name].md 파일 생성
# 내용:
# ---
# '@myapp/web': minor
# '@myapp/ui': patch
# ---
#
# Add dark mode toggle to UI components

# PR이 merge 전에 changeset들을 모아서 버전 업데이트
$ pnpm exec changeset version

# 실제 배포
$ pnpm exec changeset publish

이 방식은 semantic versioning을 자동으로 따르고, 각 패키지의 CHANGELOG도 자동 생성된다.

성능 최적화

pnpm의 성능을 더 끌어내기:

<?xml version="1.0"?>
// .npmrc
strict-peer-dependencies=false
prefer-frozen-lockfile=true
enable-pre-post-scripts=false

# 설치 속도 향상
shamefully-hoist=false  # pnpm의 엄격한 방식 유지
hoist-pattern=[]       # 의존성 최소화

# CI 최적화
frozen-lockfile=true   # lockfile 변경 금지

Turborepo와 함께 사용

pnpm + Turborepo는 최강 조합이다:

<?xml version="1.0"?>
// turbo.json
{
  "extends": ["//"],
  "globalDependencies": ["pnpm-lock.yaml"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "outputs": []
    },
    "lint": {
      "outputs": []
    }
  },
  "remoteCache": {
    "signature": true,
    "teamId": "..."
  }
}

// package.json scripts
{
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint"
  }
}

Turborepo는 빌드 결과를 캐싱해서, 변경되지 않은 패키지는 다시 빌드하지 않는다.

실전 예제: 모노레포 구조

<?xml version="1.0"?>
my-app/
  pnpm-workspace.yaml
  package.json
  pnpm-lock.yaml
  turbo.json
  .github/
    workflows/
      ci.yml
  apps/
    web/
      package.json
      src/
    api/
      package.json
      src/
    admin/
      package.json
      src/
  packages/
    ui/
      package.json
      src/
      dist/
    types/
      package.json
      src/
    utils/
      package.json
      src/

마이그레이션 팁

npm/yarn에서 pnpm으로 전환할 때:

성과

마무리

pnpm은 단순한 패키지 매니저가 아니라, 모노레포를 관리하는 완전히 새로운 방식이다. 개발자 경험이 크게 향상되고, CI 시간도 줄어든다. 모노레포를 진지하게 생각한다면, pnpm은 필수다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.