
Turborepo + pnpm: 모노레포에서 빌드 캐시 최적화
모노레포를 쓰다 보면 어느 순간 빌드가 10분이 넘어간다. Turborepo의 태스크 그래프와 캐싱이 이 문제를 어떻게 해결하는지, 실제 Next.js 모노레포 셋업으로 보여준다.

모노레포를 쓰다 보면 어느 순간 빌드가 10분이 넘어간다. Turborepo의 태스크 그래프와 캐싱이 이 문제를 어떻게 해결하는지, 실제 Next.js 모노레포 셋업으로 보여준다.
TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.

TypeScript만 믿고 있다가 런타임 에러로 앱이 터졌습니다. 컴파일 타임이 아닌 '런타임'에 데이터를 검증해야 하는 이유와 Zod 활용법.

프론트엔드, 백엔드, 공통 라이브러리를 각각 다른 레포에서 관리하다가 동기화 지옥을 겪었다. Turborepo로 모노레포를 구성한 이야기.

node_modules가 1GB를 넘어가고 npm install이 5분 걸리던 프로젝트가, pnpm으로 바꾸니 용량도 속도도 절반이 됐다.

회사 모노레포가 있었는데, 처음엔 괜찮았어. 앱 2개, 공유 패키지 3개. npm run build:all이 3분이면 됐어.
근데 1년이 지나니 앱이 6개, 공유 패키지가 12개가 됐어. CI 빌드는 18분. PR 올릴 때마다 20분 기다리는 게 일상이 됐어. 팀원들이 "그냥 코드 리뷰 없이 머지하자"는 농담 아닌 농담을 하기 시작했어.
문제가 뭔지는 알았어. app-admin이 변경됐는데 app-blog도 다시 빌드되고, 건드리지도 않은 패키지들도 타입 체크가 돌아가고, 테스트도 전부 재실행되고. 변경 없는 것들까지 다 돌리는 낭비였어.
Turborepo를 붙이고 나서 18분이 3분으로 줄었어. 캐시 히트가 많은 날은 40초.
app-web (변경 없음) → 빌드 실행 ❌ (낭비)
app-admin (변경됨) → 빌드 실행 ✅ (필요)
app-blog (변경 없음) → 빌드 실행 ❌ (낭비)
ui-package (변경 없음) → 빌드 실행 ❌ (낭비)
utils (변경됨) → 빌드 실행 ✅ (필요)
types (변경 없음) → 빌드 실행 ❌ (낭비)
각 패키지 변경 여부를 추적하지 않으면 매번 전부 다시 실행해야 해.
utils (변경됨) → 빌드 실행 ✅ (캐시 미스)
app-admin (변경됨,
utils 의존) → 빌드 실행 ✅ (캐시 미스)
ui-package (변경 없음) → 캐시에서 복원 ⚡ (히트)
app-web (변경 없음,
ui-package 의존) → 캐시에서 복원 ⚡ (히트)
types (변경 없음) → 캐시에서 복원 ⚡ (히트)
캐시 키: 입력 파일들의 해시 + 환경 변수 해시. 입력이 같으면 출력도 같다는 원칙으로 캐시를 재사용해.
| 패키지 매니저 | 디스크 사용 | 설치 속도 | 모노레포 지원 | 유령 의존성 |
|---|---|---|---|---|
| npm | 많음 | 느림 | workspace | 있음 |
| yarn | 중간 | 중간 | workspace | 있음 |
| pnpm | 적음 (심링크) | 빠름 | workspace | 없음 |
pnpm은 패키지를 하드링크로 관리해서 디스크를 훨씬 적게 써. 특히 모노레포에서 같은 패키지를 여러 앱이 공유할 때 효과가 커. 그리고 node_modules 구조가 엄격해서 유령 의존성(명시하지 않은 패키지를 import하는 것) 문제가 없어.
my-monorepo/
├── apps/
│ ├── web/ ← Next.js 앱
│ ├── admin/ ← Next.js 앱
│ └── docs/ ← Docusaurus
├── packages/
│ ├── ui/ ← 공유 UI 컴포넌트
│ ├── config/ ← 공유 설정 (ESLint, TS)
│ └── utils/ ← 공유 유틸리티
├── pnpm-workspace.yaml
├── package.json
├── turbo.json
└── .gitignore
packages:
- 'apps/*'
- 'packages/*'
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"type-check": "turbo run type-check",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"engines": {
"node": ">=18",
"pnpm": ">=8"
},
"packageManager": "pnpm@9.0.0"
}
// packages/ui/package.json
{
"name": "@my-app/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@my-app/config": "workspace:*",
"typescript": "^5.0.0"
},
"peerDependencies": {
"react": "^18 || ^19"
}
}
// apps/web/package.json
{
"name": "@my-app/web",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@my-app/ui": "workspace:*",
"@my-app/utils": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0"
},
"devDependencies": {
"@my-app/config": "workspace:*"
}
}
workspace:*는 pnpm의 workspace 프로토콜이야. 로컬 패키지를 참조하는 방식으로, 로컬에서는 항상 최신 버전을 가리켜.
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"type-check": {
"dependsOn": ["^type-check"],
"outputs": []
}
}
}
// ^ 접두사의 의미
"dependsOn": ["^build"]
// "이 태스크를 실행하기 전에 의존하는 모든 패키지의 build를 먼저 실행"
"dependsOn": ["build"]
// "이 패키지 내의 build 태스크를 먼저 실행"
"dependsOn": ["@my-app/ui#build"]
// "특정 패키지의 특정 태스크를 먼저 실행"
실제 예시:
{
"tasks": {
"build": {
// apps/web/build가 실행되기 전에
// @my-app/ui/build와 @my-app/utils/build가 먼저 완료되어야 함
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
},
"test": {
// 테스트 전에 빌드가 완료되어야 함
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {
// lint는 다른 태스크에 의존하지 않음 (독립적으로 실행 가능)
"outputs": []
}
}
}
# 태스크 그래프를 시각적으로 확인
turbo run build --graph
# 출력:
# ┌───────────────────────────────────┐
# │ @my-app/web#build │
# │ depends on: @my-app/ui#build │
# │ @my-app/utils#build │
# └───────────────────────────────────┘
Turborepo의 캐시 키는 다음 요소들의 해시야:
src/**/*.ts, package.json, 설정 파일들NODE_ENV, 커스텀 env 변수// 캐시에 영향주는 환경 변수 명시
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
}
}
}
기본적으로 모든 파일이 inputs야. 특정 파일만 보고 싶으면:
{
"tasks": {
"test": {
"inputs": [
"src/**/*.ts",
"src/**/*.tsx",
"test/**/*.ts",
"vitest.config.ts"
],
"outputs": ["coverage/**"]
},
"lint": {
"inputs": [
"src/**/*.ts",
"src/**/*.tsx",
".eslintrc*"
],
"outputs": []
}
}
}
이렇게 하면 README.md를 바꿔도 test나 lint 캐시가 무효화되지 않아.
# 캐시 히트 여부 확인
turbo run build
# 출력 예시:
# @my-app/ui:build: cache hit, replaying output
# @my-app/utils:build: cache hit, replaying output
# @my-app/web:build: cache miss, executing
# 강제로 캐시 무시하고 재실행
turbo run build --force
# 캐시 삭제
turbo run build --no-cache
# 기본 캐시 위치
.turbo/cache/
# 캐시 디렉토리 구조
.turbo/
cache/
[hash1]/
.turbo/
run-build.log
.next/ ← 캐시된 빌드 출력
로컬 캐시는 자기 머신에서만 써. 원격 캐시를 쓰면 팀원들과 CI가 같은 캐시를 공유해. 누군가 빌드했으면 다른 사람은 그 결과를 바로 가져다 써.
# Vercel 계정으로 원격 캐시 활성화
npx turbo login
# 또는 환경 변수로
TURBO_TOKEN=your-token
TURBO_TEAM=your-team-slug
// turbo.json에 team 설정 (선택적)
{
"$schema": "https://turbo.build/schema.json",
"remoteCache": {
"enabled": true
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
오픈소스 원격 캐시 구현체들이 있어:
# ducktape/turbogrid 예시
docker run -p 8080:8080 ducktape/turbogrid
# 환경 변수 설정
TURBO_API=http://localhost:8080
TURBO_TOKEN=your-custom-token
TURBO_TEAM=your-team
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Turborepo가 이전 커밋과 비교하려면 필요
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build, Lint, Test
run: pnpm turbo run build lint test type-check
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NODE_ENV: production
# 기본: CPU 코어 수에 따라 자동 병렬
turbo run build
# 병렬 작업 수 제한 (메모리 이슈 방지)
turbo run build --concurrency=2
# 순차 실행 (디버깅용)
turbo run build --concurrency=1
특정 패키지만 빌드하거나 영향받은 패키지만 테스트할 때:
# 특정 패키지만 실행
turbo run build --filter=@my-app/web
# 특정 패키지와 그 의존성 모두
turbo run build --filter=@my-app/web...
# 여러 패키지
turbo run build --filter=@my-app/web --filter=@my-app/admin
# 변경된 파일이 있는 패키지만 (git 기반)
turbo run test --filter=[HEAD^1]
# main 브랜치 이후 변경된 패키지만
turbo run test --filter=[main]
PR에서 변경된 패키지만 테스트하는 CI 최적화:
# .github/workflows/pr.yml
- name: Run tests for changed packages
run: pnpm turbo run test --filter=[origin/main]
모노레포에서 TypeScript, ESLint 설정을 공유하는 방법:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules"]
}
// apps/web/tsconfig.json
{
"extends": "@my-app/config/tsconfig.base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
// packages/config/eslint-base.js
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'import/order': ['error', {
'groups': ['builtin', 'external', 'internal'],
'newlines-between': 'always',
}],
},
};
// apps/web/.eslintrc.js
module.exports = {
extends: ['@my-app/config/eslint-base'],
rules: {
// 앱 특화 규칙
},
};
# 캐시 무효화 이유 확인
turbo run build --verbosity=2
# 주요 원인:
# - 환경 변수 변경 (turbo.json env에 없는 것도 영향)
# - package-lock.json이 아닌 파일도 inputs에 포함됨
# - node_modules가 inputs에 포함된 경우
// node_modules 제외 명시
{
"tasks": {
"build": {
"inputs": ["src/**", "!node_modules/**"]
}
}
}
// dev는 캐시하지 않도록
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
// 앱의 turbo.json에서 의존성 명확히 설정
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ← 이게 없으면 의존 패키지 먼저 빌드 안 함
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
// .next/cache는 캐시 outputs에서 제외
{
"tasks": {
"build": {
"outputs": [
".next/**",
"!.next/cache/**" // ← 이걸 빼면 수백 MB씩 캐시됨
]
}
}
}
# 빌드 시간 프로파일링
turbo run build --profile=profile.json
# Turborepo UI로 결과 확인
npx @turbo/ui profile.json
실제 수치 비교 (팀 프로젝트 기준):
| 상황 | 이전 (turbo 없음) | 이후 (turbo + remote cache) |
|---|---|---|
| 첫 CI 빌드 | 18분 | 5분 (병렬화) |
| 코드 변경 CI | 18분 | 1-3분 (캐시 히트) |
| 로컬 재빌드 | 8분 | 10-20초 (캐시) |
| PR 테스트 | 15분 | 1분 (변경분만) |
Turborepo + pnpm 조합이 모노레포의 빌드 속도 문제를 해결하는 핵심은 두 가지야:
셋업 체크리스트:
pnpm-workspace.yaml로 workspace 설정turbo.json에서 tasks 정의 (dependsOn, outputs 필수)env 필드로 캐시에 영향주는 환경 변수 명시--filter=[main]으로 변경분만 테스트빌드가 느리다면 문제는 코드가 아니라 도구 설정일 가능성이 높아. Turborepo를 붙이는 데 하루, 빌드 시간 80% 단축. 해볼 만한 투자야.
Our company monorepo was fine at first. Two apps, three shared packages. npm run build:all took three minutes.
A year later: six apps, twelve shared packages. CI builds hit 18 minutes. Waiting 20 minutes per PR became routine. Team members started half-joking about "just merging without code review."
The problem was obvious. app-admin changed, but app-blog rebuilt too. Packages we didn't touch got type-checked. Tests ran everywhere. Everything reran even when nothing changed.
After plugging in Turborepo: 18 minutes down to 3. On cache-heavy days: 40 seconds.
app-web (unchanged) → Build runs ❌ (wasted)
app-admin (changed) → Build runs ✅ (needed)
app-blog (unchanged) → Build runs ❌ (wasted)
ui-pkg (unchanged) → Build runs ❌ (wasted)
utils (changed) → Build runs ✅ (needed)
types (unchanged) → Build runs ❌ (wasted)
Without tracking which packages actually changed, you rebuild everything.
utils (changed) → Build executes ✅ (cache miss)
app-admin (changed, depends utils) → Build executes ✅ (cache miss)
ui-pkg (unchanged) → Restored from cache ⚡ (hit)
app-web (unchanged, dep ui-pkg) → Restored from cache ⚡ (hit)
types (unchanged) → Restored from cache ⚡ (hit)
Cache key: hash of input files + environment variable hash. Same inputs → same outputs → reuse cache.
| Package Manager | Disk Usage | Install Speed | Monorepo | Phantom Deps |
|---|---|---|---|---|
| npm | High | Slow | workspace | Yes |
| yarn | Medium | Medium | workspace | Yes |
| pnpm | Low (symlinks) | Fast | workspace | No |
pnpm manages packages with hard links — dramatically less disk usage. In a monorepo where multiple apps share packages, this compounds. The strict node_modules structure also eliminates phantom dependencies.
my-monorepo/
├── apps/
│ ├── web/
│ ├── admin/
│ └── docs/
├── packages/
│ ├── ui/
│ ├── config/
│ └── utils/
├── pnpm-workspace.yaml
├── package.json
├── turbo.json
└── .gitignore
packages:
- 'apps/*'
- 'packages/*'
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"type-check": "turbo run type-check"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"packageManager": "pnpm@9.0.0"
}
// packages/ui/package.json
{
"name": "@my-app/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@my-app/config": "workspace:*"
},
"peerDependencies": {
"react": "^18 || ^19"
}
}
// apps/web/package.json
{
"name": "@my-app/web",
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@my-app/ui": "workspace:*",
"@my-app/utils": "workspace:*",
"next": "^15.0.0"
}
}
workspace:* is pnpm's workspace protocol — always refers to the local package at its current version.
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"type-check": {
"dependsOn": ["^type-check"],
"outputs": []
}
}
}
"dependsOn": ["^build"]
// "Before running this task, run build in all packages this package depends on"
"dependsOn": ["build"]
// "Run build within this same package first"
"dependsOn": ["@my-app/ui#build"]
// "Run build in the specific @my-app/ui package first"
turbo run build --graph
# Output:
# @my-app/web#build
# depends on: @my-app/ui#build
# @my-app/utils#build
src/**/*.ts, package.json, config filesNODE_ENV, custom env vars{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
}
}
}
{
"tasks": {
"test": {
"inputs": [
"src/**/*.ts",
"src/**/*.tsx",
"test/**/*.ts",
"vitest.config.ts"
],
"outputs": ["coverage/**"]
}
}
}
Now changing a README.md won't invalidate the test cache.
# Normal run — shows cache hit/miss
turbo run build
# Force re-execute, ignore cache
turbo run build --force
# Run without writing to cache
turbo run build --no-cache
Local cache is per-machine. Remote caching lets the whole team and CI share the same cache. If a teammate already built it, you get the result instantly.
npx turbo login
# Or via environment variables
TURBO_TOKEN=your-token
TURBO_TEAM=your-team-slug
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build, Lint, Test
run: pnpm turbo run build lint test type-check
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
# Only one package
turbo run build --filter=@my-app/web
# Package and all its dependencies
turbo run build --filter=@my-app/web...
# Only packages changed since last commit
turbo run test --filter=[HEAD^1]
# Only packages changed since main branch
turbo run test --filter=[main]
PR optimization — test only changed packages:
- name: Test changed packages
run: pnpm turbo run test --filter=[origin/main]
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
// apps/web/tsconfig.json
{
"extends": "@my-app/config/tsconfig.base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"jsx": "preserve",
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}
# See why cache was invalidated
turbo run build --verbosity=2
Common causes:
env field)node_modules included in inputs accidentally!.next/cache/** exclusion pattern{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ← This must be present
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
{
"tasks": {
"build": {
"outputs": [
".next/**",
"!.next/cache/**" // ← Exclude this or cache grows by hundreds of MB
]
}
}
}
Real figures from a team project:
| Scenario | Before Turbo | After Turbo + Remote Cache |
|---|---|---|
| First CI build | 18 min | 5 min (parallelization) |
| Code change CI | 18 min | 1-3 min (cache hits) |
| Local rebuild | 8 min | 10-20 sec (cache) |
| PR tests | 15 min | 1 min (changed only) |