aster®

pnpm workspaces로 모노레포 시작하기

2026-07 · 모노레포

빈 윤곽선 상자 다섯이 가운데 채워진 상자 하나를 향해 각각 가는 화살표를 뻗고 있다. 어느 상자도 그 내용을 제 안에 복사해 두지 않았다.

앞 편 모노레포에서 pnpm과 Turborepo가 맡는 역할 에서 "요즘 흔한 조합"으로 pnpm workspaces + Turborepo + Changesets 를 꼽았다. 여기서는 빈 폴더에서 설정을 시작한다. pnpm dlx create-turbo@latest 를 돌리면 파일이 한 벌 쏟아지기는 한다. 생성된 파일의 역할을 이해하려면 설정을 하나씩 확인해야 한다.

세 도구는 역할이 겹치지 않는다. pnpm workspaces 는 패키지를 연결하고 설치하는 기반이고, Turborepo 는 태스크를 캐싱·병렬로 돌려 빌드를 빠르게 하고, Changesets 는 버전업·changelog·publish 를 맡는다. 먼저 패키지를 연결하는 기반부터 설정한다.

이 글은 빈 폴더에서 시작해 앱이 공용 패키지를 import 해서 쓰는 데까지를 손으로 만든다. 빌드 캐시와 릴리즈·배포는 뒤편인 Turborepo와 Changesets로 빌드·릴리스 자동화하기 로 넘긴다.

⚡ 빠른 길: pnpm dlx create-turbo@latest 하면 아래 대부분이 예제와 함께 스캐폴딩된다(세 번째 Button 폴더를 복사하다 index.ts 를 빠뜨렸다). 아래는 "무엇이 왜 필요한지" 알아보려고 일부러 손으로 까는 과정이다.


1. pnpm 버전 고정하기

첫 명령이 pnpm init 이 아니라 corepack 이라는 게 낯설 수 있다. 패키지 매니저를 까는 것도 아니고 버전을 고정하는 일이라, 혼자 쓰는 폴더에 왜 필요한지가 잘 안 보인다.

이유는 락파일이다. pnpm 은 버전마다 pnpm-lock.yaml 을 쓰는 방식이 조금씩 다르다. 사람마다 다른 pnpm 으로 설치하면 같은 커밋에서 락파일이 서로 다르게 갱신된다. 그래서 처음에 한 번 못 박는다.

# corepack으로 pnpm 버전 고정 (Node 22 기준)
corepack enable
corepack prepare pnpm@9.15.0 --activate
node -v   # v22.x

node -v 가 v22.x 로 나오면 다음으로 간다. 여기서 다른 메이저 버전이 나오면 아래 engines 설정에 걸려서 나중에 설치가 막힌다.

2. 루트 package.json 설정

mkdir my-monorepo && cd my-monorepo
git init
pnpm init

루트 package.json — private: true 필수(루트는 절대 publish 안 함), packageManager 로 pnpm 버전 못박기:

{
  "name": "my-monorepo",
  "private": true,
  "packageManager": "pnpm@9.15.0",
  "engines": { "node": ">=22" },
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test",
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "turbo run build --filter=./packages/* && changeset publish",
  },
}

scripts 에 있는 turbo 와 changeset 은 아직 깔지도 않았다. 뒤편에서 쓸 것들이라 지금은 그냥 적어만 두고 넘어가면 된다. 지금 이 편에서 값어치가 있는 건 위쪽 네 줄이다.

private: true 는 빠뜨리기 쉬운데 빠뜨리면 이슈가 생긴다. 루트는 코드가 없는 껍데기인데, private 가 아니면 나중에 릴리즈 도구가 이걸 npm 에 올릴 대상으로 본다. packageManager 는 corepack 이 읽는 줄이다. 이 줄이 있으면 다른 사람이 이 저장소에 들어와 pnpm 을 쳤을 때 corepack 이 알아서 9.15.0 을 쓴다.

3. 워크스페이스에 포함할 경로 지정

루트에 pnpm-workspace.yaml:

packages:
  - "apps/*"
  - "packages/*"

# (pnpm 9.5+) catalog: 의존성 버전을 한곳에서 통일
catalog:
  react: ^19.0.0
  react-dom: ^19.0.0
  typescript: ^5.8.0

