이번 편의 결과물: pnpm install 후 apps/web이 그대로 정상 동작하고, packages/는 아직 비어 있는 모노레포 구조가 갖춰집니다. · 다루는 개념: pnpm-workspace.yaml, 루트 package.json의 워크스페이스 스크립트, 단일 프로젝트를 apps/web + packages/로 재구성하는 절차
03편에서 패키지 경계를 나누는 이유(공유 타입, 재사용 UI, 독립 배포 단위)를 미리 살펴봤습니다. 13편까지 bookshelf-ts는 여전히 npm create vite로 만든 단일 프로젝트입니다. 이 편에서 실제로 폴더를 쪼개, 앱 코드는 apps/web으로 옮기고 공용 패키지가 들어갈 빈 packages/를 만듭니다. 타입 공유(15편)·빌드 순서(16편)·UI 패키지 분리(17편)는 모두 이 구조 위에서 진행됩니다.
이 편에서 만드는 파일
bookshelf-ts/
├── pnpm-workspace.yaml (+, 워크스페이스 범위 지정)
├── package.json (~, 루트 워크스페이스 스크립트로 교체)
├── package-lock.json (-, npm 잠금 파일 삭제)
├── apps/
│ └── web/ (+, 기존 프로젝트 전체 이동)
│ ├── package.json (~, name을 @bookshelf/web으로 변경)
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── tsconfig.app.json
│ ├── tsconfig.node.json
│ ├── index.html
│ └── src/ (기존 내용 그대로 이동)
└── packages/ (+, 빈 폴더. 15·17편에서 채워짐)개념 정리
모노레포 루트와 워크스페이스 패키지의 관계
루트 bookshelf-ts/는 그 자체로 배포되는 패키지가 아니라, 여러 실제 패키지(apps/web, 앞으로 만들 packages/shared-types, packages/ui)를 묶는 컨테이너입니다. 루트 package.json에는 private: true를 반드시 넣어 실수로 루트가 npm에 배포되는 것을 막습니다.
pnpm-workspace.yaml
pnpm은 어떤 폴더들을 워크스페이스 패키지로 볼지 pnpm-workspace.yaml의 packages 목록으로 정합니다. 글롭 패턴을 쓸 수 있고, 앞에 느낌표를 붙이면 제외됩니다.
packages:
- 'apps/*'
- 'packages/*'apps/*는 apps 바로 아래 폴더 각각(apps/web 등)을 패키지로 인식합니다. 지금은 packages/*에 맞는 폴더가 하나도 없어도 오류가 아니라 빈 목록으로 처리됩니다.
루트에서 개별 패키지 스크립트 실행하기
| 명령 | 동작 |
|---|---|
pnpm --filter @bookshelf/web dev | @bookshelf/web 패키지 하나만 지정해 dev 스크립트 실행 |
pnpm -r build | 워크스페이스의 모든 패키지에서 build 스크립트 실행 |
pnpm --workspace-root <script> | 루트 package.json에 정의된 스크립트만 실행 |
-r(재귀)과 --filter(패키지 지정)는 이후 편에서 계속 쓰이므로 이 편에서 먼저 손에 익힙니다.
실습
1. 기존 프로젝트를 apps/web으로 이동
bookshelf-ts/ 루트에서 실행합니다.
mkdir -p apps/web packages
git mv src apps/web/src
git mv public apps/web/public
git mv index.html apps/web/index.html
git mv vite.config.ts apps/web/vite.config.ts
git mv tsconfig.json apps/web/tsconfig.json
git mv tsconfig.app.json apps/web/tsconfig.app.json
git mv tsconfig.node.json apps/web/tsconfig.node.json
git mv package.json apps/web/package.json
git mv package-lock.json apps/web/package-lock.json 2>/dev/null || true마지막 줄은 package-lock.json이 없어도 오류로 멈추지 않게 하는 안전장치입니다. 이후 npm 잠금 파일은 pnpm 잠금 파일로 대체하므로 이동한 뒤 바로 지웁니다.
rm -f apps/web/package-lock.json2. apps/web/package.json 정리
{
"name": "@bookshelf/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.103.2",
"i18next": "26.4.2",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.88.0",
"react-i18next": "17.0.15",
"react-router": "8.4.0",
"zod": "4.6.5",
"zustand": "5.0.15"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@testing-library/react": "16.3.3",
"@types/node": "26.6.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"eslint": "10.11.0",
"msw": "2.15.0",
"typescript": "7.0.2",
"vite": "8.3.0",
"vitest": "5.0.1"
}
}name을 @bookshelf/web으로 바꿨습니다. 스코프(@bookshelf/)는 15편에서 만들 @bookshelf/shared-types, 17편의 @bookshelf/ui와 같은 이름 공간을 씁니다. build 스크립트에 tsc -b를 넣어뒀지만 아직 references가 없어 지금은 일반 tsc와 동일하게 동작합니다. 실제 프로젝트 참조 설정은 16편에서 채웁니다.
3. 루트 package.json과 pnpm-workspace.yaml 작성
{
"name": "bookshelf-ts",
"private": true,
"packageManager": "pnpm@12.5.1",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "pnpm --filter @bookshelf/web dev",
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck"
}
}# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'packageManager 필드는 Corepack이 이 저장소를 열 때 정확히 pnpm@12.5.1을 쓰도록 고정합니다. 팀원마다 pnpm 버전이 달라 잠금 파일이 흔들리는 것을 막아줍니다.
4. 설치와 실행
corepack enable
pnpm install
pnpm devpnpm install은 루트에 pnpm-lock.yaml을 하나 생성하고, apps/web/node_modules를 채웁니다. pnpm dev는 루트 스크립트를 거쳐 apps/web의 dev 스크립트를 실행합니다.
확인
- 루트에
pnpm-lock.yaml이 생기고apps/web에는 더 이상package-lock.json이 없습니다. - 브라우저에서 기존과 똑같은 화면(책 목록·상세·폼·로그인)이 그대로 보입니다.
pnpm --filter @bookshelf/web lint처럼 패키지를 직접 지정해도 같은 결과가 나옵니다.packages/폴더는 존재하지만 비어 있고,pnpm install이 이 폴더 때문에 오류를 내지 않습니다.
직접 해보기
- 루트
package.json스크립트에"clean": "pnpm -r exec rm -rf node_modules dist"를 추가하고 실행해, 워크스페이스 전체에서 명령이 어떻게 퍼지는지 관찰해보세요. pnpm --filter @bookshelf/web why react를 실행해, 특정 패키지 하나만 대상으로 의존성 정보를 조회하는--filter의 다른 쓰임을 확인해보세요.
정답 보기
{
"scripts": {
"clean": "pnpm -r exec rm -rf node_modules dist"
}
}pnpm -r exec <명령>은 각 워크스페이스 패키지 폴더 안에서 그 명령을 그대로 실행합니다. -r build처럼 정해진 스크립트 이름을 실행하는 것과 달리, 임의의 셸 명령을 패키지마다 반복할 때 씁니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
pnpm install이 apps/web을 워크스페이스 패키지로 인식하지 못함 | pnpm-workspace.yaml을 만들지 않거나 패턴을 잘못 씀 | 루트에 pnpm-workspace.yaml을 두고 apps/* 패턴을 정확히 넣는다 |
pnpm dev를 루트에서 실행하면 아무 것도 안 됨 | 루트 package.json에 dev 스크립트를 안 만듦 | 루트 스크립트에서 pnpm --filter @bookshelf/web dev로 위임한다 |
apps/web 안에 package-lock.json이 남아 있어 설치 도구가 섞임 | npm으로 만든 잠금 파일을 지우지 않고 이동만 함 | 이동 직후 package-lock.json을 삭제하고 pnpm으로만 설치한다 |