앞선 글 turbo.json 을 지우면 모노레포가 안 돌아갈까 에서 “요즘 흔한 조합”으로 pnpm workspaces + Turborepo + Changesets를 꼽았다. 도구를 골랐어도 빈 폴더에서 무엇부터 설정해야 하는지는 알기 어렵다. pnpm dlx create-turbo@latest를 돌리면 파일이 한 벌 쏟아지기는 한다. 쏟아진 파일이 왜 그렇게 생겼는지는 여전히 안 보인다.
세 도구는 역할이 겹치지 않는다. pnpm workspaces는 패키지를 연결하고 설치하는 기반이고, Turborepo는 태스크를 캐싱·병렬로 돌려 빌드를 빠르게 하고, Changesets는 버전업·changelog·publish를 맡는다. 손대는 순서는 기반부터다.
빈 폴더에서 시작해 앱이 공용 패키지를 import해서 사용하는 데까지 직접 설정해 본다. 빌드 캐시와 릴리즈·배포는 뒤편인 안 바뀐 패키지까지 매번 다시 빌드하고 있었다 로 넘긴다.
⚡ 빠른 길:
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 라는 말이 두 가지를 가리킨다
문서를 읽을 때 제일 헷갈리는 낱말이 이것이다. 어떤 문장에서는 프로젝트 전체를 가리키고, 어떤 문장에서는 그 안의 패키지 하나를 가리킨다. 글쓴이가 말을 섞어 쓰는 게 아니라 원래 두 뜻으로 다 쓴다.
| 의미 | 가리키는 것 | 예 |
|---|---|---|
| the workspace (단수·전체) | 모노레포 프로젝트 전체 | “이 workspace는 pnpm으로 관리해” |
| a workspace (개별 멤버) | 그 안의 개별 패키지 하나 | “@repo/ui는 하나의 workspace야” |
표 1. workspace 라는 낱말이 가리키는 두 가지
하나의 workspace(전체) 안에 여러 workspace(멤버 패키지)가 든 구조라고 보면 말이 맞아떨어진다. 그리고 pnpm-workspace.yaml의 packages: 목록이 “어떤 폴더를 멤버로 인정할지” 정하는 자리다.
7. pnpm install 한 번이 안에서 하는 일
루트에서 install 하면 개별 패키지가 아니라 전체 workspace를 한꺼번에 처리한다.
- 멤버 발견 —
pnpm-workspace.yaml을 읽어 멤버 폴더 파악 - 그래프 구성 — 모든 멤버
package.json을 읽어 전체 의존성 그래프를 하나로 합침 (외부 + 내부workspace:*모두) - 단일 락파일 — 전체를 해석해 루트에
pnpm-lock.yaml딱 하나 생성 → 멤버 간 버전 정합성 보장 - 전역 스토어 1회 다운로드 — 실체는
~/.pnpm-store(전역)에 한 번만 저장. 여러 프로젝트가 같은 버전 써도 디스크엔 하나 - 하드링크 + 심링크 배치 — 각 멤버
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 소스 폴더를 가리키는 심링크다. 폴더를 하나 더 만든 게 아니라 바로가기를 하나 걸어 둔 것이다. 그래서 이렇게 된다.
packages/ui/src/Button.tsx수정 →apps/web에서 즉시 반영 (빌드·publish·재설치 불필요)- HMR·타입체크·”정의로 이동”이 심링크를 따라 소스 원본으로 감
react는 web·ui가 같은.pnpm/react@19.0.0실체를 가리킴 → React 인스턴스 하나로 통일 (여러 개면 “Invalid hook call”)
마지막 줄이 앞에서 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이 표준이 된 이유
| pnpm | npm/yarn classic | yarn berry(PnP) | |
|---|---|---|---|
| node_modules | 심링크 + .pnpm(비평탄) |
호이스팅(평탄) | node_modules 없음 |
| 유령 의존성 | 차단 | 허용(호이스팅 부작용) | 차단 |
| 디스크 | 하드링크로 최소 | 중복 | zip 캐시 |
표 3. 패키지 매니저 세 가지의 node_modules 구조 비교
엄격함(유령 의존성 차단) + 디스크 효율 두 가지다. 유령 의존성은 package.json에 안 적었는데도 import가 되는 상태를 말한다. npm은 의존성을 평탄하게 펴서 node_modules 최상단에 몰아 놓기 때문에, 적어 두지 않은 패키지도 거기 있으면 그냥 불러진다. 되니까 그대로 쓰다가, 나중에 그 패키지가 빠지는 날 갑자기 깨진다.
pnpm은 각 패키지가 자기가 적은 것만 볼 수 있게 심링크를 건다. 그래서 처음 pnpm으로 옮기면 잘 되던 import가 안 되는 일이 생긴다. 이건 pnpm이 새로 만든 문제가 아니라 원래 잘못돼 있던 걸 이제 알려 주는 것이다.
도입 비용과 한계
이 구조를 쓰면 감수할 것들이 있다.
- 설치가 통째로 무거워진다. 루트에서 한 번 돌리면 멤버 전부의 의존성을 해석한다. 한 패키지만 건드리고 싶어도 그래프 전체를 다시 본다.
- 유령 의존성 차단이 초반에는 방해로 느껴진다. 옮겨 오는 첫날은 안 되던
import를 하나씩package.json에 적어 넣는 일을 하게 된다. - 설정을 패키지로 빼면 파일을 두 군데 봐야 한다. 처음 온 사람이
tsconfig.json을 열면extends한 줄만 있어서 실제 값을 보려면packages/tsconfig까지 따라가야 한다. workspace:*와catalog:는 pnpm 밖에서 안 통한다. 나중에 다른 패키지 매니저로 옮기려면 이 표기를 전부 되돌려야 한다.catalog:는 pnpm 9.5이상 전용이다. 버전이 낮으면 아예 못 쓴다.
13. 여기까지 오면 무엇이 되나
pnpm install 한 번이 멤버를 찾아 그래프를 합치고 락파일 하나를 만들고 심링크를 걸어 준다. import가 되는 이유는 복사가 아니라 바로가기다.
그래서 지금 이 상태에서는 공용 컴포넌트를 고치면 앱에 즉시 반영된다. 아직 없는 것은 빌드를 빠르게 하는 장치와, 이 패키지들을 밖으로 내보내는 경로다. 그건 뒤편 안 바뀐 패키지까지 매번 다시 빌드하고 있었다 에서 이어 만든다.

답글 남기기