- Published on
GitHub Actions 캐시 안 먹힘? key 복구 9단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 브랜치에서 동일한 빌드를 반복하는데도 GitHub Actions 캐시가 매번 cache miss로 뜨면, 대개 “캐시가 없는 것”이 아니라 “찾을 수 없는 키를 만들고 있는 것”입니다. 특히 actions/cache를 처음 적용할 때는 key를 너무 자주 바뀌게 만들거나(과도한 해시), 반대로 너무 뭉뚱그려서(오염된 캐시) 다시 꺼내 쓰지 못하는 상황이 흔합니다.
이 글은 캐시가 안 먹히는 상황에서 key를 다시 안정적으로 설계하고, 실제로 히트율을 올리는 key 복구 9단계 체크리스트입니다. CI를 안정화하는 관점에서는 “재시도/백오프” 같은 회복 설계도 중요하니, 운영 관점이 궁금하면 OpenAI API 429·Rate Limit 재시도 백오프 설계도 함께 참고하면 좋습니다.
캐시가 안 먹히는 대표 증상 3가지
- 로그에
Cache not found for input keys가 매번 뜬다 restore-keys를 넣었는데도 항상 miss다- 로컬에서는 잘 되는데 PR이나
pull_request이벤트에서만 miss가 난다
이때는 “캐시가 저장되었는지”보다 먼저, 캐시를 찾는 규칙이 일관적인지를 확인해야 합니다.
1단계: 캐시 대상이 맞는지부터 확인하기
캐시는 빌드 산출물(dist, build)을 저장하는 용도라기보다, 보통 의존성 다운로드/컴파일 캐시에 효과가 큽니다.
- Node.js: npm 캐시 디렉터리, pnpm store, Yarn cache
- Gradle:
~/.gradle/caches,~/.gradle/wrapper - Maven:
~/.m2/repository - Python: pip cache, poetry cache
빌드 산출물을 캐시하면, 브랜치/커밋 간 불일치로 오염되기 쉽고 히트율도 떨어집니다. 산출물은 아티팩트(actions/upload-artifact)로 분리하는 편이 안전합니다.
2단계: path가 실제로 존재하는지 로그로 검증하기
actions/cache는 지정한 path가 비어 있거나 존재하지 않아도 “겉보기엔” 잘 동작하는 것처럼 보일 수 있습니다. 다음처럼 캐시 이전/이후에 디렉터리 상태를 출력해 보세요.
- name: Debug cache paths
run: |
set -eux
ls -la
du -sh ~/.npm || true
du -sh ~/.cache/pnpm || true
path가 잘못되면, 키가 아무리 좋아도 캐시 효용은 0입니다.
3단계: key에 “너무 자주 바뀌는 값”을 넣었는지 점검하기
가장 흔한 실수는 key에 github.sha 같은 값을 넣는 겁니다. 커밋마다 키가 바뀌니 영원히 miss가 납니다.
나쁜 예(거의 항상 miss):
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ github.sha }}
좋은 예(락파일 기반, 변경될 때만 새 캐시):
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
핵심은 의존성이 바뀔 때만 키가 바뀌게 만드는 것입니다.
4단계: hashFiles 대상 파일이 이벤트에서 보이는지 확인하기
hashFiles('**/pnpm-lock.yaml')를 썼는데 계속 miss라면, 다음 가능성을 체크하세요.
- 모노레포에서 락파일 위치가 다르다
- PR 이벤트에서 체크아웃이 얕게 되거나(
fetch-depth) 특정 파일이 없다고 판단된다 - 생성되는 파일(
npm install후 만들어지는 lock)을 해시 대상으로 삼았다
해결 팁:
- 락파일은 레포에 커밋된 파일만 대상으로
- 체크아웃을 확실히 수행
- uses: actions/checkout@v4
with:
fetch-depth: 0
5단계: restore-keys를 “폴백 계층”으로 설계하기
restore-keys는 완전 일치 키가 없을 때 접두사(prefix) 매칭으로 가장 가까운 캐시를 가져옵니다. 여기서도 자주 하는 실수는 접두사를 너무 구체적으로 잡는 겁니다.
예시(Node + npm):
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
npm-
- 1순위: OS + 락파일 해시 완전 일치
- 2순위: 같은 OS에서 가장 최근 유사 캐시
- 3순위: OS도 달라도 일단 가져오기(프로젝트 성격에 따라 비권장)
폴백을 허용할수록 히트율은 오르지만, 오염 가능성도 올라갑니다. 언어나 패키지 매니저 특성에 맞춰 조절하세요.
6단계: 브랜치/PR 스코프를 오해했는지 확인하기
캐시는 “레포 단위로 공유된다”라고만 이해하면 위험합니다. 실제로는 이벤트와 보안 정책에 의해 체감 스코프가 달라집니다.
pull_request에서 포크(fork) PR은 캐시 쓰기가 제한되거나 기대와 다르게 동작할 수 있음- 브랜치마다 키가 달라지면(예:
github.ref_name포함) 서로 캐시를 못 씀
따라서 브랜치를 키에 넣을지 여부는 목적에 따라 결정해야 합니다.
- 히트율 최우선: 브랜치 제외
- 격리 최우선(오염 방지): 브랜치 포함
브랜치를 포함하는 예:
key: npm-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/package-lock.json') }}
7단계: 캐시가 “저장되지 않는” 조건을 제거하기
actions/cache는 스텝이 실행되더라도, 워크플로가 실패로 끝나거나(조건에 따라) 저장이 안 되는 케이스가 있습니다. 또한 캐시는 보통 잡 종료 시점에 업로드됩니다.
권장 패턴:
- 의존성 설치가 실패하면 캐시가 업데이트되지 않는 건 정상
- 캐시를 저장하려면 잡이 끝까지 가야 함
- 캐시 스텝에
if를 과하게 걸지 말기
디버깅을 위해 캐시 히트 여부를 출력하세요.
- name: Restore npm cache
id: cache-npm
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Cache debug
run: |
echo "cache-hit=${{ steps.cache-npm.outputs.cache-hit }}"
8단계: setup-* 액션의 내장 캐시와 중복 적용을 정리하기
actions/setup-node@v4는 npm/yarn/pnpm에 대해 내장 캐시 옵션을 제공합니다. 이를 actions/cache와 동시에 쓰면
- 같은 경로를 중복 캐시하거나
- 서로 다른 키 전략이 충돌해서
- 기대와 다른 히트율을 만들 수 있습니다.
내장 캐시를 쓸 거면 간결하게:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
직접 actions/cache로 컨트롤할 거면 내장 캐시는 끄고(설정하지 않고) 한 방식만 유지하세요.
9단계: “키 복구”를 위한 최종 권장 템플릿(모노레포 포함)
여기부터가 실전에서 가장 많이 쓰는 형태입니다.
- OS 고정
- 패키지 매니저/런타임 버전 반영
- 락파일 해시 기반
- 모노레포는 워크스페이스 락파일 단일화를 전제로 하거나, 필요한 락파일을 명시
Node.js + pnpm 예시
pnpm은 store 경로가 환경에 따라 달라질 수 있으니, store path를 명시적으로 얻어 캐시하는 패턴이 안정적입니다.
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Enable corepack
run: corepack enable
- name: Resolve pnpm store path
id: pnpm-store
run: |
set -eux
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm cache
id: cache-pnpm
uses: actions/cache@v4
with:
path: |
${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-node20-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-node20-
pnpm-${{ runner.os }}-
- name: Install
run: pnpm install --frozen-lockfile
- name: Test
run: pnpm test
- name: Cache debug
run: echo "cache-hit=${{ steps.cache-pnpm.outputs.cache-hit }}"
이 템플릿이 “키 복구”에 강한 이유는 다음과 같습니다.
github.sha같은 휘발성 값을 제거- 런타임 버전(
node20)을 키에 포함해 ABI/의존성 차이로 인한 오염을 줄임 - 락파일 기반으로만 키가 변하므로 히트율이 안정적
자주 묻는 함정: 캐시 히트인데도 빌드가 느린 이유
캐시가 히트해도 느릴 수 있습니다.
- 캐시 압축/해제 시간이 다운로드 시간보다 길다
- 캐시 경로가 너무 커서(예:
node_modules전체) 오히려 손해 - 네트워크가 빠른 러너에서는 캐시 효용이 낮음
이럴 때는 “캐시 크기”를 줄이거나, 캐시 경로를 store/registry 캐시로 바꾸는 게 정답입니다.
운영 관점 팁: 실패를 줄이는 설계로 연결하기
캐시는 CI 안정화의 한 축이고, 나머지는 실패 시 회복 전략입니다. 예를 들어 외부 레지스트리 장애나 일시적 네트워크 오류가 잦다면, 단순 재시도 정책을 설계하는 것이 전체 파이프라인 시간을 줄일 때가 많습니다. 이 관점은 OpenAI API 429·Rate Limit 재시도 백오프 설계처럼 “실패를 전제로 한 설계”와 동일한 결을 가집니다.
또한 Git 기반 워크플로에서 충돌/정리 비용이 커 CI가 자주 깨진다면, 캐시보다 먼저 브랜치 전략과 리베이스 자동화가 더 큰 효과를 낼 수 있습니다. 관련해서는 Git rebase 충돌 자동해결 - rerere·autosquash 실전도 함께 보면 파이프라인 유지보수 비용을 줄이는 데 도움이 됩니다.
마무리: 캐시가 “안 먹히는” 게 아니라 “못 찾는” 경우가 대부분
정리하면, GitHub Actions 캐시 이슈는 대부분 다음 3가지로 귀결됩니다.
path가 틀렸다key가 너무 자주 바뀐다- 스코프(브랜치/PR/포크)와 폴백(
restore-keys) 설계가 없다
위 9단계를 순서대로 적용하면, 캐시가 왜 miss 나는지 원인을 로그로 설명할 수 있고, 그 과정에서 자연스럽게 key를 복구하게 됩니다. 캐시 히트율이 안정화되면, 그때부터는 “얼마나 빠르게”가 아니라 “얼마나 안전하게(오염 없이)” 캐시를 공유할지로 최적화 포인트가 이동합니다.