Turborepo 캐싱 전략으로 모노레포 CI 시간을 60% 이상 줄인 방법: turbo.json과 Remote Cache 실전 설정
PR 하나 올렸는데 CI가 15분 넘게 돌고, 로컬에서도 전체 빌드가 몇 분씩 걸리는 상황. 건드린 패키지는 딱 하나인데 왜 전체를 다시 빌드하는 건지 답답했던 경험이 있으실 겁니다. 모노레포(여러 패키지를 하나의 저장소에서 함께 관리하는 구조)를 운영하다 보면 거의 피할 수 없는 문제입니다. 저도 처음엔 "뭔가 설정이 잘못된 건가?" 싶어서 한참 헤맸습니다.
Turborepo는 이 문제를 콘텐츠 기반 해싱과 의존성 그래프 실행으로 풀어냅니다. 그런데 단순히 도입만 하면 절반의 효과밖에 못 냅니다. turbo.json 설정을 어떻게 짜느냐, 그리고 원격 캐시(Remote Cache)를 연결하느냐에 따라 팀 전체의 빌드 경험이 완전히 달라집니다. johal.in에서 공개한 사례를 보면, Next.js 15 + Turborepo 2.0 전환 후 연간 CI 비용을 $11,800 절감했다고 합니다. 이 글을 다 읽으면 turbo.json 핵심 세 가지 설정으로 캐시 히트율을 직접 측정하고, CI 파이프라인에 원격 캐시를 연결할 수 있게 됩니다. 15개 패키지 모노레포 기준으로 단일 패키지 PR의 CI 시간을 60~80% 줄일 수 있는 설정입니다.
핵심 개념
Turborepo가 빌드를 빠르게 만드는 원리
처음에 해싱 개념이 헷갈렸는데, 핵심은 단순합니다. 파일 내용이 바뀌지 않으면 다시 빌드할 이유가 없다는 거예요. Turborepo는 소스 파일, package.json 의존성, 환경 변수, 빌드 설정을 조합해 태스크별로 고유한 fingerprint를 만들고, 이 해시가 캐시에 이미 있으면 실제 빌드 없이 밀리초 단위로 결과를 복원합니다.
여기에 두 가지가 더 얹어집니다. turbo.json의 dependsOn 필드로 태스크 간 실행 순서를 명시하면 패키지 간 의존 관계를 자동으로 파악해서 최적 순서로 빌드를 배치하고, 의존 관계가 없는 태스크는 CPU 코어를 최대한 활용해 동시에 실행합니다. 10개 패키지의 lint를 순서대로 돌리는 게 아니라, 한꺼번에 병렬로 처리하는 방식입니다.
turbo.json 구조 이해하기
Turborepo 2.0부터 pipeline 키가 제거되고 tasks로 통합됐습니다. 예전 설정 파일을 그대로 가져왔다가 동작이 안 되는 경우가 바로 이 때문입니다.
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json"],
"outputs": [".next/**", "dist/**", "!.next/cache/**"],
"env": ["NODE_ENV", "API_URL"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tests/**"],
"outputs": ["coverage/**"]
},
"lint": {
"dependsOn": [],
"inputs": ["src/**", ".eslintrc*"]
},
"deploy": {
"dependsOn": ["build", "test"],
"cache": false
}
}
}dependsOn에서 ^build는 "이 패키지가 의존하는 upstream 패키지들의 build가 먼저 완료되어야 한다"는 의미입니다. app이 ui 패키지를 사용한다면, ^build는 ui의 build가 먼저 실행됨을 보장합니다. 방향을 혼동하기 쉬운데, downstream(이 패키지를 쓰는 쪽)이 아니라 upstream(이 패키지가 쓰는 쪽)입니다.
lint 태스크의 "dependsOn": []는 "아무것도 기다리지 않고 즉시 실행"을 의미합니다. 다른 태스크와 관계없이 독립적으로 병렬 실행됩니다.
각 필드가 캐싱에 미치는 영향을 정리하면 이렇습니다:
| 필드 | 역할 | 캐시 영향 |
|---|---|---|
dependsOn |
태스크 실행 순서 결정 | 선행 태스크 완료 여부가 해시에 반영 |
inputs |
해시 계산 대상 파일 지정 | 지정 파일 변경 시에만 캐시 무효화 |
outputs |
캐시에 저장할 산출물 경로 | 누락 시 파일 캐싱 불가 |
env |
해시에 포함할 환경 변수 | 값 변경 시 캐시 무효화 |
cache |
캐싱 활성화 여부 | false 설정 시 항상 새로 실행 |
한 가지 짚어두고 싶은 게 있는데, .env 파일은 inputs에 넣지 않는 것을 권장합니다. 대부분 .gitignore에 등록되어 있고, 팀원마다 내용이 다르면 해시 불일치로 캐시 히트율이 오히려 낮아집니다. 환경 변수는 env 필드로 관리하는 것이 맞습니다.
실전 적용
예시 1: outputs 설정으로 캐시 히트율 높이기
솔직히 처음 Turborepo 도입했을 때 "왜 캐시가 안 되지?" 싶었던 게 바로 outputs 누락 때문이었습니다. Turborepo는 outputs에 명시된 경로만 캐시에 저장하고 복원합니다. 빈 배열이거나 누락되면 파일 캐싱이 전혀 동작하지 않아 매번 전체 빌드가 발생합니다.
{
"tasks": {
"build": {
"outputs": [
".next/**",
"!.next/cache/**",
"dist/**",
"storybook-static/**"
]
}
}
}!.next/cache/**처럼 ! 접두사로 제외할 경로를 지정할 수 있습니다. Next.js의 경우 .next/cache는 Next.js 자체 내부 캐시라서 Turborepo 캐시에서 제외해야 충돌이 생기지 않습니다. 포함시키면 캐시 크기만 불필요하게 커집니다.
설정 후 실제로 캐시 히트가 잘 이뤄지고 있는지 확인하는 방법도 있습니다.
# 어떤 태스크가 캐시 히트인지 상세 리포트 출력
turbo run build --summarize
# 실제 실행 없이 실행 계획만 확인
turbo run build --dry-run--summarize 옵션을 쓰면 각 태스크별로 캐시 히트 여부, 실행 시간, 해시 정보를 볼 수 있어서 설정을 바꿀 때마다 효과를 바로 검증할 수 있습니다.
프레임워크별 권장 outputs 설정은 이렇습니다:
| 프레임워크 | 권장 outputs | 제외 경로 |
|---|---|---|
| Next.js | .next/** |
!.next/cache/** |
| Vite | dist/** |
— |
| Storybook | storybook-static/** |
— |
| NestJS | dist/** |
— |
| Jest | coverage/** |
— |
예시 2: Vercel 원격 캐시 연결 (가장 빠른 방법)
로컬 캐시는 내 머신에만 저장됩니다. 팀원이 같은 코드를 빌드해도 그 캐시를 공유할 수 없죠. 원격 캐시는 이 캐시 스토리지를 팀 전체가 공유하도록 만들어줍니다.
개발자 A가 feat/payment 브랜치를 빌드하면 그 결과가 원격에 올라갑니다. 개발자 B가 같은 브랜치를 체크아웃해서 빌드하면, 동일한 해시를 감지하고 실제 빌드 없이 캐시 결과를 내려받습니다. CI 파이프라인도 마찬가지입니다. 팀 규모가 클수록, CI 실행 빈도가 높을수록 효과가 극대화됩니다.
Vercel을 이미 사용하고 있다면 셋업이 정말 간단합니다.
# Vercel 계정으로 로그인
turbo login
# 프로젝트에 원격 캐시 연결
turbo linkturbo link를 실행하면 .turbo/config.json이 자동으로 생성됩니다. 내용은 아래와 같습니다:
{
"teamid": "team_xxxxx",
"apiurl": "https://vercel.com"
}CI 파이프라인(GitHub Actions)에서는 환경 변수 방식으로 설정합니다. .turbo/config.json을 커밋하지 않아도 되니 CI 환경에서 더 깔끔합니다.
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_REMOTE_ONLY: true
steps:
- uses: actions/checkout@v4
- run: pnpm install
- run: pnpm turbo build test lintTURBO_TOKEN은 Vercel 대시보드 → Account Settings → Tokens에서 발급받을 수 있고, TURBO_TEAM은 팀 슬러그입니다. GitHub Secrets에 등록해두면 됩니다. TURBO_REMOTE_ONLY: true는 CI 환경에서 로컬 캐시를 스킵하고 원격 캐시만 사용하도록 하는 옵션입니다.
예시 3: 자체 호스팅 원격 캐시 (Vercel 없이 운영하기)
Vercel 계정 없이 S3나 GCS에 직접 캐시를 올리고 싶다면 오픈소스인 ducktors/turborepo-remote-cache를 활용할 수 있습니다. Docker 하나로 올라가니 진입 장벽이 낮습니다.
# Docker로 캐시 서버 실행 (S3 스토리지 사용 예시)
docker run -d \
-e STORAGE_PROVIDER=s3 \
-e AWS_ACCESS_KEY_ID=xxx \
-e AWS_SECRET_ACCESS_KEY=xxx \
-e S3_BUCKET=my-turbo-cache \
-p 3000:3000 \
ducktors/turborepo-remote-cache서버를 올린 뒤에는 .turbo/config.json의 apiurl만 바꿔주면 됩니다.
{
"teamid": "my-team",
"apiurl": "http://my-cache-server:3000"
}지원하는 스토리지 백엔드가 다양해서 인프라 환경에 맞게 선택할 수 있습니다:
| 스토리지 | 환경 변수 | 비고 |
|---|---|---|
| AWS S3 | STORAGE_PROVIDER=s3 |
가장 보편적 |
| GCS | STORAGE_PROVIDER=gcs |
GCP 환경 |
| Cloudflare R2 | STORAGE_PROVIDER=s3 + R2 엔드포인트 |
S3 호환 API 활용 |
| Azure Blob | STORAGE_PROVIDER=azureBlob |
Azure 환경 |
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 빌드 속도 | 로컬 캐시만으로 2 |
| 팀 협업 | 팀원 간 캐시 공유로 첫 체크아웃 후 즉시 빌드 가능 |
| CI 비용 | 반복 빌드 없이 캐시 히트만으로 CI 시간 60~80% 감소 |
| 점진적 도입 | 기존 모노레포에 turbo.json 하나 추가로 시작 가능 |
| 병렬 실행 | 의존 관계 없는 태스크 자동 병렬화로 추가 설정 불필요 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Vercel 의존성 | 공식 원격 캐시는 Vercel 계정 필요, 무료 플랜 제한 있음 | 자체 호스팅(ducktors) 또는 Cloudflare Workers 활용 |
| 캐시 무효화 복잡성 | env, inputs 설정 오류 시 잘못된 캐시 히트 가능 |
변경 후 --force 플래그로 강제 재빌드 확인 |
| 초기 설정 비용 | outputs 경로를 태스크별로 정확히 파악해야 함 |
빌드 후 산출물 경로를 ls -la로 먼저 확인 |
| 비결정적 빌드 | 타임스탬프·랜덤값 포함 빌드는 캐시 효율 낮음 | 빌드 결과물에 동적 값 주입 최소화 |
| 시크릿 관리 | env 필드의 민감한 변수가 캐시 메타데이터에 노출될 위험 |
민감한 값은 passThroughEnv로 분리 |
실제로 저를 당황하게 했던 함정을 하나 언급하자면, 배포 태스크를 실수로 캐싱했던 적이 있습니다. 분명히 turbo deploy를 실행했는데 아무 일도 일어나지 않는 황당한 상황이었습니다. 캐시 히트가 발생해서 실제 배포 없이 완료된 것처럼 처리된 거였습니다. "cache": false를 빠뜨리면 이런 일이 생깁니다.
passThroughEnv: Turborepo 2.0에서 추가된 필드. 해시 계산에는 포함하지 않고 빌드 프로세스에만 환경 변수를 전달합니다. 보안 관련 시크릿을 캐시 해시 로직에서 격리할 때 유용합니다.
실무에서 가장 흔한 실수
-
outputs를 빈 배열로 두거나 누락하는 경우 — "캐시가 왜 안 되지?"의 원인 1위입니다.outputs가 없으면 파일 아티팩트 캐싱이 전혀 동작하지 않습니다. 빌드 후 생성되는 경로를 직접 확인하고 정확히 명시해두는 것을 권장합니다. -
deploy태스크에"cache": false를 빠뜨리는 경우 — 배포 태스크가 캐싱되면 실제 배포 없이 완료된 것처럼 처리됩니다. 재배포가 필요한 상황에서도 캐시 히트가 발생해서 아무 일도 일어나지 않는 상황이 생길 수 있습니다. -
!.next/cache/**를outputs제외 목록에서 빠뜨리는 경우 — Next.js 내부 캐시와 Turborepo 캐시가 충돌해서 빌드 결과가 예측 불가능해질 수 있습니다. Next.js 프로젝트라면 반드시!.next/cache/**로 제외하는 것을 권장합니다.
마치며
turbo.json의 outputs 설정 하나, 원격 캐시 연결 두 줄이 팀 전체의 빌드 경험을 완전히 바꿔놓을 수 있습니다. 핵심은 세 가지입니다. "어떤 파일을 보고 해시를 만들지(inputs), 어떤 결과물을 저장할지(outputs), 어떤 환경 변수를 해시에 포함할지(env)" — 이 세 가지를 제대로 잡으면 빌드 속도는 눈에 띄게 달라집니다. 다만 패키지 간 타입 의존성이나 비결정적 빌드 같은 추가적인 문제는 별도로 다뤄야 할 수 있다는 점도 염두에 두면 좋습니다.
지금 바로 시작해볼 수 있는 3단계:
turbo.json에outputs경로를 명시 —ls -la dist또는ls -la .next로 빌드 산출물 경로를 확인한 뒤 추가합니다.--summarize옵션으로 캐시 히트율 변화를 바로 확인할 수 있습니다.turbo login && turbo link로 Vercel 원격 캐시 연결 — 무료 플랜으로도 팀 단위 캐시 공유가 가능합니다. 팀원이 빌드한 결과를 내 로컬에서 그대로 받아쓰는 경험이 도입 이유를 바로 납득하게 해줍니다.- CI에
TURBO_TOKEN과TURBO_TEAM환경 변수 추가 — GitHub Actions 기준으로 두 줄이면 됩니다. 첫 번째 CI 실행 이후부터 동일한 해시의 빌드는 캐시 히트로 처리됩니다.
참고 자료
- Turborepo 공식 문서 — Remote Caching
- Turborepo 공식 문서 — Caching
- Turborepo 공식 문서 — Configuring turbo.json
- Turborepo 공식 문서 — Using Environment Variables
- Vercel 공식 — Remote Caching
- Vercel 블로그 — Iterate faster with Turborepo and Vercel Remote Cache
- DEV.to — Turborepo 2.0: Remote Caching, Task Pipelines, and What Actually Speeds Up CI
- GitHub — ducktors/turborepo-remote-cache (오픈소스 자체 호스팅)
- Leapcell — Optimizing CI/CD with Turborepo's Remote Caching
- johal.in — 2026 Monorepo Setup with Turborepo 2.0, pnpm 8.15, Next.js 15