Prologue: 빌드가 10분 넘어가던 날
회사 모노레포가 있었는데, 처음엔 괜찮았어. 앱 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 (변경 없음) → 빌드 실행 ❌ (낭비)
각 패키지 변경 여부를 추적하지 않으면 매번 전부 다시 실행해야 해.
Turborepo의 접근법
utils (변경됨) → 빌드 실행 ✅ (캐시 미스)
app-admin (변경됨,
utils 의존) → 빌드 실행 ✅ (캐시 미스)
ui-package (변경 없음) → 캐시에서 복원 ⚡ (히트)
app-web (변경 없음,
ui-package 의존) → 캐시에서 복원 ⚡ (히트)
types (변경 없음) → 캐시에서 복원 ⚡ (히트)
캐시 키: 입력 파일들의 해시 + 환경 변수 해시. 입력이 같으면 출력도 같다는 원칙으로 캐시를 재사용해.
pnpm Workspace 셋업
왜 pnpm인가?
| 패키지 매니저 | 디스크 사용 | 설치 속도 | 모노레포 지원 | 유령 의존성 |
|---|---|---|---|---|
| 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
pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
루트 package.json
{
"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"
}
개별 패키지 package.json
// 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 프로토콜이야. 로컬 패키지를 참조하는 방식으로, 로컬에서는 항상 최신 버전을 가리켜.
turbo.json 완전 가이드
기본 구조
{
"$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 이해하기
// ^ 접두사의 의미
"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 변수 - turbo.json 설정
- 의존 패키지들의 출력물
// 캐시에 영향주는 환경 변수 명시
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
}
}
}
inputs 커스터마이징
기본적으로 모든 파일이 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/ ← 캐시된 빌드 출력
원격 캐싱 (Remote Caching)
로컬 캐시는 자기 머신에서만 써. 원격 캐시를 쓰면 팀원들과 CI가 같은 캐시를 공유해. 누군가 빌드했으면 다른 사람은 그 결과를 바로 가져다 써.
Vercel Remote Cache 설정
# 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/**"]
}
}
}
자체 원격 캐시 서버 (self-hosted)
오픈소스 원격 캐시 구현체들이 있어:
# ducktape/turbogrid 예시
docker run -p 8080:8080 ducktape/turbogrid
# 환경 변수 설정
TURBO_API=http://localhost:8080
TURBO_TOKEN=your-custom-token
TURBO_TEAM=your-team
CI/CD 설정
GitHub Actions
# .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 설정을 공유하는 방법:
packages/config/tsconfig.base.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules"]
}
각 앱/패키지에서 extends
// 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
// 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: {
// 앱 특화 규칙
},
};
실전 문제 해결
1. "빌드가 캐시됐는데 왜 안 된다?"
# 캐시 무효화 이유 확인
turbo run build --verbosity=2
# 주요 원인:
# - 환경 변수 변경 (turbo.json env에 없는 것도 영향)
# - package-lock.json이 아닌 파일도 inputs에 포함됨
# - node_modules가 inputs에 포함된 경우
// node_modules 제외 명시
{
"tasks": {
"build": {
"inputs": ["src/**", "!node_modules/**"]
}
}
}
2. "dev 모드에서 캐시 때문에 변경이 반영 안 돼"
// dev는 캐시하지 않도록
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
3. "공유 패키지 변경이 앱에 반영이 안 돼"
// 앱의 turbo.json에서 의존성 명확히 설정
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ← 이게 없으면 의존 패키지 먼저 빌드 안 함
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
4. ".next 캐시로 인한 용량 폭증"
// .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필드로 캐시에 영향주는 환경 변수 명시- CI에서 원격 캐시 설정 (TURBO_TOKEN, TURBO_TEAM)
--filter=[main]으로 변경분만 테스트
빌드가 느리다면 문제는 코드가 아니라 도구 설정일 가능성이 높아. Turborepo를 붙이는 데 하루, 빌드 시간 80% 단축. 해볼 만한 투자야.
Turborepo + pnpm: Optimizing Build Cache in Monorepos
Prologue: The Day the Build Hit 18 Minutes
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.
The Core Monorepo Build Problem
The Traditional Approach
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.
Turborepo's Approach
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.
pnpm Workspace Setup
Why pnpm?
| 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.
Directory Structure
my-monorepo/
├── apps/
│ ├── web/
│ ├── admin/
│ └── docs/
├── packages/
│ ├── ui/
│ ├── config/
│ └── utils/
├── pnpm-workspace.yaml
├── package.json
├── turbo.json
└── .gitignore
pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
Root package.json
{
"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"
}
Individual Package package.json
// 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.
turbo.json: Complete Guide
Basic Structure
{
"$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": []
}
}
}
Understanding dependsOn
"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"
Visualize the Task Graph
turbo run build --graph
# Output:
# @my-app/web#build
# depends on: @my-app/ui#build
# @my-app/utils#build
How Caching Works
Cache Key Calculation
- Input files:
src/**/*.ts,package.json, config files - Environment variables:
NODE_ENV, custom env vars - turbo.json configuration
- Outputs from dependency packages
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
}
}
}
Narrow Down Inputs
{
"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.
Cache Commands
# 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
Remote Caching
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.
Vercel Remote Cache
npx turbo login
# Or via environment variables
TURBO_TOKEN=your-token
TURBO_TEAM=your-team-slug
CI: GitHub Actions
# .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 }}
Filtering Tasks
# 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]
Shared Config Packages
packages/config/tsconfig.base.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
Each App Extends It
// 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"]
}
Common Troubleshooting
"Why isn't my cached build being used?"
# See why cache was invalidated
turbo run build --verbosity=2
Common causes:
- Environment variable changed (even ones not in
envfield) node_modulesincluded in inputs accidentally- Missing
!.next/cache/**exclusion pattern
"Dev mode changes aren't reflecting"
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
"Shared package changes don't propagate to apps"
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ← This must be present
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
Cache ballooning from .next/cache
{
"tasks": {
"build": {
"outputs": [
".next/**",
"!.next/cache/**" // ← Exclude this or cache grows by hundreds of MB
]
}
}
}
Performance Numbers
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) |