- Published on
GitHub Actions 캐시가 안 먹을 때 - 키 전략·디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 워크플로우/브랜치에서 매번 의존성을 다시 받거나 빌드가 풀로 도는 문제는 대부분 cache key 설계와 path 범위, 그리고 “무엇을 캐시해야 하는가”의 판단이 어긋나서 생깁니다. 이 글은 GitHub Actions 캐시가 안 먹을 때(= cache-hit: false가 반복될 때) 가장 먼저 의심해야 할 포인트를 체크리스트처럼 정리하고, 실제로 캐시 키를 어떻게 설계하고 디버깅해야 재발을 막을 수 있는지 다룹니다.
또한 CI 성능을 올리다 보면 인증/권한 이슈(OIDC, AssumeRole) 때문에 캐시 이전 단계에서 실패하는 경우도 많습니다. 그런 케이스는 GitHub Actions OIDC assume-role 실패 원인별 해결도 함께 참고하면 진단 속도가 빨라집니다.
1) GitHub Actions 캐시 동작 원리 한 장 요약
actions/cache는 “키로 스냅샷을 찾아서 지정한 경로를 복원”하고, 작업이 끝날 때 “같은 키가 없으면 저장”합니다.
- 복원 단계:
key로 정확히 매칭되는 캐시가 있으면 히트 - 폴백 단계:
restore-keys접두사(prefix)로 가장 가까운 캐시를 찾음 - 저장 단계: 잡(job) 종료 시점에
key로 캐시가 없으면 업로드
중요한 함정은 다음입니다.
- 캐시는 “업로드 시점”이 잡 종료 시점이므로, 중간에 실패하면 저장되지 않습니다.
key가 매번 바뀌면 항상 미스가 납니다(예: 커밋 SHA를 키에 포함).path가 잘못되면 복원해도 효과가 없습니다(예: 패키지 매니저가 실제로 쓰는 캐시 경로가 아님).
2) 캐시가 안 먹는 대표 원인 10가지
2.1 키에 변동성이 큰 값이 들어감
다음 값은 키에 넣는 순간 “거의 매번 미스”가 됩니다.
github.sha- 빌드 번호/런 ID
- 타임스탬프
키는 “의존성/빌드 산출물의 의미 있는 변경”이 있을 때만 바뀌어야 합니다.
2.2 hashFiles 대상이 부정확하거나 과도함
hashFiles로 락 파일만 해시해야 하는데, 소스 전체를 해시하면 소스 변경마다 캐시가 갈립니다.
- Node:
package-lock.json,yarn.lock,pnpm-lock.yaml - Gradle:
gradle.lockfile,build.gradle,settings.gradle(팀 정책에 따라) - Maven:
pom.xml(멀티 모듈이면 상위/하위 포함 범위 주의)
2.3 path가 실제 캐시 경로가 아님
예를 들어 npm은 보통 ~/.npm을 캐시로 쓰고, pnpm은 ~/.pnpm-store 또는 설정된 store 경로를 씁니다. node_modules만 캐시하면 플랫폼/네이티브 모듈 차이로 깨지거나, 복원해도 설치 시간이 크게 줄지 않는 경우가 많습니다.
2.4 브랜치/PR/기본 브랜치 간 캐시 공유 전략 부재
PR에서 만든 캐시를 기본 브랜치가 못 쓰거나, 반대로 기본 브랜치 캐시를 PR이 못 쓰면 체감이 크게 떨어집니다. restore-keys로 “기본 브랜치 캐시 폴백”을 제공하는 패턴이 실전에서 가장 효과적입니다.
2.5 OS/아키텍처/런타임 버전을 키에 포함하지 않음
ubuntu-latest에서 만든 캐시를 macos-latest가 복원하려 하면 실패하거나, 복원되더라도 바이너리 호환성 문제가 생깁니다.
키에는 최소한 다음을 넣는 것이 안전합니다.
${{ runner.os }}- 언어 런타임 버전(예: Node, Java)
2.6 잡이 실패해서 캐시 저장이 안 됨
테스트 실패, 린트 실패, 권한 실패 등으로 잡이 중간에 종료되면 캐시 저장이 안 됩니다. “복원은 되는데 저장이 안 된다”면 이 케이스가 많습니다.
2.7 동시성(concurrency)로 인한 캐시 경쟁
같은 키로 여러 잡이 동시에 실행되면, 먼저 성공한 잡만 캐시를 저장하고 나머지는 저장을 건너뜁니다. 이는 정상 동작이지만, 키 설계가 너무 넓으면 갱신이 잘 안 되는 느낌을 줄 수 있습니다.
2.8 캐시 용량/정책 문제
리포지토리 단위 캐시 총량 제한, 오래된 캐시 eviction, 대용량 업로드 실패 등으로 기대대로 유지되지 않을 수 있습니다.
2.9 모노레포에서 “전체 락 파일 하나”만으로 키를 만들지 않음
패키지별 락 파일이 있거나, 워크스페이스 구조가 복잡하면 “작업 단위별 키”가 필요합니다.
2.10 빌드 산출물 캐시와 의존성 캐시를 혼동
의존성 캐시(다운로드/압축 해제 시간 단축)와 빌드 캐시(컴파일/테스트 결과 재사용)는 성격이 다릅니다.
- 의존성: npm/pip/Gradle 다운로드 캐시
- 빌드: Gradle build cache, Bazel cache, Next.js
.next/cache등
3) 키 설계 전략: “정확 키 + 폴백 키”가 정답
캐시 키 설계는 다음 두 줄로 요약됩니다.
key: 재현 가능한 정확 매칭 키(락 파일 해시 기반)restore-keys: 최대한 재사용 가능한 폴백(브랜치/기본 브랜치/OS 단위 접두사)
3.1 Node 예시: npm 캐시 키
- name: Use Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Restore npm cache
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
npm-${{ runner.os }}-
- name: Install
run: npm ci
포인트
key는 락 파일 해시로만 변합니다.restore-keys는 해시 없이 접두사로 폴백합니다.node-version이 바뀌면 키도 바뀌어야 합니다.
3.2 pnpm 예시: store 경로를 먼저 알아내기
pnpm은 store 경로가 환경에 따라 달라질 수 있으니, 경로를 출력해 확인한 뒤 캐시하는 편이 안전합니다.
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store
id: pnpm-store
shell: bash
run: |
echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-pnpm9-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-pnpm9-
pnpm-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
3.3 Python 예시: pip 캐시
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Restore pip cache
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py312-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
pip-${{ runner.os }}-py312-
pip-${{ runner.os }}-
- run: pip install -r requirements.txt
requirements.txt가 여러 개인 레포라면 해시 대상 패턴을 더 명확히 하거나, 서비스별로 키를 분리하세요.
3.4 Gradle 예시: 의존성 캐시와 빌드 캐시를 분리
Gradle은 “다운로드 캐시”와 “빌드 캐시”를 분리하면 효과가 좋습니다.
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Restore Gradle caches
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-j21-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-j21-
gradle-${{ runner.os }}-
- name: Build
run: ./gradlew build --no-daemon
빌드 캐시는 Gradle 자체 기능(원격/로컬 빌드 캐시)을 쓰는 편이 더 안정적일 때가 많습니다.
4) 브랜치 전략: PR은 기본 브랜치 캐시를 폴백으로
실무에서 가장 체감이 큰 패턴은 “PR은 기본 브랜치 캐시를 먼저 당겨 쓰고, PR 전용 키로 저장”입니다.
- name: Restore cache
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-main-
npm-${{ runner.os }}-
주의점
- 기본 브랜치 이름이
main이 아닐 수 있으니 레포 설정에 맞추세요. github.ref_name을 키에 넣으면 브랜치별로 캐시가 갈리지만,restore-keys로 공유가 됩니다.
5) 디버깅: “왜 miss 났는지”를 로그로 증명하기
5.1 캐시 액션 출력 확인
actions/cache는 요약 로그에 Cache not found for input keys 또는 Cache restored from key 같은 문구를 남깁니다. 먼저 이 메시지를 기준으로 “정확 키 미스인지, 폴백도 실패인지”를 구분하세요.
5.2 실제로 생성된 키를 출력
키가 예상과 다른 경우가 많습니다. 해시 결과와 ref, OS 등을 그대로 출력하세요.
- name: Debug cache key inputs
shell: bash
run: |
echo "ref_name=${{ github.ref_name }}"
echo "os=${{ runner.os }}"
echo "lock_hash=${{ hashFiles('**/package-lock.json') }}"
5.3 캐시 경로가 존재하는지 확인
복원은 됐는데 효과가 없다면, 복원된 경로가 실제로 채워졌는지 확인해야 합니다.
- name: Inspect cache dir
shell: bash
run: |
ls -la ~/.npm | head -n 50
du -sh ~/.npm || true
5.4 패키지 매니저가 캐시를 쓰는지 확인
예를 들어 npm은 다운로드 캐시를 쓰지만, npm ci는 node_modules를 항상 새로 구성합니다. “다운로드가 빨라졌는지”를 로그로 확인하세요.
- npm:
npm ci --prefer-offline고려 - pnpm: store 히트 여부 로그 확인
- Gradle:
--info로 캐시 관련 로그 확인
5.5 잡 실패로 저장이 안 되는지 확인
캐시는 잡 끝에서 저장됩니다. 테스트가 실패하면 저장이 안 되니, 캐시를 “반드시 남겨야 하는” 워크플로우에서는 캐시 저장 이전에 실패하지 않도록 단계 분리 또는 조건을 고민해야 합니다.
예: 의존성 설치까지만 별도 워크플로우로 분리해 성공률을 높이는 방식.
6) 무엇을 캐시할 것인가: 의존성 vs 빌드 산출물
6.1 의존성 캐시(다운로드 캐시)가 우선
대부분의 레포에서 가장 안전하고 효과가 꾸준한 건 “다운로드 캐시”입니다.
- npm:
~/.npm - pip:
~/.cache/pip - Gradle:
~/.gradle/caches,~/.gradle/wrapper
이 방식은 OS/런타임만 맞으면 재현성이 높고, 깨질 확률이 낮습니다.
6.2 빌드 산출물 캐시는 신중하게
예를 들어 Next.js의 .next/cache 같은 것은 잘 먹히면 매우 빠르지만, 키 설계가 조금만 어긋나도 “묘하게 느리거나, 가끔 깨지는” 원인이 됩니다.
빌드 캐시 키에는 최소한 다음이 들어가야 합니다.
- OS
- 런타임 버전
- 락 파일 해시
- 빌드에 영향을 주는 환경 변수(예:
NEXT_PUBLIC_*중 빌드 시점에 고정되는 값)
7) 캐시 관련 자주 하는 실수와 교정 패턴
7.1 실수: 키를 너무 촘촘하게
- 증상: 매번 miss
- 교정: 커밋 SHA 제거, 락 파일 해시 중심으로 재설계
7.2 실수: 키를 너무 넓게
- 증상: 히트는 되는데 빌드가 이상하거나 오래됨(오염된 캐시)
- 교정: OS/런타임/주요 설정을 키에 포함
7.3 실수: restore-keys를 안 씀
- 증상: 락 파일이 조금만 바뀌어도 완전 miss
- 교정: 접두사 폴백으로 “가까운 캐시”를 재사용
7.4 실수: 캐시 경로를 추측으로 넣음
- 증상: 히트여도 속도 개선이 없음
- 교정: 도구가 실제로 쓰는 캐시 경로를 커맨드로 확인
8) 운영 팁: 캐시가 아니라 실패 원인부터 제거하기
캐시 문제로 보이지만 실제로는 워크플로우 자체가 불안정해서(권한, 네트워크, 토큰 만료) 캐시 저장까지 못 가는 경우가 있습니다. 특히 클라우드 인증이 끼면 “가끔 실패”가 치명적입니다.
- OIDC/AssumeRole 실패로 잡이 중단되면 캐시 저장도 중단
- 의존성 다운로드가 네트워크 문제로 흔들리면 캐시가 있어도 체감이 줄어듦
인증 계층이 의심되면 GitHub Actions OIDC assume-role 실패 원인별 해결을 같이 점검하세요.
또한 캐시는 결국 “시간 최적화” 수단입니다. 병목이 캐시가 아닌 테스트/렌더링/빌드 자체라면, 캐시만 만지다 시간을 낭비할 수 있습니다. 성능 병목을 찾는 접근 자체는 Chrome 렌더링 느림 - Long Task 잡는 법처럼 “측정 가능한 지표로 원인을 좁히는 방식”이 CI에도 그대로 적용됩니다.
9) 최종 체크리스트
key에github.sha같은 고변동 값이 들어가 있지 않은가hashFiles가 락 파일 중심으로 구성되어 있는가${{ runner.os }}와 런타임 버전이 키에 포함되어 있는가restore-keys로 폴백 전략이 있는가(특히 PR에서 기본 브랜치)path가 실제 캐시 경로가 맞는가(커맨드로 검증)- 잡이 끝까지 성공해서 캐시 저장이 수행되는가
- 캐시 히트가 “실제 속도 개선”으로 이어지는지 로그로 확인했는가
10) 결론
GitHub Actions 캐시는 “한 번 맞추면 계속 빨라지는 마법”이 아니라, 키와 경로를 정확히 설계하고 로그로 검증해야 안정적으로 먹습니다. 가장 좋은 패턴은 락 파일 해시 기반의 정확 키를 두고, restore-keys로 브랜치/기본 브랜치/OS 단위 폴백을 제공하는 것입니다. 여기에 키 입력값 출력, 캐시 디렉터리 검사 같은 디버깅 단계를 추가하면, 캐시 미스가 발생해도 원인을 재현 가능하게 좁히고 빠르게 수정할 수 있습니다.