Turborepo CI 캐시 히트율 최대 90%: `dependsOn`, `outputs`, `env` 핵심 설정
모노레포에서 Turborepo를 도입한 뒤 "캐시가 왜 이렇게 안 맞지?" 하며 당황한 경험, 저도 있습니다. 분명히 아무것도 바꾸지 않았는데 빌드가 처음부터 다시 돌아가고, CI는 여전히 10분 넘게 걸리고. 알고 보면 turbo.json 설정 하나가 문제인 경우가 대부분입니다.
Turborepo를 처음 접하시는 분들을 위해 한 줄만 설명하면: Turborepo는 모노레포에서 여러 패키지의 빌드·테스트·린트 같은 작업을 병렬로 실행하고, 변경이 없는 부분은 캐시에서 바로 복원해주는 도구입니다. 핵심은 캐시인데, 이 캐시가 제대로 동작하려면 turbo.json에서 세 가지 필드를 올바르게 설정해야 합니다.
dependsOn으로 실행 순서와 병렬성을 정확히 선언하고, outputs로 저장할 결과물을 명시하고, env로 캐시 해시에 포함할 환경변수를 격리하면 — 이 세 가지만으로도 CI 캐시 히트율을 최대 90%까지 끌어올릴 수 있습니다. Mercari 엔지니어링 팀은 이 방식으로 Turbo 태스크 실행 시간을 50%, 전체 CI Job 소요 시간을 30% 단축하는 성과를 냈습니다. 이 글에서는 각 필드가 캐시 해시 계산에 어떻게 영향을 미치는지 원리부터 짚고, 실무에서 바로 쓸 수 있는 설정 예시를 함께 살펴봅니다.
핵심 개념
Turborepo 캐시가 동작하는 원리
Turborepo는 태스크를 실행하기 전에 **입력값의 해시(fingerprint)**를 먼저 계산합니다. 이 해시가 이전에 캐시된 결과물과 일치하면 실제 실행 없이 결과물을 그대로 복원합니다. 해시가 다르면 태스크를 새로 실행하고 결과물을 캐시에 저장합니다.
캐시 해시란 소스 파일, 의존성, 환경변수, 설정 파일 등을 조합해 만든 고유 식별자입니다. 같은 해시 = 같은 결과물이라는 보장이 전제돼야 캐시가 신뢰할 수 있습니다.
캐시 히트율을 결정하는 건 결국 "해시에 무엇을 포함시키느냐"입니다. 너무 많이 포함하면 조금만 바뀌어도 미스가 나고, 너무 적게 포함하면 실제로 바뀐 상황인데도 캐시를 그대로 써버립니다.
기본적으로 Turborepo는 패키지 안에서 git이 추적하는 모든 파일을 해시 입력(inputs)으로 삼습니다. 이 범위를 좁히고 싶을 때 inputs 필드를 활용할 수 있습니다. .env 파일이나 *.log처럼 자주 바뀌는 파일이 기본 inputs에 포함돼 있으면, 코드를 전혀 바꾸지 않아도 캐시 미스가 계속 발생합니다. .gitignore에 등록된 파일은 자동으로 제외되기 때문에, 사실 inputs 문제의 대부분은 git 추적 파일 관리를 잘 하는 것만으로도 해결됩니다.
dependsOn — 실행 순서와 병렬성을 동시에 제어합니다
dependsOn은 현재 태스크가 실행되기 전에 완료돼야 하는 태스크를 선언합니다. 여기서 ^ 접두사 하나가 의미를 완전히 바꿉니다.
{
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {
"dependsOn": []
}
}
}| 선언 방식 | 의미 | 사용 예 |
|---|---|---|
"^build" |
의존 패키지들의 build가 먼저 완료돼야 함 |
공유 라이브러리 → 앱 순서 보장 |
"build" |
현재 패키지 내 build가 먼저 완료돼야 함 |
동일 패키지 내 태스크 순서 제어 |
[] (빈 배열) |
의존성 없음, 즉시 병렬 실행 가능 | lint, format 등 독립 태스크 |
lint에 dependsOn: []를 설정하면 다른 태스크와 무관하게 병렬로 즉시 실행됩니다. 반면 불필요한 의존성을 달아두면 그만큼 병렬성이 줄어듭니다. 정확한 선언이 곧 빠른 파이프라인입니다.
outputs — 이게 없으면 캐시가 아무 의미 없습니다
솔직히 처음엔 "Turborepo 쓰면 자동으로 캐시되겠지"라고 막연히 생각했는데, 여기에 중요한 함정이 있습니다.
outputs를 아예 선언하지 않은 경우와 outputs: [](빈 배열)를 명시한 경우는 미묘하게 다릅니다. 두 경우 모두 태스크 완료 상태 자체는 캐시에 기록됩니다. 그래서 다음 실행에서 >>> FULL TURBO가 뜨기도 하는데, 실제로 dist/ 폴더 같은 결과물 파일은 복원되지 않습니다. "캐시 히트인데 왜 빌드 결과가 없지?"라는 혼란이 여기서 생깁니다. 결과물을 캐시에서 복원하려면 outputs에 해당 경로를 반드시 명시해야 합니다.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": ["*.tsbuildinfo"]
}
}
}! 접두사는 제외 패턴입니다. .next/cache/**를 제외하는 이유가 있는데, Next.js 내부 빌드 캐시는 빌드할 때마다 자주 변경돼서 이걸 outputs에 포함하면 Turborepo 캐시가 불필요하게 무효화됩니다. TypeScript의 .tsbuildinfo를 outputs에 넣으면 증분 빌드 캐시도 함께 보존되니 타입 체크 속도도 빨라집니다.
glob 패턴
**는 모든 하위 디렉토리를 포함,*는 현재 디렉토리의 파일만 매칭합니다.dist/**는dist하위 모든 파일을 캐시 대상으로 잡습니다.
env — 환경변수 변화를 캐시 해시에 반영합니다
env에 선언된 환경변수의 값이 바뀌면 캐시 미스가 발생합니다. 반대로 선언하지 않은 변수는 값이 바뀌어도 Turborepo가 인식하지 못해, 예전 캐시를 그대로 써버릴 수 있습니다.
여기서 passThroughEnv를 왜 쓰는지 처음엔 직관적으로 이해가 안 됐는데, 이렇게 생각하면 명확합니다. CI=true는 CI 환경에서만 설정되는 변수인데, 이걸 캐시 해시에 포함하면 로컬 개발과 CI 환경의 해시가 항상 달라집니다. 실제 빌드 결과물에는 아무 영향도 없는 변수 때문에 캐시를 공유하지 못하게 되는 거죠. passThroughEnv로 분리하면 런타임에는 전달되지만 해시에는 포함되지 않아, 불필요한 캐시 미스를 막을 수 있습니다.
{
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_ANALYTICS_ID"],
"passThroughEnv": ["CI", "GITHUB_TOKEN"]
},
"test": {
"env": ["DATABASE_URL"],
"passThroughEnv": ["CI"]
}
}
}Turborepo 2.0부터 기본 envMode가 strict로 바뀌었습니다. 선언되지 않은 환경변수는 태스크에 아예 노출되지 않습니다. CI에서 자동 주입되는 GITHUB_TOKEN, CI 같은 변수들을 passThroughEnv로 명시하지 않으면 빌드가 실패할 수 있어서 주의가 필요합니다.
globalEnv에는NODE_ENV처럼 진짜 전체 태스크에 공통으로 영향을 미치는 변수만 두는 것을 권장합니다. 그 외에는 태스크 단위env로 격리하면 캐시 무효화 범위를 최소화할 수 있습니다.
실전 적용
예시 1: Next.js + 공유 UI 라이브러리 모노레포 전체 파이프라인
실무에서 가장 많이 보게 되는 구성입니다. apps/web이 packages/ui에 의존하는 형태인데, 이 의존 관계를 dependsOn: ["^build"]로 명확히 선언해줘야 순서가 보장됩니다.
// turbo.json (루트)
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_ANALYTICS_ID"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"env": ["DATABASE_URL"],
"passThroughEnv": ["CI"]
},
"lint": {
"dependsOn": [],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}dev 태스크의 persistent: true는 단순히 "오래 실행된다"는 표시 이상의 의미가 있습니다. 이 플래그가 없으면 Turborepo가 다른 태스크들이 모두 끝난 뒤 dev 프로세스를 강제 종료할 수 있습니다. 개발 서버가 갑자기 꺼지는 이유가 여기에 있는 경우도 있어서, cache: false와 함께 반드시 짝지어 설정하는 것을 권장합니다.
test의 dependsOn: ["^build"]는 빌드 결과물에 의존하는 통합 테스트에 적합한 설정입니다. 유닛 테스트처럼 빌드 결과물이 필요 없는 경우라면 dependsOn: []로 두어 병렬 실행하는 쪽이 더 빠릅니다.
예시 2: 패키지별 turbo.json으로 환경변수 격리하기
모노레포 안에 Next.js 앱이 여러 개라면, 각 앱마다 필요한 환경변수가 다릅니다. 이걸 전부 루트 turbo.json에 몰아넣으면 한 앱의 환경변수가 바뀔 때 다른 앱 캐시까지 함께 무효화됩니다.
Turborepo의 Package Configurations 기능으로 이 문제를 해결할 수 있습니다.
// packages/web/turbo.json
{
"extends": ["//"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_FEATURE_FLAG_X", "NEXT_PUBLIC_WEB_API_URL"]
}
}
}// packages/admin/turbo.json
{
"extends": ["//"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_ADMIN_API_URL", "NEXT_PUBLIC_ADMIN_KEY"]
}
}
}"extends": ["//"]는 루트 turbo.json 설정을 상속받겠다는 의미입니다. 환경변수 영향 범위를 패키지 단위로 격리하면 캐시 무효화 범위도 그만큼 줄어듭니다. web 앱의 환경변수가 바뀌어도 admin 앱의 캐시는 그대로 유지됩니다.
예시 3: globalEnv 과다 선언 방지
저도 초반에 "일단 다 넣어두자"는 마음으로 globalEnv에 온갖 변수를 몰아넣었다가, CI에서 변수 하나 바뀔 때마다 전체 캐시가 날아가는 경험을 했습니다.
// 문제가 되는 설정 — 변수 하나만 바뀌어도 전체 캐시 무효화
{
"globalEnv": ["API_URL", "DB_URL", "AUTH_SECRET", "CI", "VERCEL_URL", "GITHUB_TOKEN"]
}// 개선된 설정 — 영향 범위를 태스크 단위로 한정
{
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_API_URL"],
"passThroughEnv": ["VERCEL_URL"]
},
"test": {
"env": ["DATABASE_URL"],
"passThroughEnv": ["CI", "GITHUB_TOKEN"]
}
}
}Mercari 엔지니어링 팀이 Remote Cache를 도입하면서 공개한 사례를 보면, 환경변수 범위를 태스크 단위로 정리하고 outputs를 정확히 지정하는 것만으로 Turbo 태스크 실행 시간을 50%, 전체 CI Job 소요 시간을 30% 단축하는 성과를 냈습니다. Remote Cache와 캐시 서버를 CI와 같은 리전에 배치한 것도 효과적이었지만, 설정 자체가 탄탄하지 않으면 Remote Cache도 제대로 동작하지 않습니다.
장단점 분석
장점
| 항목 | 효과 |
|---|---|
| Remote Cache 활성화 | 변경 없는 코드 대상 CI 시간 최대 10배 단축 가능 |
정확한 outputs 설정 |
캐시 유효성 보장, stale artifact 버그 방지 |
태스크 단위 env 격리 |
무관한 환경변수 변경이 캐시에 영향 안 줌 |
dependsOn: [] 활용 |
불필요한 직렬 실행 제거, 병렬성 극대화 |
| Package Configurations | 패키지별 캐시 범위 세밀하게 제어 가능 |
| 로컬-CI 캐시 공유 | passThroughEnv 활용 시 로컬과 CI 간 캐시 재활용 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
turbo.json 수정 시 전체 캐시 무효화 |
설정 파일이 바뀌면 모든 캐시 리셋 | 초기에 신중하게 설계, 변경 최소화 |
strict envMode의 미선언 변수 차단 |
CI 주입 변수가 태스크에 전달 안 될 수 있음 | passThroughEnv로 명시적 허용 |
| volatile 파일의 inputs 포함 | .env, *.log 등이 포함되면 지속적 캐시 미스 발생 |
.gitignore 패턴과 정렬 |
| Remote Cache 비용 | Vercel 무료 플랜은 용량 제한 있음, 셀프호스팅 시 운영 비용 발생 | 팀 규모에 맞는 플랜 선택, ducktors/turborepo-remote-cache 검토 |
| Remote Cache 네트워크 지연 | 캐시 서버와 거리가 멀면 오히려 느려질 수 있음 | CI와 같은 리전에 캐시 서버 배치 |
| 팀 협업 시 설정 누락 | 여러 개발자가 각자 passThroughEnv 관리하면 변수 누락 발생 |
turbo.json을 PR 필수 리뷰 파일로 지정 |
stale artifact란 소스는 이미 변경됐는데 이전 빌드 결과물이 그대로 사용되는 상황입니다.
outputs를 정확히 지정하지 않으면 캐시 복원 시 이런 문제가 생길 수 있습니다.
실무에서 가장 흔한 실수
-
outputs를 아예 선언하지 않는 것 —>>> FULL TURBO가 뜨는데dist/폴더가 비어있다면 이 경우일 가능성이 높습니다. 태스크가 성공해도 결과물이 캐시에 저장되지 않아 복원도 되지 않습니다.turbo.json을 처음 작성할 때 각 태스크의outputs를 반드시 확인해보시면 좋습니다. -
globalEnv에 모든 환경변수를 몰아넣는 것 — 변수 하나만 바뀌어도 전체 모노레포의 캐시가 무효화됩니다.NODE_ENV처럼 진짜 전역적인 것만 두고, 나머지는 태스크 단위env로 격리하는 것을 권장합니다. -
dev태스크에cache: false와persistent: true를 빠뜨리는 것 — 개발 서버는 상태가 계속 유지되는데 캐싱이 켜져 있으면 예상치 못한 동작이 생길 수 있습니다.persistent: true가 없으면 Turborepo가 프로세스를 조기 종료할 수도 있어서, 두 옵션은 항상 함께 설정하는 것을 권장합니다.
마치며
dependsOn으로 실행 순서와 병렬성을 정확히 선언하고, outputs로 저장할 결과물을 명시하고, env로 캐시 해시에 포함할 환경변수를 격리하는 것 — 이 세 가지가 Turborepo 캐시를 제대로 동작하게 만드는 핵심입니다. 조건이 갖춰지면 CI 캐시 히트율을 최대 90%까지 끌어올릴 수 있습니다.
지금 turbo.json을 열어서 바로 점검해볼 수 있는 체크리스트입니다:
- 각 태스크에
outputs가 선언돼 있는가? -
globalEnv에NODE_ENV외의 변수가 들어있는가? → 태스크 단위env로 이동 검토 -
CI,GITHUB_TOKEN같은 CI 주입 변수가passThroughEnv에 있는가? -
dev태스크에cache: false와persistent: true가 함께 있는가? - 유닛 테스트 태스크에 불필요한
dependsOn: ["^build"]가 달려 있지 않은가?
점검 후 실제로 개선됐는지 확인하고 싶다면 지금 바로 시작할 수 있는 3단계가 있습니다:
-
globalEnv에 선언된 환경변수를 태스크별env와passThroughEnv로 분산하기 — 가장 빠르게 캐시 히트율을 높이는 방법입니다.CI,GITHUB_TOKEN같은 변수부터passThroughEnv로 옮겨보시면 효과를 바로 느낄 수 있습니다. -
turbo run build --dry-run으로 파이프라인 점검하기 — 실제 실행 없이 어떤 태스크가 어떤 순서로 계획되는지 확인할 수 있습니다. 의존 관계가 예상대로인지 먼저 살펴보시면 좋습니다. -
turbo run build --summarize로 캐시 히트 여부 확인하기 — 실행 후 각 태스크의 캐시 히트/미스 여부와 원인이 리포트로 출력됩니다.outputs가 누락된 태스크가 있다면 여기서 바로 확인됩니다.