- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 캐시 용량을 늘렸는데 CI 시간은 그대로였다
- 캐시 히트는 크기가 아니라 키의 문제다
- 해시 입력을 좁히는 세 가지 손잡이
- affected 판정 — 실행하지 않는 것이 가장 빠른 실행이다
- 원격 캐시를 공유할 때 생기는 신뢰 경계
- 캐시 오염과 헤르메틱성이 같은 문제인 이유
- 히트율을 정직하게 측정하기
- 마치며 — 캐시는 저장 장치가 아니라 계약이다
- 참고 자료
들어가며 — 캐시 용량을 늘렸는데 CI 시간은 그대로였다
모노레포 CI가 느리다는 이슈가 올라오면 가장 먼저 나오는 제안이 대개 같습니다. "캐시가 자꾸 밀려나니까 용량을 늘리자." 그래서 저장소 한도를 올리고, 러너 디스크를 키우고, 아티팩트 보관 기간을 늘립니다. 그리고 다음 주에도 CI는 12분이 걸립니다.
원인은 대개 용량이 아닙니다. 캐시가 저장은 되는데 조회에서 빗나가고 있는 것입니다. 빌드마다 조금씩 다른 환경 변수가 해시에 섞여 들어가거나, 타임스탬프가 산출물에 박히거나, 러너 이미지가 바뀌면서 툴체인 버전이 달라지거나, 캐시 키에 커밋 SHA가 들어가서 처음부터 재사용될 수 없는 키를 만들고 있거나. 이 상태에서 저장소를 키우면 쓰레기 데이터가 더 오래 남을 뿐입니다.
이 글은 Turborepo·Nx·Bazel·GitHub Actions 캐시를 재료로, 정확한 캐시 키와 헤르메틱한 입력이라는 한 축으로 모노레포 CI 캐시를 정리합니다. 빌드가 느린 일반적인 이유는 내 빌드는 왜 느린가 편에서 다뤘고, 콘텐츠 주소 지정 저장소의 원리는 콘텐츠 주소 지정 저장소 편에 있습니다. 여기서는 모노레포 CI에 특화된 부분만 봅니다.
캐시 히트는 크기가 아니라 키의 문제다
모든 빌드 캐시는 같은 계약 위에 서 있습니다. 입력이 같으면 출력도 같다. 캐시 키는 이 "입력"을 해시한 값이고, 히트율은 키가 얼마나 정확하게 입력을 포착하는가에 비례합니다. 키가 실제보다 넓으면(불필요한 것까지 포함하면) 히트가 나지 않고, 키가 실제보다 좁으면(중요한 입력을 빠뜨리면) 틀린 결과를 재사용합니다.
도구마다 이 계약을 지키는 방식이 다릅니다.
| 도구 | 캐시 단위 | 해시에 들어가는 것 | 헤르메틱성 보장 | 원격 캐시 |
|---|---|---|---|---|
| Turborepo | 패키지 단위 태스크 | 패키지 소스, 선언한 env, 내부 의존 패키지의 해시, 태스크 정의 | 규약 기반. 선언하지 않은 환경 변수는 strict 모드에서 차단 | Vercel 호스팅 또는 OpenAPI 호환 자체 서버 |
| Nx | 프로젝트 단위 태스크 | namedInputs로 정의한 파일 집합, env, 의존 프로젝트 해시 | 규약 기반. inputs 정의가 곧 계약 | Nx Cloud 또는 자체 호스팅 캐시 |
| Bazel | 액션(명령 하나) | 선언된 입력 파일, 명령줄, 환경, 툴체인 전체 | 샌드박스로 강제. 선언 안 한 파일은 보이지 않음 | gRPC 원격 캐시 프로토콜 |
| GitHub Actions cache | 임의의 디렉터리 tarball | 사용자가 문자열로 직접 작성한 키 | 없음. 전적으로 작성자 책임 | 저장소 범위, 브랜치 스코프 |
표에서 읽어야 할 것은 성능 순위가 아니라 책임의 위치입니다. Bazel은 샌드박스로 선언되지 않은 입력을 물리적으로 차단하므로 헤르메틱성을 도구가 강제합니다. Turborepo와 Nx는 "선언한 것이 전부"라는 규약을 두고 사용자가 지키기를 기대합니다. GitHub Actions 캐시는 키 문자열을 사람이 손으로 씁니다 — 가장 유연하고, 가장 자주 틀립니다.
그래서 실무의 첫 진단은 언제나 같습니다. 왜 미스가 났는지 도구에게 물어보는 것입니다. 캐시가 밀려나서인지, 키가 달라서인지, 애초에 캐시 대상이 아닌지를 구분하지 않으면 어떤 조치도 추측입니다.
해시 입력을 좁히는 세 가지 손잡이
키가 틀리는 원인은 실무에서 거의 항상 셋 중 하나입니다.
첫째, 파일 입력이 너무 넓습니다. 기본값은 대개 "패키지 안의 모든 파일"입니다. README를 고쳐도 테스트가 다시 돌고, 그 패키지에 의존하는 하위 그래프 전체가 무효화됩니다. 태스크별로 실제 입력만 선언해야 합니다.
// turbo.json — 태스크별 입력을 좁힌다
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json"],
"outputs": ["dist/**"]
},
"test": {
"inputs": ["src/**", "tests/**", "vitest.config.ts"],
"outputs": []
}
}
}
Nx는 같은 일을 namedInputs로 합니다. 한 번 정의해 두고 프로젝트마다 재사용하는 구조라 대규모 저장소에서 관리가 쉽습니다.
// nx.json
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.md"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": {
"build": { "inputs": ["production", "^production"], "cache": true }
}
}
둘째, 환경 변수가 새어 들어옵니다. 이건 양방향으로 틀립니다. CI 벤더가 주입하는 GITHUB_RUN_ID 같은 값이 해시에 들어가면 히트가 영원히 나지 않고, 반대로 빌드 결과를 실제로 바꾸는 NODE_ENV나 API 엔드포인트가 해시에서 빠지면 개발 빌드가 프로덕션 캐시로 재사용됩니다.
Turborepo의 strict 환경 모드가 이 문제의 표준 해법입니다. 기본값이 strict이며, env·globalEnv로 명시하지 않은 변수는 태스크 런타임에서 아예 보이지 않습니다. 빌드가 갑자기 실패한다면 그 변수가 원래 몰래 쓰이고 있었다는 증거입니다.
{
"globalEnv": ["NODE_ENV"],
"globalPassThroughEnv": ["CI", "GITHUB_ACTIONS"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_API_URL", "SENTRY_*"],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
passThroughEnv는 값이 태스크에 전달되되 해시에는 들어가지 않습니다. 로깅용 플래그처럼 결과를 바꾸지 않는 변수 전용입니다. 여기에 결과를 바꾸는 변수를 넣으면 그 순간 캐시가 거짓말을 시작합니다.
셋째, 산출물에 비결정적 값이 박힙니다. 빌드 타임스탬프, 절대 경로, 빌드 번호, 랜덤 청크 ID. 이건 키의 문제가 아니라 출력의 문제인데, 결과적으로 상위 태스크의 입력을 바꿔 캐시 체인을 무너뜨립니다. 재현 가능한 빌드의 표준 대응은 SOURCE_DATE_EPOCH를 커밋 시각으로 고정하고 경로를 상대화하는 것입니다.
# 커밋 시각을 빌드 타임스탬프로 고정한다
export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"
# 같은 입력에서 두 번 빌드해 산출물이 바이트 단위로 같은지 확인
pnpm build && cp -r dist /tmp/build-a
rm -rf dist && pnpm build && cp -r dist /tmp/build-b
diff -r /tmp/build-a /tmp/build-b && echo "reproducible"
이 3단계 확인은 캐시 작업의 첫날에 해야 할 일입니다. 산출물이 재현되지 않으면 그 위의 모든 캐시 논의가 무의미합니다.
affected 판정 — 실행하지 않는 것이 가장 빠른 실행이다
캐시 히트도 공짜가 아닙니다. 원격 캐시 조회는 네트워크 왕복이고, 산출물 다운로드와 압축 해제도 시간입니다. 태스크가 수천 개인 저장소에서는 "전부 조회해서 전부 히트"보다 "애초에 대상이 아니어서 조회조차 하지 않음"이 훨씬 빠릅니다.
# Turborepo — origin/main 이후 변경된 패키지와 그 의존자만
turbo run build test --filter="...[origin/main]"
# Nx — 프로젝트 그래프와 git 히스토리로 affected 판정
nx affected -t build test --base=origin/main --head=HEAD
# 무엇이 왜 선택됐는지 눈으로 확인
nx show projects --affected --base=origin/main
nx graph --affected --base=origin/main
# Bazel — 변경된 파일에서 역의존 대상 질의
bazel query "rdeps(//..., set($(git diff --name-only origin/main)))" --output=label
여기서 자주 하는 실수가 베이스 커밋을 잘못 잡는 것입니다. PR 워크플로에서 --base=origin/main을 쓰면 main이 앞서 있을 때 관련 없는 변경까지 affected로 들어옵니다. 정확한 기준은 병합 기준점입니다.
BASE="$(git merge-base origin/main HEAD)"
nx affected -t build --base="$BASE" --head=HEAD
그리고 얕은 클론이면 이 계산 자체가 불가능합니다. GitHub Actions의 checkout 액션은 기본이 depth 1이므로, affected를 쓰는 워크플로는 히스토리를 충분히 받아야 합니다.
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 또는 최소한 병합 기준점이 포함될 만큼
affected 판정이 신뢰를 잃는 지점도 알아 둘 값어치가 있습니다. 프로젝트 그래프에 잡히지 않는 암묵적 의존 — 런타임에 문자열로 조립하는 모듈 경로, 생성 코드, 공유 설정 파일, 도커 베이스 이미지 — 이 있으면 바뀌었는데도 affected에서 빠집니다. 이런 항목은 sharedGlobals나 globalDependencies에 명시적으로 넣어 전역 무효화 대상으로 선언하는 편이 안전합니다. 히트율이 조금 떨어지더라도 틀린 결과를 통과시키는 것보다 낫습니다.
원격 캐시를 공유할 때 생기는 신뢰 경계
원격 캐시의 이득은 공유 범위에 비례합니다. CI 러너끼리만 공유하면 같은 커밋의 재실행에서만 이득이고, 개발자 머신까지 넣으면 아침에 main을 받은 사람이 빌드 없이 바로 시작합니다.
그런데 공유 범위를 넓히는 순간 문제가 하나 생깁니다. 캐시에 쓸 수 있는 주체가 곧 다른 사람의 빌드 산출물을 결정합니다. 노트북에서 오염된 산출물을 하나 올리면, 그것을 내려받은 모든 사람의 빌드가 그 결과를 씁니다. 그래서 실무 규칙은 단순합니다.
- 쓰기는 신뢰된 CI만. 개발자 머신과 포크 PR은 읽기 전용입니다.
- 쓰기 주체는 재현 가능한 환경에서만 실행. 고정된 컨테이너 이미지, 고정된 툴체인 버전.
- 산출물에 서명. 다운로드 시 검증에 실패하면 미스로 처리합니다.
Turborepo는 서명을 지원합니다. turbo.json에서 켜고 키를 환경 변수로 주면 HMAC-SHA256 서명이 붙고, 검증 실패한 아티팩트는 무시하고 캐시 미스로 처리합니다.
{ "remoteCache": { "signature": true } }
# CI: 쓰기 권한 있는 신뢰된 잡
export TURBO_API="https://cache.example.internal"
export TURBO_TEAM="platform"
export TURBO_TOKEN="***"
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="***"
turbo run build
# 개발자 머신 / 포크 PR: 읽기 전용 토큰만 배포
export TURBO_TOKEN="read-only-***"
자체 호스팅도 어렵지 않습니다. Turborepo는 원격 캐시 API의 OpenAPI 명세를 공개하고 있어서 S3 호환 스토리지 앞에 얇은 서버를 두면 됩니다. Bazel은 gRPC 원격 캐시 프로토콜을 쓰며, 쓰기 권한을 잡별로 분리하는 플래그가 있습니다.
# 읽기 전용 소비자(개발자, 포크 PR)
bazel build //... \
--remote_cache=grpcs://cache.example.internal \
--noremote_upload_local_results
# 쓰기 주체(신뢰된 CI)
bazel build //... \
--remote_cache=grpcs://cache.example.internal \
--remote_upload_local_results
캐시 오염과 헤르메틱성이 같은 문제인 이유
2026년은 CI 캐시가 공급망 공격 경로라는 사실이 반복해서 확인된 해였습니다. 5월 11일 TanStack 사고에서는 오염된 캐시가 main 브랜치 스코프에 기록된 뒤 악성 패키지 버전들이 연쇄 배포됐다고 보고됐습니다. GitHub은 6월 26일 체인지로그에서 신뢰되지 않은 트리거에 대해 읽기 전용 캐시 토큰을 발급하도록 기본값을 바꿨습니다.
바뀐 내용은 정확히 이렇습니다. 저장소 쓰기 권한 없이 트리거될 수 있는 이벤트 — pull_request_target, issue_comment, 포크 PR에서 파생된 workflow_run — 이면서 실행 컨텍스트와 캐시 스코프가 기본 브랜치 SHA에서 오는 경우, 캐시 토큰이 읽기 전용이 됩니다. push, schedule, workflow_dispatch 같은 신뢰된 트리거와, 기본 브랜치가 아닌 스코프를 쓰는 pull_request·release는 읽기-쓰기를 유지합니다. 따라서 캐시 저장을 하던 워크플로가 위 조건에 걸린다면, 저장은 push 트리거 워크플로로 옮기고 나머지는 복원만 하도록 나눠야 합니다.
여기서 중요한 점은 이게 접근 제어 조치이지 근본 해결이 아니라는 것입니다. 근본 문제는 캐시 항목이 "무엇으로부터 만들어졌는지"를 증명하지 않는다는 데 있습니다. 헤르메틱한 빌드에서는 캐시 키가 입력 전체의 해시이므로 오염된 산출물을 정당한 키에 밀어 넣기 어렵습니다. 반대로 키가 사람이 쓴 문자열이면 — GitHub Actions 캐시가 그렇습니다 — 키만 맞추면 아무 내용이나 넣을 수 있습니다.
실무에서 지킬 규칙을 정리하면 이렇습니다.
- 신뢰 경계를 넘는 캐시 공유를 없앨 것. 포크 PR과 기본 브랜치가 같은 키 공간을 쓰지 않게 키에 스코프를 넣습니다.
- 신뢰되지 않은 입력으로 만든 파일을 캐시에 넣지 말 것. GitHub은 이 패턴을 잡는 CodeQL 질의를 제공합니다 — 코드 인젝션을 통한 캐시 오염, 신뢰할 수 없는 파일 캐싱.
- 캐시 항목의 TTL을 짧게 잡을 것. GitHub 기본은 마지막 접근 기준 7일 슬라이딩이라, 오염이 일주일 살아남을 수 있습니다.
- 의존성 캐시와 빌드 산출물 캐시를 분리할 것. 전자는 락파일 해시로 결정론적이지만, 후자는 실행 결과이므로 신뢰 요구 수준이 다릅니다.
CI 파이프라인 전반의 신뢰 경계는 CI 에이전트와 프롬프트 인젝션 편에서 더 넓게 다뤘습니다.
히트율을 정직하게 측정하기
캐시 작업의 성과 보고에서 가장 흔한 과장 세 가지가 있습니다.
같은 머신에서 두 번 돌린 시간을 비교하는 것. 두 번째 실행은 로컬 캐시에 맞습니다. 원격 캐시가 실제로 동작하는지는 알 수 없습니다. 측정은 반드시 새 러너, 새 클론에서 해야 합니다.
affected 스킵과 캐시 히트를 합쳐서 세는 것. 둘은 다른 최적화이고 실패 양상도 다릅니다. affected가 과소 판정하면 틀린 빌드를 통과시키고, 캐시가 과소 히트하면 그냥 느려집니다. 합쳐 놓으면 어느 쪽이 나빠졌는지 알 수 없습니다.
태스크 개수 기준 히트율만 보는 것. 사소한 lint 태스크 900개가 히트하고 무거운 빌드 10개가 미스면 히트율은 98퍼센트인데 CI 시간은 그대로입니다. 히트율은 절약된 시간 기준으로도 함께 봐야 합니다.
측정 자체는 도구가 제공합니다.
# Turborepo — 실행 요약을 JSON으로 남기고 캐시 상태를 집계
turbo run build --summarize
jq '[.tasks[] | {task: .taskId, status: .cache.status, ms: .execution.duration}]' \
.turbo/runs/*.json
# 두 실행의 요약을 비교해 어떤 입력이 달랐는지 찾는다
turbo run build --dry=json > /tmp/run-a.json
# (다른 환경에서 다시 실행)
diff <(jq -S . /tmp/run-a.json) <(jq -S . /tmp/run-b.json)
# Bazel — 두 실행의 액션 로그를 비교해 비헤르메틱 요소를 찾는다
bazel build //... --execution_log_compact_file=/tmp/exec-ci.log
bazel build //... --execution_log_compact_file=/tmp/exec-local.log
# 액션 키가 같은데 히트가 없다면 설정 문제, 키가 다르다면 입력이 다른 것
Bazel 문서의 진단 원칙이 도구를 불문하고 그대로 적용됩니다 — 액션 키가 다르면 입력이 다른 것이고, 액션 키가 같은데 히트가 없으면 캐시 설정이 문제입니다. 이 두 갈래를 먼저 나누면 조사 범위가 절반으로 줄어듭니다.
목표 수치에 대해서도 솔직하게 적어 두겠습니다. 원격 캐시가 정상 동작하는 저장소에서 main 대비 작은 PR의 히트율은 90퍼센트대가 나옵니다. 반복 실행에서 80퍼센트를 밑돈다면 키나 스토리지 쪽에 문제가 있다고 보는 것이 합리적입니다. 다만 이 숫자는 저장소 구조와 태스크 분포에 따라 크게 달라지므로, 남의 벤치마크가 아니라 자기 저장소에서 2주간의 추세로 판단해야 합니다.
마치며 — 캐시는 저장 장치가 아니라 계약이다
캐시 튜닝을 저장 용량 문제로 접근하면 거의 언제나 실패합니다. 캐시는 "입력이 같으면 출력도 같다"는 계약이고, 히트율은 그 계약을 얼마나 정확하게 기술했는지의 지표입니다.
- 먼저 재현성부터 확인하세요. 같은 입력에서 두 번 빌드해 바이트가 다르면, 캐시 논의는 그 뒤의 일입니다.
- 태스크별
inputs와env를 좁히고, strict 환경 모드로 새는 변수를 드러내세요. 빌드가 깨진다면 원래 몰래 쓰이던 입력을 찾은 것입니다. - affected는 캐시보다 앞선 최적화입니다. 대신 암묵적 의존은 전역 무효화 대상으로 명시하세요.
- 원격 캐시 쓰기는 신뢰된 CI로 제한하고, 서명을 켜고, 신뢰되지 않은 트리거는 읽기 전용으로 두세요. 2026년의 사고들은 전부 이 경계가 없어서 생겼습니다.
- 측정은 새 러너·새 클론에서, affected와 캐시를 분리해서, 절약된 시간 기준으로 하세요.
캐시가 커서 빨라지는 CI는 없습니다. 입력을 정확히 아는 CI가 빨라질 뿐입니다.
참고 자료
- Turborepo — Caching 개념 문서
- Turborepo — 환경 변수와 strict 모드
- Turborepo — Remote Caching과 자체 호스팅, 아티팩트 서명
- Nx — Run Only Tasks Affected by a PR
- Nx — Distribute Task Execution (Nx Agents)
- Bazel — Remote Caching
- Bazel — Debugging Remote Cache Hits (실행 로그 비교)
- GitHub Docs — Dependency caching reference
- GitHub Changelog — Read-only Actions cache for untrusted triggers (2026-06-26)
- GitHub Changelog — Actions 캐시 10GB 초과 허용 (2025-11-20)
- CodeQL — Cache Poisoning via low-privileged code injection
- CodeQL — Cache Poisoning via caching of untrusted files
- 내 빌드는 왜 느린가 (관련 글)
- 콘텐츠 주소 지정 저장소 (관련 글)
- CI 에이전트와 프롬프트 인젝션 공급망 (관련 글)