이 글은 사내에서 운영하는 두 monorepo — 디자인 시스템과 공통 유틸 패키지 CI에서 죽어 있던 cache를 살려, PR 머지 게이트를 단축시킨 과정을 정리합니다. 유틸 레포 기준 3분 46초에서 약 1분으로, 디자인 시스템 레포 기준 8~9분에서 약 29초로 -94% 까지 줄였습니다!. 두 레포 모두 cache 장치가 "없었던" 것이 아니라 "있었지만 작동하지 않았다"는 점에서 출발입니다. 패키지 코드는 한 줄도 바꾸지 않았고, 변경은 전부 workflow와 설정 수준이었습니다.
글의 절반은 Turborepo(JavaScript monorepo를 위한 빌드 시스템으로, 패키지 간 의존성을 읽어 빌드·테스트를 조율하고 결과를 cache합니다)의 cache가 어떤 원리로 안전한지를 다루고, 나머지 절반은 그 원리에 비추어 무엇이 고장 나 있었고 무엇을 배웠는지를 다룹니다.
발단: 문서 한 줄짜리 PR도 CI가 3분
최근, 사내 패키지 MCP가 서빙하는 문서에 오타가 있어서, 한 줄을 수정한 PR 올렸는데, CI가 3분대로 걸렸습니다. 사실상 패키지 변경분이 없기에, 코드가 한 글자도 바뀌지 않았는데 빌드와 테스트가 전부 돌았던 것입니다.
스텝별로 시간을 쪼개 보니 절반이 빌드, 4분의 1이 테스트였습니다. 둘 다 Turborepo가 cache로 건너뛸 수 있는 작업인데 매번 풀로 돌고 있었습니다. 유틸 레포를 고친 뒤 "디자인 시스템 레포도 같은 상태인가"를 확인해 보니, 이쪽은 한술 더 떠서 cache 장치가 두 개나 있는데 둘 다 작동하지 않는 상태였습니다.
판단과 보관 — cache라는 단어를 둘로 쪼개기
이미지 출처: Turborepo 공식 사이트
이번 작업에서 얻은 가장 중요한 인사이트부터 정리합니다. CI에서 "cache"라는 단어는 혼동되기 쉽습니다. Github Actions 에도 cache가, Turborepo 에서도 cache 가 있기 때문입니다. 같은 용어로 서로 다른 두 시스템을 동시에 가리킵니다. 그러나 둘의 역할은 완전히 다릅니다. Turborepo의 cache와 GitHub actions/cache (workflow 실행 사이에 디렉토리를 저장하고 복원해 주는 GitHub의 key-value 저장 기능입니다)로 구분 지어야하고, 중요한 건 cache가 "있다/없다"가 아니라, 누가 판단하고 누가 보관하는가로 나눠 봐야 합니다. 역할을 비유하자면 Turborepo는 무엇을 다시 해야 하는지 판단하는 두뇌이고, GitHub actions/cache는 그 결과물을 들고만 있는 창고입니다.
ㅤ | Turborepo (판단) | GitHub actions/cache (보관) |
책임 | "무엇을 다시 빌드, 테스트, 린트 해야 하는가"의 판단 | 결과물의 보관과 운반 |
하는 일 | 태스크별 해시 계산, 의존성 그래프 기반 무효화 전파, 적중 시 outputs 복원과 로그 재생 | 키-값 저장과 복원, prefix 폴백, 브랜치 단위 격리, 레포당 10GB 한도 내 오래 안 쓴 것부터 정리 |
하지 않는 일 | run 사이의 저장 (CI 러너는 휘발성이므로) | 유효성 판단 |
실수의 결과 | 잘못 설정하면 틀린 CI가 조용히 통과합니다 | 잘못 설정시 내용이 틀렸을 떄에도 "복원 성공" 로그가 뜹니다. |
일반적인 CI run 흐름에서 둘은 다음과 같이 분업합니다. 이중에 테스크라는 용어를 쓰는데, 이는 다음과 같습니다.
여기서 태스크란 하나의 패키지에서 실행되는 하나의 명령 단위입니다.button#build,core#test처럼패키지#스크립트꼴로 적으며, package.json의 scripts에 정의된build,test,lint같은 명령 하나가 패키지 하나에 묶인 것이 곧 태스크 하나입니다. 그래프의 노드도, 해시가 매겨지는 단위도, cache 적중/미스가 판정되는 단위도 전부 이 태스크입니다. 이 글에서 "전 패키지 무효화", "60개 태스크 중 58개 적중" 같은 표현은 모두 이 단위를 센 것입니다.
[잡 시작] │ ▼ actions/cache: .turbo/cache 디렉토리를 통째로 복원 (내용은 모름) │ ▼ turbo run build test ├─ 태스크별 해시 계산 ├─ 같은 키가 디렉토리에 있음 → 실행 생략, outputs 복원 + 로그 재생 └─ 없음 → 실제 실행 후 해시를 키로 저장 │ ▼ actions/cache: 잡 종료 시 .turbo/cache 저장 (같은 키가 이미 있으면 조용히 스킵 ← 뒤에서 볼 고장의 근원)
사실 기존의 CI 가 매번 고장난 이유는 이 경계에서 났습니다. actions/cache는 Turborepo가 무엇을 넣었는지 모르고, Turborepo는 actions/cache가 무엇을 돌려줬는지 자기 해시로 검증할 뿐입니다. 그래서 actions/cache의 "Cache restored successfully"라는 로그는 유용한 cache였다는 뜻이 아닙니다. 진실은 turbo의
Cached: N cached, M total 로그에만 있습니다.Turborepo는 어떻게 판단하는가 — 그래프와 해시
판단 원리를 이해하면 보관 쪽이 단순해도 되는 이유가 따라 나옵니다. Turborepo 가 어떻게 판단하는지 이를 꺼내서 어떻게 확인하는지 등 크게는 그래프 구축, 해시 계산, 조회 세 단계로 나눠 봅니다.
첫째, 그래프 구축입니다. turbo는 얼핏 보면 간단하게 동작합니다. 복잡한 모노레포 환경을 통제함에도 불구하고, Turborepo는 코드를 분석하지 않습니다. 레포에 이미 선언돼 있는 두 가지를 읽어 태스크의 DAG를 만듭니다. DAG(Directed Acyclic Graph)는 글자 그대로 풀면 "방향이 있고(Directed) 순환이 없는(Acyclic) 그래프(Graph)"입니다. 간선에 방향이 있어 누가 누구에 의존하는지가 표현되고, 따라가도 제자리로 돌아오는 순환이 없습니다. 순환이 없다는 성질 하나에서 두 가지가 따라옵니다.
- "의존관계 파악부터 먼저"라는 실행 순서가 존재한다. 순환 의존을 에러로 잡아내는 이유도 이것입니다.
- 서로 의존이 없는 노드들은 동시에 실행해도 안전하다고 판단합니다.
turbo가 빌드 순서와 병렬도를 매번 묻지 않고, 사람의 지시 없이 스스로 결정하는 근거가 이것이고, 해당 근거를 통해 굳이 빌드를 돌려보지 않아도 되는 것이죠.
앞서 말한 이러한 그래프를 만들려면 재료가 필요합니다. 그러한 재료가 되는 선언은 두 곳에 있습니다.
// 패키지 그래프 — 각 package.json의 워크스페이스 의존 선언 { "dependencies": { "@scope/core": "workspace:*" } } // → "button은 core에 의존한다" // 태스크 그래프 — turbo.json의 dependsOn { "test": { "dependsOn": ["^build"] } } // → ^는 "내 의존 '패키지들'의"라는 뜻. // button#test는 core#build가 끝나야 시작할 수 있다
둘째, 해시 계산입니다. 태스크마다 하나의 지문(해시)을 만드는데, 재료는 네 가지입니다.
재료 | 예 | 바뀌면 |
패키지의 입력 파일 내용 | button의 src/ | button 해시 변경 |
의존 태스크들의 해시 | core#build의 해시 | button 해시도 연쇄 변경 |
태스크 정의 자체 | turbo.json의 해당 설정 | 해당 태스크 전부 변경 |
전역 의존성과 선언된 env | globalDependencies, lockfile | 전 태스크 변경 |
핵심은 ‘의존 태스크들의 해시’ 입니다. 특정 태스크가 의존하고 있던 태스크의 해시가 내 해시의 입력으로 들어가므로, 의존하고 있던 태스크가 무효화되면, 자연스럽게 그래프를 따라 하위 태스크들에 전파됩니다. core 한 줄 수정 →
core#build 해시 변경 → 그 해시를 입력으로 갖는 button#build 해시 변경 → button#test 해시 변경. 만약, core를 안 쓰는 modal이 있다면, 그 modal의 해시는 그대로입니다.[core 한 줄 수정 — 무효화 전파 경로] core#build ──→ button#build ──→ button#test (연쇄 캐시 미스) -> 캐시 미스로 인해 태스크 다시 실행 --------------------------- (변경) --------------------------- [core를 안 쓰는 경로 — 그대로 적중] icons#build (HIT) modal#build ──→ modal#test (HIT)
셋째, 조회입니다. cache 저장소는
해시 → tar(outputs + 실행 로그)의 key-value이고, 조회시에는 단 하나만을 확인합니다. 바로, "방금 계산한 내 해시와 똑같은 키가 있는가?" 만약 있으면 캐시 히트로 간주하고 그 값을 그대로 안전하게 재사용할 수 있습니다. 만약 입력이 1비트라도 달랐다면 다른 키가 나왔을 테니, 키의 일치 자체가 "입력 동일 → 결과 동일"의 증명서입니다.유효성 증명이 해시 키 안에 들어 있으므로, 보관하는 쪽에게 요구되는 능력은 "넣은 걸 그대로 돌려준다"가 전부입니다. Github actions/cache의 책임이 여기서 확 줄어듭니다. 오래된 항목인지 검증하기, 만료되었는지 판단정책, 무효화 판단등이 전부 불필요합니다.
그래서 GitHub Actions cache처럼 내용을 전혀 모르는 key-value 저장소로 충분하고, 같은 이유로 저장소를 Vercel Remote Cache(Turborepo가 지원하는 원격 cache 저장소입니다)로 갈아 끼워도 Turborepo 쪽은 아무 영향이 없습니다. 다만 이 단순함이 보관 쪽을 면책해 주지는 않습니다. 정확성은 turbo가 보장하지만, 애초에 키에 무엇이 담겨 저장되느냐 — 즉 적중률은 actions/cache 운영의 몫이고, 뒤에서 볼 고장은 전부 이 키 설계에서 났습니다.
무효화 범위는 어떻게 결정되는가 — 시나리오로 보기
같은 원리라도 무엇을 건드렸느냐에 따라 무효화 범위가 크게 달라집니다. 말로만 보면 막연하니, 작은 디자인 시스템 monorepo 하나를 예로 두고 보겠습니다.
packages/ ├── tokens/ 색상·간격 등 디자인 토큰 (아무것에도 의존 안 함) ├── icons/ tokens에 의존 ├── core/ tokens에 의존 — 대부분의 component가 의존하는 상위 패키지 ├── button/ core, icons에 의존 ├── modal/ core에 의존 └── calendar/ core에 의존 — 아무도 calendar에는 의존 안 함
의존 방향을 그래프로 보면 이렇습니다. 화살표는 "왼쪽이 오른쪽에 의존한다"로 읽습니다.
button ──→ icons ──→ tokens └─────→ core ───↗ modal ────→ core calendar ─→ core
여기서 두 가지만 눈에 담아 두면 표가 읽힙니다.
core는 button·modal·calendar가 모두 의존하는 상위 패키지이고, calendar는 아무도 의존하지 않는 하위 패키지입니다. 이 구조 위에서 무엇을 건드렸을 때 무엇이 다시 도는지를 추려 보면 다음과 같습니다.시나리오 | 미스(재실행) | 적중(재사용) | 왜 |
문서만 수정 — README 한 줄 | 없음 | 전부 (CI 약 30초) | build·test의 inputs가 !README.md로 제외 → 해시 재료에 아예 안 들어감 |
하위 패키지의 한 줄 — calendar의 소스 | calendar의 build·test 2개 | 약 60개 태스크 중 나머지 58개 | calendar를 입력으로 갖는 태스크가 자기 자신뿐 |
상위 패키지의 한 줄 — core의 소스 | core를 전이적으로 의존하는 모든 build·test (button·modal·calendar) | core와 무관한 소수 (tokens, icons) | 그래프를 따라 끝까지 전파 — 느리지만 정확한 케이스 |
stories 파일만 수정 — button의 데모 | button의 lint 1개 | build·test 전부 | build·test inputs가 !**/*.stories.*로 제외. lint는 stories도 검사하므로 의도적으로 포함 유지 |
루트 eslint/tsconfig 수정 | 전 태스크 (의도된 과잉 무효화) | 없음 | globalDependencies 등록 — 규칙이 바뀌었는데 옛 결과로 조용히 통과하는 것을 막는 선택 |
이 표에서 읽혀야 할 패턴은 하나입니다. 무효화 범위는 "바뀐 양"이 아니라 "그래프에서의 위치 × inputs 글롭의 정밀도"로 결정됩니다. 한 줄짜리 수정이라도 상위 패키지(core)면 그를 의존하는 전체가 돌고, 수천 줄짜리 문서 변경이라도 글롭이 제외하면 0개가 돕니다. 그래서 cache 튜닝이란 것을 할 때면 “더 오래 보관하기"가 아닙니다. 그래프를 잘 쪼개기(상위 패키지가 비대해지지 않게), 그리고 inputs 글롭을 정직하게 좁히기입니다.
잠깐, 이런 관리 체계가 없었다면 어떻게 해야 했을까
여기까지 읽으면 자연스러운 의문이 생깁니다. 결국 Turborepo 가 다 계산을 해주는 거면, Turborepo 같은 도구가 없는 monorepo에서는 CI 시간을 어떻게 줄여야 할까요. 결론부터 말하면, 같은 판단을 workflow YAML 위에서 손으로 다시 구현하게 됩니다. 흔한 시도는 세 가지입니다.
첫째, 경로 필터입니다. "packages/button/ 아래가 바뀌었을 때만 button 테스트를 돌린다"는 식으로 잡에 paths 조건을 거는 방식입니다. 문제는 의존성입니다. core가 바뀌면 button도 다시 돌아야 하므로, 필터에 의존 패키지의 경로까지 사람이 적어 넣어야 합니다. 패키지 간 의존이 추가될 때마다 YAML도 함께 고쳐야 하는데, 이 동기화가 깨지는 순간 영향을 받은 패키지가 검증 없이 통과합니다. 앞에서 본 비용 비대칭 그대로, 조용히 틀리는 쪽의 실수입니다.
둘째, 잡 직렬 순서와 아티팩트 전달입니다. "테스트 전에 빌드"를
needs:로 잡 순서에 박고, 빌드 결과물을 artifact 업로드/다운로드로 다음 잡에 넘기는 방식입니다. turbo.json의 dependsOn: ["^build"] 한 줄이 선언으로 표현하는 것을 절차로 풀어 쓴 셈인데, 잡이 늘수록 중계 잡과 전달 단계가 함께 늘어납니다.셋째,
hashFiles 기반 cache 키입니다. key: build-${{ hashFiles('packages/core/src/**') }}처럼 입력 파일의 해시를 직접 키로 쓰는 방식으로, 원리상 Turborepo의 해시 계산과 같은 발상입니다. 다만 전파가 없습니다. button의 키가 core 변경에 반응하려면 키의 글롭에 의존 패키지의 소스까지 전부 나열해야 하고, 패키지가 늘수록 이 나열은 손으로 관리할 수 없는 규모가 됩니다.세 시도의 공통점이 보입니다. 전부 레포에 이미 선언돼 있는 의존성 지식을 YAML에 절차로 복제하는 일이고, 복제본은 원본과 어긋나는 순간부터 이상하게 작동합니다. 예를 들면, 실제론 돌아가야하는 빌드를 스킵해놓고, CI 통과라고 말합니다. 가장 위험한 상황이며 아래에서 다루겠지만, 이는 디자인 시스템 레포에서 존재했었습니다.
사실 Turborepo 뿐만이 아니라, Nx, Bazel, moon 같은 빌드 시스템도 "태스크 그래프 + 해시 기반 cache"라는 같은 원리를 공유합니다. 이를 얼마나 커스텀할 수 있는가, 어떻게 설정하는가 등의 차이이지 핵심은 같습니다.
무엇이 죽어 있었는가 — 레포별 진단
유틸 레포는 단순했습니다. 보관 장치 자체가 없었습니다. turbo는 정상 동작 중이었지만 CI에
.turbo/cache를 보관할 actions/cache 스텝이 아예 없어, 매 run이 100% cold(아무 cache도 없는 상태에서의 실행)였습니다. 덤으로, globalDependencies에 자동 생성되는 문서의 글롭이 들어 있어 actions/cache를 붙여도 문서 한 줄에 전 패키지가 무효화될 구조였습니다. 그 문서를 실제로 소비하는 것은 빌드에 포함하는 패키지 하나뿐이었으므로, 글롭을 전역 의존성에서 해당 패키지의 inputs로 내렸습니다.디자인 시스템 레포는 cache 장치가 둘이나 있었는데 둘 다 고장이었습니다. 고장은 셋으로 나뉘는데, 하나씩 풀어 봅니다.
첫째, 빈 cache가 저장되고 있었습니다. lint·test·build 등 여러 잡이 전부 같은 cache 키 하나를 공유하고 있었습니다. actions/cache는 같은 키를 한 번만 저장하므로(이미 있으면 이후엔 조용히 무시), 여러 잡 중 가장 먼저 끝난 잡이 그 키의 내용을 확정합니다. 그런데 가장 빨리 끝나는 잡은 보통 lint였고, lint는 cache가 꺼져 있어
.turbo에 저장할 아티팩트가 없었습니다. 결과적으로 빈 .turbo가 키에 박히고, 뒤늦게 끝난 빌드 잡이 진짜 아티팩트를 저장하려 해도 "키가 이미 있음"으로 버려졌습니다. 다음 run은 이 빈 cache를 복원하니, 영원히 0 적중이 됩니다. 모든 잡에 서로 다른 키를 주는 것으로 풀었습니다.둘째, cache 정책 자체가 꺼져 있었습니다. turbo.json에서 lint, test, type-coverage 태스크가 전부
cache: false로 선언돼 있었습니다. 이러면 actions/cache가 멀쩡히 살아 있어도 turbo가 이 태스크들의 결과를 애초에 저장하지 않습니다. 보관할 창구가 있어도 보낼 물건을 안 만드는 상태였던 셈입니다. 결과가 재현 가능한 태스크들이므로 cache: true로 되돌렸습니다.셋째, 복원 키의 형식이 어긋나 있었습니다. 빌드 아티팩트를 저장할 때의 키는
build-deps-<커밋 sha> 형식인데, 복원 시 폴백으로 찾는 키는 build-deps-<브랜치명>- 으로 시작하는 prefix였습니다. sha로 저장한 것을 브랜치명으로 찾으니 영원히 매치되지 않습니다. 이전 run의 cache를 가져오려는 의도였지만, 키 형식이 어긋나 매번 빈손으로 시작하고 있었습니다. 저장과 복원의 키 형식을 일치시켜 해결했습니다.세 고장 모두 Turborepo의 판단이 아니라 보관 쪽 운영 — 즉 키와 정책 설계의 문제였다는 점이, 앞에서 본 분업 구도와 정확히 맞아떨어집니다.
얼마나 빨라졌는가 — 실측
이미지 출처: Vercel — Remote Caching
위 수치는 Vercel이 Remote Caching으로 절약했다고 집계한 누적 빌드 시간입니다. 1300만 시간이라니, 저 또한 많은 시간을 줄일 수 있었는데, 그 결과는 다음과 같습니다.
공통 유틸 monorepo의 결과입니다.
구간 | Before | Warm | 단축 |
Build and Test 잡 | 3m46s | 38s | -83% |
└ 빌드 / 테스트 / 타입커버리지 | 109s / 58s / 19s | 2s / 1s / 0.1s | -98%대 |
React 19 호환성 잡 | 2m40s | 1m2s | -62% |
PR 머지 게이트 | 3m46s | 약 1m | -73% |
디자인 시스템 monorepo의 결과입니다. 디자인 시스템 자체가 규모가 크다보니, 효과 또한 컸습니다.
구간 | Before | Cold (개편 후) | Warm | 단축 |
test 잡 | 341s + 106s 직렬 | 370s | 28s (61/61 FULL TURBO) | — |
lint 잡 | 약 166s | 166s | 29s (33/33) | -83% |
type-coverage 잡 | 약 150s | 298s | 29s (46/46) | -82% |
머지 게이트 | 8~9분 | 6m10s (-28%) | 약 29s | -94% (약 18배) |
마무리
이번 작업은 한 줄로 요약됩니다. 무엇을 다시 할지 판단하는 것은 Turborepo의 해시이고, 그 판단 결과를 run 사이에 실어 나르는 것은 actions/cache이며, 고장은 늘 둘 사이의 경계에서 납니다. Turborepo의 해시는 입력이 같으면 결과도 같음을 키 하나로 증명하므로 보관하는 쪽이 내용을 몰라도 안전하지만, 그 단순함이 키와 정책을 잘못 설계해도 된다는 뜻은 아니었습니다.
kyu-log