이 파일이 하는 일은 하나다. 어떤 폴더를 멤버로 인정할지 정한다. apps/* 와 packages/* 아래에 package.json 이 있는 폴더만 pnpm 이 워크스페이스의 일원으로 본다. 목록에 없는 폴더는 그냥 남의 폴더다.

폴더 골격은 이렇게 잡는다.

my-monorepo/
├── apps/
│   └── web/            # Next.js 앱 (배포 대상)
├── packages/
│   ├── ui/             # 공용 컴포넌트 (npm publish 대상)
│   ├── utils/          # 공용 유틸 (publish 대상)
│   └── tsconfig/       # 공용 설정 (내부 전용)
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

apps 와 packages 를 가르는 기준은 "배포되는 방식"이다. apps 아래 것은 사이트나 서버로 떠서 사람이 접속하는 것이고, packages 아래 것은 다른 코드가 불러다 쓰는 것이다. 이 구분이 뒤편의 배포 절에서 그대로 이어진다.

4. 공용 설정 패키지 만들기

packages/ui/package.json — 내부 참조용 스코프 이름 @repo/* 관례:

{
  "name": "@repo/ui",
  "version": "0.0.0",
  "main": "./src/index.ts", // 내부는 소스 직접, 배포 시 dist로
  "types": "./src/index.ts",
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "lint": "eslint .",
    "test": "vitest run",
  },
  "peerDependencies": { "react": "catalog:" },
  "devDependencies": { "tsup": "^8", "typescript": "catalog:" },
}

@repo 는 npm 에 등록된 조직 이름이 아니다. 모노레포 안에서만 통하는 이름표라 아무거나 붙여도 되는데, 다들 @repo 를 쓰니까 따라 쓴다.

react 가 dependencies 가 아니라 peerDependencies 에 있는 게 눈에 걸리는 자리다. 공용 컴포넌트 패키지는 React 를 자기가 들고 오면 안 된다. 자기를 쓰는 쪽이 이미 들고 있는 React 를 빌려 쓴다는 뜻으로 peer 에 적는다. 여기서 dependencies 로 적으면 React 가 두 벌 설치되는 버그로 이어진다. 이건 아래 심링크 절에서 다시 나온다.

packages/tsconfig 는 이 목록에서 제일 낯선 자리다. 설정 파일을 패키지로 만든다는 발상 자체가 잘 떠오르지 않는다. 설정 자체를 패키지로 만들어 공유하면, 각 패키지 tsconfig.json 이 "extends": "@repo/tsconfig/base.json" 한 줄로 통일된다. 컴포넌트만 패키지가 되는 게 아니라 규칙도 패키지가 될 수 있다.

5. 앱에서 로컬 패키지 가져오기

apps/web/package.json — 로컬 패키지는 workspace:* 프로토콜로 참조:

{
  "name": "@repo/web",
  "private": true, // ← 앱은 publish 안 하므로 private
  "dependencies": {
    "@repo/ui": "workspace:*", // 로컬 심링크로 연결됨
    "next": "^15",
    "react": "catalog:",
    "react-dom": "catalog:",
  },
}
pnpm install   # 전 패키지 의존성 설치 + 로컬 링크 한 번에

이제 apps/web 에서 import { Button } from '@repo/ui' 가 npm publish 없이 즉시 동작한다.

설치가 끝났으면 확인할 것은 두 개다. 루트에 pnpm-lock.yaml 이 하나만 생겼는지, 그리고 apps/web/node_modules/@repo/ui 가 실제 폴더가 아니라 심링크인지. 두 번째는 ls -l apps/web/node_modules/@repo/ 로 보면 화살표로 packages/ui 를 가리키고 있다. 저게 안 걸렸으면 pnpm-workspace.yaml 의 packages: 목록에 폴더가 안 잡힌 것이다.


여기까지가 "돌아가게 만드는" 부분이다. 다만 이 상태로는 왜 되는지가 설명되지 않는다. 아래는 pnpm install 이 안에서 무슨 일을 했는지를 뜯어본다.

6. 워크스페이스와 workspace 프로토콜

workspace는 문맥에 따라 프로젝트 전체를 가리키기도 하고, 그 안의 패키지 하나를 가리키기도 한다.

의미가리키는 것예
the workspace (단수·전체)모노레포 프로젝트 전체"이 workspace는 pnpm으로 관리해"
a workspace (개별 멤버)그 안의 개별 패키지 하나"@repo/ui는 하나의 workspace야"

표 1. workspace 라는 낱말이 가리키는 두 가지

하나의 workspace(전체) 안에 여러 workspace(멤버 패키지)가 든 구조라고 보면 말이 맞아떨어진다. 그리고 pnpm-workspace.yaml 의 packages: 목록이 "어떤 폴더를 멤버로 인정할지" 정하는 자리다.

7. pnpm install의 처리 순서

루트에서 install 하면 개별 패키지가 아니라 전체 workspace 를 한꺼번에 처리한다.

  1. 멤버 발견 — pnpm-workspace.yaml 을 읽어 멤버 폴더 파악
  2. 그래프 구성 — 모든 멤버 package.json 을 읽어 전체 의존성 그래프를 하나로 합침 (외부 + 내부 workspace:* 모두)
  3. 단일 락파일 — 전체를 해석해 루트에 pnpm-lock.yaml 딱 하나 생성 → 멤버 간 버전 정합성 보장
  4. 전역 스토어 1회 다운로드 — 실체는 ~/.pnpm-store(전역)에 한 번만 저장. 여러 프로젝트가 같은 버전 써도 디스크엔 하나
  5. 하드링크 + 심링크 배치 — 각 멤버 node_modules 를 심링크로 엮음

3번이 이 구조의 요점이다. 락파일이 하나라서 멤버들이 서로 다른 버전을 붙잡고 있을 수가 없다. 멤버마다 락파일이 따로 있으면 A 패키지는 React 19.0.0, B 패키지는 19.0.2 를 쓰는 상태가 조용히 생긴다.

8. node_modules와 심볼릭 링크

packages/ui의 변경이 apps/web에 반영되는 것은 pnpm이 파일을 복사해서가 아니라 심볼릭 링크로 연결하기 때문이다. 실제 폴더 구조는 다음과 같다.

my-monorepo/
├── node_modules/
│   └── .pnpm/                         ← 가상 스토어: 실제 패키지들 (전역 스토어에 하드링크)
│       ├── react@19.0.0/
│       └── next@15.1.0/
├── pnpm-lock.yaml                     ← 락파일 단 하나 (전체 workspace 공유)
│
├── apps/web/
│   └── node_modules/
│       ├── @repo/ui   ──심링크──▶  ../../../packages/ui   ★ 로컬 소스로 직접 연결
│       ├── next        ──심링크──▶  ../../../node_modules/.pnpm/next@15.1.0/...
│       └── react       ──심링크──▶  ../../../node_modules/.pnpm/react@19.0.0/...
└── packages/ui/
    └── node_modules/
        └── react       ──심링크──▶  ../../../node_modules/.pnpm/react@19.0.0/...

★ 표시가 핵심. apps/web/node_modules/@repo/ui 는 복사본이 아니라 packages/ui 소스 폴더를 가리키는 심링크다. 폴더를 하나 더 만든 게 아니라 바로가기를 하나 걸어 둔 것이다. 그래서 이렇게 된다.

마지막 줄이 앞에서 peerDependencies 를 쓴 이유다. @repo/ui 가 React 를 자기 dependencies 로 들고 오면 web 이 보는 React 와 ui 가 보는 React 가 다른 실체가 된다. 그 상태에서 훅을 쓰면 Invalid hook call 이 뜨는데, 이 에러 메시지만 봐서는 원인이 설치 구조에 있다는 걸 알아채기가 어렵다.

9. 배포 시 workspace 의존성 변환

"dependencies": { "@repo/ui": "workspace:*" }

뜻은 이렇다. "npm 레지스트리에서 찾지 말고 모노레포 안의 로컬 @repo/ui 를 심링크로 써라."

표기의미
workspace:*로컬 버전 그대로
workspace:^ / workspace:~로컬 버전 기준 caret/tilde 범위

표 2. workspace 프로토콜 표기 두 가지

여기서 한 가지가 걸린다. workspace:* 는 pnpm 안에서만 통하는 표기인데, 이 패키지를 npm 에 올리면 밖에 있는 사람은 그걸 해석할 수가 없다. 그래서 publish 시 pnpm 이 workspace:* 를 실제 버전으로 자동 치환한다.

개발 중:   "@repo/ui": "workspace:*"
   │  (publish 시 자동 변환)
   ▼
npm 배포본: "@repo/ui": "1.4.0"     ← 외부 사용자가 받는 것

덕분에 개발 중에는 심링크로 편하게 쓰고, 밖으로 나가는 배포본에는 실제 버전이 박혀 나간다.

10. 패키지 간 버전 불일치 처리

멤버마다 "react": "^19" 를 따로 적으면 하나만 어긋나도 React 가 두 벌 설치된다. 위에서 본 Invalid hook call 이 여기서도 나온다. catalog: 로 막는다.

# pnpm-workspace.yaml
catalog:
  react: ^19.0.0
// 각 멤버
"dependencies": { "react": "catalog:" }

버전의 단일 출처다. catalog: 한 줄만 고치면 전 멤버에 반영된다. 이것도 publish 할 때 실버전으로 치환된다. 다만 pnpm 9.5 이상이어야 쓸 수 있어서, 그보다 낮은 버전을 쓰고 있으면 이 절은 건너뛰고 멤버마다 버전을 직접 적어야 한다.

11. 특정 패키지에 명령 실행하기

pnpm --filter @repo/web build       # web만
pnpm --filter @repo/web... build    # web + web이 의존하는 것들(하류)
pnpm --filter ...@repo/ui build     # ui + ui에 의존하는 것들(상류) → ui 고쳤을 때 영향받는 것만

...(점 3개) 방향이 헷갈리는데 규칙은 단순하다. pkg... = pkg 와 그 의존성, ...pkg = pkg 와 그것에 의존하는 것들. 점이 붙은 쪽으로 따라간다고 외우면 된다.

세 번째 줄이 실무에서 제일 자주 쓰인다. 공용 패키지를 고쳤을 때 "그럼 뭘 다시 빌드해야 하지" 를 사람이 세지 않아도 되니까.

12. pnpm을 선택한 이유

pnpmnpm/yarn classicyarn berry(PnP)
node_modules심링크 + .pnpm(비평탄)호이스팅(평탄)node_modules 없음
유령 의존성차단허용(호이스팅 부작용)차단
디스크하드링크로 최소중복zip 캐시

표 3. 패키지 매니저 세 가지의 node_modules 구조 비교

엄격함(유령 의존성 차단) + 디스크 효율 두 가지다. 유령 의존성은 package.json 에 안 적었는데도 import 가 되는 상태를 말한다. npm 은 의존성을 평탄하게 펴서 node_modules 최상단에 몰아 놓기 때문에, 적어 두지 않은 패키지도 거기 있으면 그냥 불러진다. 되니까 그대로 쓰다가, 나중에 그 패키지가 빠지는 날 갑자기 깨진다.

pnpm 은 각 패키지가 자기가 적은 것만 볼 수 있게 심링크를 건다. 그래서 처음 pnpm 으로 옮기면 잘 되던 import 가 안 되는 일이 생긴다. 이건 pnpm 이 새로 만든 문제가 아니라 원래 잘못돼 있던 걸 이제 알려 주는 것이다.

워크스페이스 운영 시 주의점

이 구조를 쓰면 감수할 것들이 있다.

13. 앱과 공용 패키지 연결 결과

pnpm install 한 번이 멤버를 찾아 그래프를 합치고 락파일 하나를 만들고 심링크를 걸어 준다. import 가 되는 이유는 복사가 아니라 바로가기다.

그래서 지금 이 상태에서는 공용 컴포넌트를 고치면 앱에 즉시 반영된다. 아직 없는 것은 빌드를 빠르게 하는 장치와, 이 패키지들을 밖으로 내보내는 경로다. 그건 뒤편 Turborepo와 Changesets로 빌드·릴리스 자동화하기 에서 이어 만든다.

관련 글