Monorepo & Package 관리 학습 일지
프로젝트: kodeflo-monorepo (pnpm + Turborepo) 작성일: 2026-04-21
목차
- semver 이해
- pnpm Workspaces
- Lockfile 내부 구조
- node_modules 3단 링크 구조
- 시나리오로 보는 동작
- 대표 에러와 원인
- Turborepo
- 내부 Shared Packages
1. semver 이해
왜 존재하는가
라이브러리 제작자가 "이 업데이트가 안전한지"를 사용자에게 알리는 신호 체계.
버전 번호 구조
19 . 1 . 0
↑ ↑ ↑
Major Minor Patch| 자리 | 변경 기준 | 의미 |
|---|---|---|
| Patch | 버그 수정 | 동작 변경 없음. 무조건 올려도 안전 |
| Minor | 기능 추가 (하위 호환) | 기존 API 유지. 올려도 안전 |
| Major | 파괴적 변경 | 기존 코드 깨질 수 있음. 마이그레이션 가이드 필수 |
실제 예:
19.0.0 → 19.0.1 (Patch: 메모리 누수 수정)
19.0.0 → 19.1.0 (Minor: 새 훅 추가, 기존 코드 유지)
18.0.0 → 19.0.0 (Major: forwardRef 제거, use() 추가 등)범위 기호: ^ vs ~
정확한 버전만 고정하면 보안 패치도 자동으로 못 받기 때문에 범위를 선언한다.
^ (캐럿) — Minor, Patch 허용
^19.0.0 → 19.0.0 이상, 20.0.0 미만
^15.5.6 → 15.5.6 이상, 16.0.0 미만
^3.2.0 → 3.2.0 이상, 4.0.0 미만Minor 업데이트는 하위 호환된다는 semver 약속을 신뢰하는 것. 실무에서 가장 많이 쓴다.
~ (틸드) — Patch만 허용
~19.0.0 → 19.0.0 이상, 19.1.0 미만
~15.5.6 → 15.5.6 이상, 15.6.0 미만Minor 업데이트도 믿기 어려운 라이브러리에 사용. ^보다 보수적.
정확한 버전 고정
"react": "19.0.0" → 딱 이 버전만함정: ^0.x.x
"^0.8.0" → 0.8.x만 허용 (0.9.0 불가!)Major가 0이면 Minor가 파괴적 변경으로 취급된다. 라이브러리 초기 개발 단계에 흔함.
lockfile이 해결하는 문제
package.json의 범위 선언은 실행 시점마다 다른 버전을 설치할 수 있다.
3월에 pnpm i → registry 최신: react@19.0.3 설치
4월에 pnpm i → registry 최신: react@19.1.0 설치팀원마다, 배포 환경마다 버전이 달라지는 문제 발생.
lockfile이 해결하는 방식:
pnpm-lock.yaml: react 19.0.3 고정
→ 이후 누가 pnpm i 해도 항상 19.0.3 설치| 파일 | 역할 |
|---|---|
package.json | 의도 (범위 선언) |
pnpm-lock.yaml | 사실 (정확한 버전 고정) |
이 프로젝트 실제 사례:
marketer의 standalone pnpm-lock.yaml이 루트 lockfile과 버전이 달랐던 이유.
생성 시점(3월 vs 4월)이 달라서 resolve된 버전이 달랐던 것. 현재 삭제 완료.
2. pnpm Workspaces
구조
kodeflo-monorepo/
├── pnpm-workspace.yaml ← 워크스페이스 선언
├── pnpm-lock.yaml ← 단일 lockfile (모든 앱 공유, 388KB)
├── package.json ← 루트 (turbo, eslint, husky 등)
├── apps/
│ ├── admin/ (@kodeflo-admin-next)
│ ├── adv/ (@kodeflo-adv-next)
│ ├── marketer/ (@kodeflo-marketer-next)
│ ├── partner-admin/
│ └── partner-marketer/
└── packages/
├── config/
├── shared/
└── ui/pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'이 파일이 "누가 workspace 멤버인가"를 정의한다. glob으로 지정된 경로 아래의 모든 package.json이 한 프로젝트로 묶인다.
왜 루트 단일 lockfile인가
앱별 lockfile을 두지 않고 루트 하나로 통합함으로써 얻는 것:
| 목적 | 설명 |
|---|---|
| 버전 단일화 강제 | react@19와 react@18이 앱별로 갈라지는 걸 원천 차단. 공유 패키지가 여러 React 인스턴스를 마주치는 "dual package hazard" 방지 |
| Peer dependency 전역 해상 | Context API·Hook 같은 React 전역 상태가 깨지지 않도록, peer dep을 workspace 전체 그래프 기준으로 해상 |
| CI 재현성 | pnpm install --frozen-lockfile 한 줄로 전 앱이 동일 상태로 복원 |
| 디스크 절약 | 동일 패키지 tarball이 모든 앱에 복제되지 않고 store에 1벌만 존재 |
| 업데이트 일관성 | pnpm up react 한 번이면 전 앱의 react 버전이 동기 업데이트 |
pnpm install 흐름
[1] pnpm-workspace.yaml 읽기
→ "apps/*, packages/*" 멤버 목록 수집
[2] 모든 멤버의 package.json 로드
→ 앱 + 패키지 + 루트 전체 package.json 병합
[3] 의존성 그래프 계산
→ 각 요구 범위를 만족하는 버전을 레지스트리 조회
→ peer dep 조합 계산 (동일 패키지도 peer 조합 다르면 별도 인스턴스)
[4] lockfile과 비교
→ --frozen-lockfile: 불일치 시 실패 (CI용)
→ 일반 실행: lockfile 갱신
[5] 중앙 store에 tarball 다운로드
→ ~/.pnpm-store/v3/files/<hash> (content-addressable)
→ 전 프로젝트 공유, 디스크 1벌
[6] node_modules 구성
→ store → hardlink → symlink 3단 링크
→ 각 앱/패키지의 node_modules는 심링크 뷰주요 명령어
# 전체 설치 (루트에서만)
pnpm install
# 특정 앱에만 패키지 추가
pnpm add <pkg> --filter @kodeflo/marketer
# 특정 앱 dev 실행
pnpm dev:marketer
# 특정 패키지가 어디서 왜 설치됐는지 확인
pnpm why <pkg> --filter @kodeflo/marketer앱 디렉터리 안에서 pnpm install 하면 루트 lockfile 깨짐 위험. 항상 루트에서 실행.
3. Lockfile 내부 구조
pnpm-lock.yaml은 크게 두 섹션으로 나뉜다.
lockfileVersion: '9.0'
# Section 1: 각 workspace 멤버의 요구사항
importers:
.: # 루트
devDependencies:
turbo:
specifier: ^2.5.0
version: 2.5.0
apps/admin:
dependencies:
'@chakra-ui/react':
specifier: 3.28.1
version: 3.28.1(@emotion/react@11.14.0)(...)
'@kodeflo/shared':
specifier: workspace:*
version: link:../../packages/shared # ← workspace 내부 링크
next:
specifier: ^15.5.6
version: 15.5.6(react@19.0.0)(...)
apps/marketer:
dependencies:
next:
specifier: ^15.5.6
version: 15.5.6(react@19.0.0)(...) # ← admin과 동일 해시 공유
# Section 2: 패키지 풀 (실제 tarball 해시 기록)
packages:
next@15.5.6:
resolution:
integrity: sha512-...
tarball: https://registry.npmjs.org/next/-/next-15.5.6.tgz
react@19.0.0:
resolution:
integrity: sha512-...importers 섹션 — "누가 뭘 원하는가"
- 키: workspace 루트 기준 상대 경로 (
.,apps/admin,packages/config) specifier: 원래package.json에 적힌 범위 (^15.5.6)version: 해결된 정확 버전 + peer dep 조합 지문 (15.5.6(react@19.0.0))
packages 섹션 — "그 버전은 어디서 받는가"
- 각 패키지 버전별로 integrity 해시·tarball URL이 한 번만 기록됨.
apps/admin과apps/marketer가 같은next@15.5.6을 쓰면,packages섹션엔 단 하나의 엔트리만 존재.
workspace:*는 링크로 기록된다
@kodeflo/shared: workspace:*는 version: link:../../packages/shared로 저장된다.
"npm에서 받지 말고 workspace 내부 디렉토리를 심링크해라"는 지시. pnpm publish 시에만 실제 버전 번호로 치환된다.
4. node_modules 3단 링크 구조
pnpm의 핵심: "복사하지 않고 링크만 걸어 동일 그래프를 각자 투영한다"
실제 디스크 레이아웃
~/.pnpm-store/v3/files/ [1단계: 전역 store]
00/2a7... ← react@19 실제 파일 (hardlink 소스)
4f/b81... ← next@15 실제 파일
kodeflo-monorepo/
├── node_modules/
│ └── .pnpm/ [2단계: 가상 저장소]
│ ├── react@19.0.0/
│ │ └── node_modules/
│ │ └── react/ ← store에서 hardlink
│ ├── next@15.5.6_react@19.0.0/ ← peer dep 조합별 폴더
│ │ └── node_modules/
│ │ ├── next/ ← hardlink
│ │ └── react -> ../../react@19.0.0/node_modules/react
│ └── @kodeflo+shared/
│ └── node_modules/
│ └── @kodeflo/shared -> ../../../../packages/shared
│
├── apps/marketer/
│ └── node_modules/ [3단계: 앱별 평면 뷰]
│ ├── react -> ../../../node_modules/.pnpm/react@19.0.0/node_modules/react
│ ├── next -> ../../../node_modules/.pnpm/next@15.5.6_.../node_modules/next
│ └── @kodeflo/
│ └── shared -> ../../../../packages/shared ← 원본 직접 참조
│
└── packages/shared/ ← 실제 소스 (수정하면 즉시 반영)각 레벨의 역할
1단계 — 전역 store (~/.pnpm-store)
머신의 모든 pnpm 프로젝트가 공유한다. content-addressable 구조로, 파일 내용 해시로 주소를 지정한다. 동일 파일은 1벌만 저장된다.
2단계 — 가상 저장소 (node_modules/.pnpm/)
모노레포 루트에만 존재한다. 모든 버전 + 모든 peer 조합이 모인 "패키지 풀"이다. 폴더명에 peer 지문이 붙는다: next@15.5.6_react@19.0.0 — peer가 달라지면 물리적으로 다른 폴더가 된다. 내부 파일은 store로 hardlink(복사가 아니라 같은 inode)된다.
3단계 — 앱별 node_modules
각 앱·패키지가 자기 package.json에 선언한 dep만 평면적으로 심링크로 노출한다. strict 구조이기 때문에 선언하지 않은 패키지는 import 불가 — phantom dependency를 원천 차단한다.
workspace 패키지 특별 취급
@kodeflo/shared 같은 내부 패키지는 store를 거치지 않고 원본 디렉토리로 직접 심링크된다.
apps/marketer/node_modules/@kodeflo/shared
→ ../../../../packages/shared (실제 소스 폴더)packages/shared/src/foo.ts를 수정하면 apps/marketer가 즉시 반영된다. 빌드나 install을 다시 실행할 필요가 없다.
5. 시나리오로 보는 동작
apps/marketer에 date-fns 추가
pnpm add date-fns --filter @kodeflo/marketer내부 동작:
- 루트
pnpm-lock.yaml의importers.apps/marketer에date-fns추가. packages.date-fns@4.1.0엔트리를packages섹션에 추가.- store에서 tarball 확보 →
node_modules/.pnpm/date-fns@4.1.0/hardlink. apps/marketer/node_modules/date-fns심링크 생성.
다른 앱은 영향 없음 (lockfile의 자기 섹션이 바뀌지 않으므로).
여러 앱이 동일 버전 공유
apps/admin,apps/adv,apps/marketer모두next@15.5.6요구.- lockfile의
packages.next@15.5.6은 딱 한 번 기록. node_modules/.pnpm/next@15.5.6_.../도 딱 한 폴더.- 각 앱
node_modules/next심링크가 그 폴더를 가리킴.
버전이 갈라지는 경우
만약 apps/admin이 react@19, apps/adv가 react@18을 요구한다면:
packages섹션에 두 엔트리 모두 기록.node_modules/.pnpm/react@19.0.0/과react@18.2.0/이 공존.- 공유 패키지
@kodeflo/ui가 peer dep으로 react를 요구하면, admin용·adv용 두 개의 인스턴스가 peer 조합별로 생성됨.
6. 대표 에러와 원인
ERR_PNPM_OUTDATED_LOCKFILE
CI에서 pnpm install --frozen-lockfile 실행 시, package.json이 수정됐는데 lockfile이 갱신되지 않은 경우 발생한다. lockfile 커밋을 빼먹은 상황이다.
해결: 로컬에서 pnpm install로 lockfile 갱신 후 커밋.
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND
workspace:*로 선언했는데 해당 이름의 패키지가 workspace에 없는 경우 발생한다. pnpm-workspace.yaml의 glob 범위 밖이거나, 패키지 name 필드가 다른 경우가 원인이다.
Phantom dependency
package.json에 선언하지 않은 패키지를 import했는데 우연히 동작하던 코드가 pnpm 도입 후 깨지는 현상이다. pnpm은 선언되지 않은 패키지를 node_modules 루트에 노출하지 않기 때문이다.
해결: 실제 사용하는 패키지를 package.json에 명시 선언.
7. Turborepo
역할
여러 앱의 빌드·테스트·개발 스크립트를 병렬 실행 + 캐싱으로 효율화한다. pnpm이 의존성 설치를 담당하고, Turborepo는 그 위에서 스크립트 오케스트레이션을 담당한다.
| 영역 | 담당 |
|---|---|
| 의존성 설치·버전 해상·링크 | pnpm workspace |
| 앱별 스크립트 실행 (build, dev, test) | Turbo |
| 빌드 캐시·태스크 파이프라인 | Turbo |
turbo.json — Task Pipeline
{
"tasks": {
"build": {
"dependsOn": ["^build"], // 의존 패키지 먼저 빌드
"outputs": [".next/**"] // 캐시 대상
},
"dev": {
"cache": false, // dev는 캐시 안 함
"persistent": true
},
"type-check": {
"dependsOn": ["^build"]
}
}
}^build는 해당 패키지가 의존하는 다른 워크스페이스 패키지를 먼저 빌드하라는 의미다.
Caching
같은 입력(소스 코드, env)이면 이전 결과를 재사용한다.
첫 번째 빌드: 실제 빌드 실행 (30초)
두 번째 빌드: cache hit → 즉시 완료 (1초)캐시 기준: 소스 파일 해시 + 환경변수
이 프로젝트 scripts
pnpm dev # 전체 앱 dev (병렬)
pnpm dev:marketer # marketer만 dev
pnpm build # 전체 빌드 (의존성 순서 자동 결정)
pnpm type-check # 전체 앱 타입 체크 (병렬)8. 내부 Shared Packages
언제 만드는가
여러 앱에서 동일한 코드가 반복될 때 추출한다.
apps/admin/src/utils/format-phone.ts
apps/marketer/src/utils/format-phone.ts ← 중복!→ packages/utils/src/format-phone.ts 로 추출
구조
kodeflo-monorepo/
├── apps/
│ ├── admin/
│ └── marketer/
└── packages/
├── ui/ ← 공유 컴포넌트
└── utils/ ← 공유 유틸workspace 프로토콜
// apps/marketer/package.json
{
"dependencies": {
"@kodeflo/ui": "workspace:*"
}
}workspace:*는 npm registry 대신 로컬 packages/ui를 참조한다. 변경사항이 즉시 반영된다.
pnpm-workspace.yaml 확장
packages:
- 'apps/*'
- 'packages/*' ← 공유 패키지 추가핵심 요약
semver → 버전 번호의 의미와 범위 선언 규칙
lockfile → 실행 시점 무관하게 버전 고정 (재현 가능한 빌드)
importers → lockfile 내 각 앱의 의존성 요구사항 섹션
packages → lockfile 내 실제 tarball 해시 풀 (중복 없음)
3단 링크 → store(hardlink) → .pnpm(가상 저장소) → 앱 node_modules(symlink)
workspace:* → npm 레지스트리 우회, 내부 패키지 원본 직접 참조
turborepo → 병렬 실행 + 캐싱으로 빌드 효율화
packages/ → 앱 간 코드 중복 제거참고 링크
- pnpm workspace: https://pnpm.io/workspaces
- lockfile 포맷: https://pnpm.io/motivation#creating-a-non-flat-node_modules-directory
- workspace protocol: https://pnpm.io/workspaces#workspace-protocol-workspace
- Turborepo + pnpm: https://turbo.build/repo/docs/crafting-your-repository/managing-dependencies