Turborepo 캐시 히트율이 낮다면, 원인은 대부분 turbo.json의 dependsOn, inputs, outputs에 있습니다
Turborepo를 모노레포에 도입했는데 캐시가 제대로 맞지 않는다면, 원인은 대부분 turbo.json의 inputs, outputs, dependsOn 세 가지 중 하나입니다. README.md 한 줄 고쳤을 뿐인데 전체 빌드가 다시 도는 상황, 캐시 히트로 표시는 되는데 막상 dist/ 폴더가 비어 있는 상황 — 저도 처음 Turborepo를 붙이고서 이 두 가지 문제로 한참을 헤맸습니다. dependsOn으로 실행 순서를 정확히 잡고, inputs로 해시 계산 범위를 좁히고, outputs로 빌드 결과물 경로를 명시하는 것, 이 세 가지가 Turborepo 캐시 히트율을 결정합니다.
Turborepo는 Vercel이 개발한 JavaScript/TypeScript 모노레포용 빌드 시스템입니다. 여러 패키지가 한 저장소에 모인 구조에서 "이미 빌드한 결과물은 다시 빌드하지 않겠다"는 태스크 캐싱이 핵심입니다. 태스크를 실행하기 전에 입력값들의 해시를 계산하고, 동일한 해시가 이미 캐시에 있으면 재실행을 건너뜁니다. 이 해시 계산 범위와 결과물 저장 경로를 turbo.json에서 직접 지정하는 구조입니다.
이 글에서는 세 가지 설정이 각각 무엇을 하는지, 어떤 값이 잘못됐을 때 어떤 증상이 나타나는지, 그리고 실무에서 바로 쓸 수 있는 설정 패턴을 살펴봅니다. 글 끝에는 오늘 당장 복사해서 적용할 수 있는 turbo.json 템플릿을 얻어가실 수 있습니다.
핵심 개념
Turborepo가 캐시 히트를 판단하는 방식
Turborepo는 태스크를 실행하기 전에 해당 태스크의 "지문(fingerprint)"을 만듭니다. inputs에 지정된 파일들의 내용, env에 선언된 환경변수 값들, dependsOn으로 연결된 선행 태스크의 해시를 조합해 이 지문을 계산합니다. 이전 실행에서 같은 해시를 가진 캐시 항목이 있으면 outputs에 지정된 파일들을 복원하고 실행을 건너뜁니다.
입력 파일 해시 + 환경변수 값 + 의존 태스크 해시
↓
캐시 키(Cache Key)
↓
히트 → 결과물 복원 / 미스 → 실제 실행세 설정이 이 흐름의 각 단계를 담당합니다.
| 설정 | 역할 | 잘못됐을 때 증상 |
|---|---|---|
dependsOn |
실행 순서 및 의존 관계 정의 | 의존 패키지 빌드 전에 현재 태스크가 먼저 실행됨 |
inputs |
해시 계산에 포함할 파일 범위 | 관련 없는 파일 변경에도 캐시 미스 발생 |
outputs |
캐시에 저장할 결과물 경로 | 히트로 표시되지만 빌드 파일이 없음 |
dependsOn — 실행 순서를 정확히 잡아야 캐시도 의미가 생깁니다
dependsOn은 두 가지 형태로 씁니다. ^ 접두사가 붙으면 현재 패키지가 의존하는 패키지들(package.json의 dependencies/devDependencies)의 동명 태스크를 먼저 실행합니다. 접두사가 없으면 같은 패키지 내의 다른 태스크를 가리킵니다.
{
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
}
}
}
^build— 현재 패키지가 의존하는 모든 패키지의build태스크를 선행 실행합니다. 의존 패키지의 빌드 결과물이 준비되기 전에 현재 패키지 빌드가 시작되는 상황을 방지해줍니다.
여기서 test.dependsOn: ["build"](캐럿 없음)는 같은 패키지의 build가 먼저 완료돼야 한다는 의미입니다. 타입 선언 파일(.d.ts)처럼 빌드 결과물을 참조해야 하는 테스트가 있을 때 필요한 패턴입니다. 빌드 결과에 의존하지 않는 단순 유닛 테스트라면 이 항목을 빼도 무방합니다.
특정 패키지만 명시적으로 지정하고 싶다면 "pkg-a#build" 형식도 쓸 수 있습니다. 의존성 그래프 자동 추적 대신 특정 공유 라이브러리가 반드시 먼저 빌드돼야 하는 경우처럼 명시적 지정이 더 명확한 상황에서 유용합니다.
inputs — 해시 범위를 좁힐수록 히트율이 올라갑니다
기본값을 그대로 두면 패키지 디렉토리 안에서 Git이 추적하는 파일 전체가 해시 계산에 포함됩니다. Git 추적 파일이란 .gitignore에 걸리지 않아 Git이 변경을 감지하는 모든 파일을 말합니다. 문서나 테스트 픽스처를 손댔을 뿐인데 빌드 캐시가 무효화되는 이유가 여기 있습니다.
{
"tasks": {
"build": {
"inputs": ["src/**", "package.json", "tsconfig.json"]
},
"lint": {
"inputs": ["src/**", ".eslintrc.*"]
},
"test": {
"inputs": ["src/**", "tests/**", "jest.config.*"]
}
}
}여기서 한 가지 주의할 점이 있습니다. inputs를 명시하는 순간 .gitignore 기반의 자동 제외 로직이 비활성화됩니다. 기본 동작을 유지하면서 특정 파일만 제외하고 싶다면 $TURBO_DEFAULT$ 마이크로신택스를 쓰는 것이 훨씬 안전합니다.
{
"tasks": {
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!README.md", "!CHANGELOG.md"]
}
}
}
$TURBO_DEFAULT$—inputs에 포함하면 "Git이 추적하는 패키지 내 모든 파일"이라는 기본 동작을 그대로 유지합니다. 그 뒤에!패턴을 붙여 제외할 파일만 골라낼 수 있어서, 기본 동작을 통째로 대체하는 것보다 훨씬 안전합니다.
outputs — 빠뜨리면 캐시 히트가 아무 의미가 없습니다
솔직히 이 부분에서 가장 많은 시간을 날린 것 같습니다. outputs를 설정하지 않으면 Turborepo는 실행 로그는 캐시하지만 빌드 결과물 파일은 저장하지 않습니다. 그러면 캐시 히트가 발생해도 dist/ 폴더는 비어 있고, 이 파일을 필요로 하는 다운스트림 패키지 빌드가 결국 실패합니다.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
]
},
"test": {
"inputs": ["src/**", "tests/**"],
"outputs": ["coverage/**"]
}
}
}.next/cache/**를 !로 제외한 이유가 있습니다. Next.js가 자체적으로 관리하는 내부 빌드 캐시 디렉토리까지 Turborepo가 저장하면 캐시 저장소 크기가 불필요하게 커지고, 이 대용량 디렉토리가 매번 직렬화·역직렬화되면서 히트율도 오히려 떨어질 수 있습니다.
실전 적용
예시 1: 전형적인 모노레포 turbo.json 전체 구성
실무에서 가장 자주 보게 되는 구성을 그대로 옮겨봤습니다. Turborepo 2.x부터는 기존의 pipeline 키 대신 tasks 아래에 직접 정의하는 방식을 권장합니다(pipeline은 하위 호환을 위해 여전히 동작하지만, 새 프로젝트에서는 tasks로 작성하는 것이 좋습니다).
이 구성에서 제가 가장 자주 실수했던 항목은 test.dependsOn입니다. 타입 선언 파일을 참조하는 테스트처럼 빌드 결과가 선행돼야 할 때만 ["^build"]를 넣어두는 것을 권장합니다. 그렇지 않은 프로젝트에서는 이 항목을 빼면 불필요한 직렬 실행을 줄일 수 있습니다.
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["NODE_ENV", "CI"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tests/**", "jest.config.*"],
"outputs": ["coverage/**"]
},
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!README.md", "!*.md"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}각 설정의 의도를 정리하면 아래와 같습니다.
| 설정 | 값 | 의도 |
|---|---|---|
build.dependsOn |
["^build"] |
의존 패키지 빌드 완료 후 현재 빌드 시작 |
build.inputs |
src/**, package.json, tsconfig.json |
소스 외 파일 변경 시 캐시 무효화 방지 |
build.outputs |
dist/**, .next/** (내부 캐시 폴더 제외) |
빌드 결과물을 캐시에 저장 |
test.dependsOn |
["^build"] |
타입 선언 파일 등 빌드 결과에 의존하는 테스트 지원 |
lint.inputs |
$TURBO_DEFAULT$ + 마크다운 제외 |
문서 파일 변경 시 lint 재실행 방지 |
dev.cache |
false |
개발 서버는 캐시 자체가 의미 없음 |
예시 2: 환경변수를 캐시 키에 반영하기
API_URL이나 NEXT_PUBLIC_* 같은 환경변수가 바뀌었는데 오래된 빌드 결과물이 그대로 복원된다면, 해당 변수가 env 또는 globalEnv에 선언돼 있지 않은 것입니다. 이건 조용히 잘못된 결과물을 배포하게 만드는 가장 위험한 케이스입니다.
특히 Next.js의 NEXT_PUBLIC_* 변수는 런타임이 아닌 빌드 타임에 번들 결과물 안에 직접 인라인됩니다. 이 값이 달라지면 빌드 결과물 자체가 달라지므로 반드시 캐시 키에 반영해야 합니다. staging과 production이 다른 NEXT_PUBLIC_API_URL을 쓴다면, 이 변수가 env에 없을 경우 두 환경이 같은 캐시를 재사용하게 됩니다.
{
"globalEnv": ["NODE_ENV", "CI"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"env": ["API_URL", "NEXT_PUBLIC_APP_VERSION"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**"]
}
}
}
globalEnvvsenv—globalEnv는 모든 태스크의 캐시 키에 영향을 주는 전역 환경변수입니다.NODE_ENV나CI처럼 어떤 태스크에서든 결과가 달라질 수 있는 변수를 여기에 넣어두면 됩니다. 특정 태스크에서만 관련된 변수는 해당 태스크의env배열에 선언하는 것이 더 정확합니다.
Turborepo 2.2부터는 turbo.json에 선언되지 않은 환경변수를 태스크가 사용하면 경고를 출력해줍니다. 처음 도입할 때 이 경고를 주의 깊게 보면 누락된 환경변수를 찾는 데 큰 도움이 됩니다.
예시 3: 캐시 미스가 왜 발생하는지 추적하기
캐시 히트율이 생각보다 낮을 때, 어떤 파일이 해시를 바꾸고 있는지 추적하는 워크플로입니다. 저는 CI에서 캐시 미스가 잦을 때마다 이 순서로 확인합니다.
# 1. 실제 실행 없이 어떤 태스크가 실행될지 확인
turbo run build --dry=json
# 2. 두 번 실행해서 해시가 달라지는지 확인
turbo run build --summarize
# .turbo/runs/ 폴더의 JSON 파일에서 inputs 섹션을 비교
# 3. 변경된 패키지에 영향받은 태스크만 선택 실행 (PR 빌드 최적화에 유용)
turbo run build --affected--summarize 옵션을 쓰면 .turbo/runs/ 폴더에 실행 요약 JSON이 생성됩니다. 같은 태스크를 두 번 실행했는데 해시 값이 다르다면, JSON 파일에서 inputs 항목을 비교해 어떤 파일이 달라졌는지 바로 찾을 수 있습니다.
--affected는 캐시 디버깅보다는 "변경된 패키지에 영향받은 태스크만 골라 실행하는" 최적화 옵션으로, 역할이 조금 다릅니다. PR 빌드처럼 변경 범위가 제한된 상황에서 전체 빌드 대신 필요한 태스크만 돌릴 때 활용할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 세밀한 캐시 제어 | Glob 패턴으로 파일 단위까지 캐시 범위를 조정할 수 있습니다 |
| Remote Cache 공유 | Vercel Remote Cache 연동 시 팀원·CI 간 캐시를 공유해 반복 빌드 비용을 줄일 수 있습니다 |
| 환경변수 해시 반영 | 빌드 환경 차이를 캐시 키에 포함해 잘못된 결과물 복원을 방지합니다 |
| 증분 실행 | --affected 플래그로 변경된 패키지에 영향받은 태스크만 선택 실행할 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 초기 설정 비용 | inputs/outputs를 패키지마다 정확히 잡는 데 시간이 소요됩니다 |
--dry=json과 --summarize로 점진적으로 검증할 수 있습니다 |
inputs 명시 시 .gitignore 무효화 |
명시적 inputs 설정 시 자동 제외 파일도 해시에 포함될 수 있습니다 |
$TURBO_DEFAULT$ 마이크로신택스를 활용할 수 있습니다 |
| 환경변수 선언 누락 | 선언되지 않은 변수가 바뀌어도 오래된 캐시가 복원됩니다 | 2.2 이상 버전의 경고 메시지를 적극 활용할 수 있습니다 |
| Remote Cache 비용 | Vercel Remote Cache는 팀 플랜 이상에서 무제한 제공됩니다 | 자체 호스팅 Remote Cache 솔루션을 검토해볼 수 있습니다 |
실무에서 가장 흔한 실수
단점 표가 "무엇이 문제인가"를 정리한 것이라면, 아래는 이 실수들이 현장에서 실제로 어떤 결과로 이어지는지에 대한 이야기입니다.
outputs를 아예 안 쓴 경우 — 빌드 로그에는FULL TURBO가 찍히는데dist/폴더가 비어 있어 다음 패키지 빌드가 통째로 실패합니다. 캐시는 분명히 맞은 것 같은데 왜 빌드가 깨지지? 하며 몇 시간을 헤매게 되는 전형적인 패턴입니다.inputs기본값을 그대로 둔 경우 —CHANGELOG.md한 줄 업데이트했을 뿐인데 전체 모노레포 빌드가 다시 돕니다. CI 캐시 히트율이 낮은 팀의turbo.json을 열어보면inputs항목 자체가 없는 경우가 대부분입니다.- 환경변수를
env에 선언하지 않은 경우 — staging과 production이 다른API_URL을 쓰는데 두 환경에서 동일한 번들이 복원됩니다. 에러 없이 잘못된 결과물이 배포되기 때문에 발견 자체가 늦어지는 것이 문제입니다.
마치며
outputs를 명시해 결과물을 캐시에 저장하고, inputs로 해시 범위를 소스 파일 위주로 좁히고, 환경변수를 env 또는 globalEnv에 선언하는 것 — 이 세 가지만 잡아도 Turborepo 캐시 히트율에서 눈에 띄는 개선을 경험할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계가 있습니다.
turbo run build --dry=json명령으로 현재 태스크 구성을 살펴볼 수 있습니다.outputs가 비어 있는 태스크가 있다면 그게 1순위 개선 대상이고, 이 항목만 채워도 캐시 히트 이후 빌드 결과물이 정상적으로 복원되기 시작합니다.turbo run build --summarize를 두 번 연속 실행하고.turbo/runs/폴더의 JSON 파일에서 해시 값을 비교해볼 수 있습니다. 해시가 달라진다면inputs섹션에서 어떤 파일이 변경됐는지 확인하고, 그 파일들을inputs에서 제외하거나$TURBO_DEFAULT$와!패턴조합으로 좁혀나가면 불필요한 캐시 미스가 줄어듭니다.process.env로 환경변수를 참조하는 태스크에서 해당 변수가env또는globalEnv에 선언돼 있는지 확인해볼 수 있습니다. Turborepo 2.2 이상을 사용 중이라면 빌드 시 출력되는 경고 메시지가 누락된 변수를 찾는 작업을 크게 덜어주고, 이 확인만으로 staging/production 간 잘못된 캐시 복원 문제를 예방할 수 있습니다.