- Published on
GitHub Actions 캐시가 안 먹을 때 key 전략과 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 CI처럼 보이지만 GitHub Actions 캐시는 꽤 “상태”를 많이 탑니다. 워크플로우는 매번 새 러너에서 시작하고, 캐시는 오직 key와 path가 정확히 맞아떨어질 때만 복원됩니다. 그래서 캐시가 안 먹는 상황의 대부분은 “캐시가 없어서”가 아니라 “키 전략이 잘못되어 매번 다른 캐시를 찾고” 있거나, “복원은 했는데 실제 빌드가 그 경로를 안 쓰는” 문제입니다.
이 글은 actions/cache를 기준으로, 캐시 miss를 실전에서 가장 많이 만드는 패턴과 키 설계, 그리고 디버깅 루틴을 한 번에 정리합니다.
캐시가 안 먹는 대표 증상 6가지
1) hashFiles 대상이 과도하게 자주 바뀐다
패키지 락 파일 외에 소스 전체를 해시하면 커밋마다 키가 바뀌어 캐시가 항상 새로 생성됩니다. 예를 들어 hashFiles('**/*') 같은 패턴은 캐시를 “무용지물”로 만듭니다.
권장: 의존성 캐시는 락 파일 중심으로만 해시합니다.
- Node:
package-lock.json,pnpm-lock.yaml,yarn.lock - Gradle:
gradle.lockfile,gradle-wrapper.properties - Maven:
pom.xml도 가능하지만 플러그인/리포지토리 변경에 민감하므로 주의
2) restore-keys가 없거나 너무 구체적이다
key가 딱 맞아야만 복원되는 구조에서, restore-keys는 “가장 가까운 캐시”를 찾아주는 안전망입니다. 이를 빼면 락 파일이 한 줄만 바뀌어도 매번 miss가 납니다.
3) 캐시 path가 실제로 사용되는 경로가 아니다
예: pnpm은 ~/.pnpm-store를 쓰는데 node_modules만 캐시하면 설치 시간이 크게 줄지 않거나, 설치 옵션에 따라 아예 재생성됩니다.
또한 일부 툴은 워크스페이스 내부 .gradle과 사용자 홈 ~/.gradle을 동시에 사용합니다. 한쪽만 캐시하면 효과가 반감됩니다.
4) OS, 아키텍처, 런타임 버전이 섞인다
ubuntu-latest는 시간이 지나면서 22.04에서 24.04로 바뀔 수 있고, Node 18과 Node 20은 네이티브 모듈 산출물이 달라질 수 있습니다. 이런 환경 차이를 키에 반영하지 않으면 “복원은 되는데 빌드가 깨지는” 상황이 생기거나, 반대로 안전을 위해 너무 많은 차원을 키에 넣어 캐시가 잘게 쪼개져 miss가 늘어납니다.
5) PR 이벤트에서 권한/스코프 차이로 캐시가 기대대로 안 보인다
특히 pull_request와 pull_request_target는 보안 모델이 다르고, fork PR에서는 캐시 저장/복원에 제한이 걸릴 수 있습니다. 이 경우 “내 브랜치에서는 잘 되는데 PR에서는 항상 miss” 같은 현상이 발생합니다.
6) 캐시는 “업로드 시점”이 중요하다
actions/cache는 스텝이 끝날 때 업로드됩니다. 빌드 실패로 job이 중단되면 캐시가 저장되지 않아 다음 실행도 계속 miss가 날 수 있습니다.
키 설계의 기본 원칙: 안정성과 구체성의 균형
캐시 키는 크게 3층으로 나누어 설계하면 디버깅과 적중률이 좋아집니다.
- 환경층: OS, 런타임 버전, 패키지 매니저 버전 등 “호환성”을 좌우하는 요소
- 의존성층: 락 파일 해시
- 프로젝트층: 모노레포라면 패키지 경로, 빌드 타겟 등
그리고 restore-keys는 위에서 아래로 “점진적으로 덜 구체적인 키”를 제공하는 형태가 좋습니다.
예시: Node (pnpm) 캐시 키 전략
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Enable pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store path
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-node20-
pnpm-${{ runner.os }}-
포인트:
key에hashFiles('pnpm-lock.yaml')만 넣어 “의존성 변경”에만 민감하게 만듭니다.restore-keys는 Node 20 고정 캐시를 먼저 찾고, 없으면 OS 단위로라도 찾게 합니다.path는node_modules가 아니라 pnpm store를 캐시합니다.
디버깅 실전 루틴: “왜 miss인지”를 로그로 확정하기
캐시 문제는 감으로 고치면 끝이 없습니다. 아래 순서대로 “키와 경로가 무엇인지”를 먼저 출력해 확정하세요.
1) 실제로 계산된 key를 출력한다
hashFiles는 눈으로 확인이 어렵습니다. 출력 스텝을 넣어 키 문자열을 고정적으로 확인합니다.
- name: Debug cache key inputs
run: |
echo "OS=${{ runner.os }}"
echo "Ref=${{ github.ref }}"
echo "SHA=${{ github.sha }}"
echo "Lock hash=${{ hashFiles('pnpm-lock.yaml') }}"
주의: 본문에 > 기호가 노출되면 MDX에서 문제될 수 있으므로, 로그 예시에서 화살표 같은 표기를 쓰지 말고 그대로 출력만 하세요.
2) 캐시 스텝 결과를 강제로 확인한다
actions/cache는 cache-hit 출력이 있습니다.
- name: Cache pnpm store
id: cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-node20-
- name: Print cache result
run: |
echo "cache-hit=${{ steps.cache.outputs.cache-hit }}"
cache-hit이 false면 “정확히 일치하는 key”가 없었다는 뜻입니다. 하지만 restore-keys로 복원된 경우에도 false가 나올 수 있으니, 복원 로그를 함께 봐야 합니다.
3) 캐시 경로가 실제로 채워졌는지 확인한다
복원됐더라도 경로가 비어 있으면 사실상 효과가 없습니다.
- name: Inspect cache path
run: |
ls -al "${{ steps.pnpm-store.outputs.STORE_PATH }}" | head -n 50
du -sh "${{ steps.pnpm-store.outputs.STORE_PATH }}" || true
4) 설치/빌드 도구가 그 경로를 쓰는지 확인한다
pnpm은 store 경로가 맞지만, npm이나 yarn은 경로가 다릅니다. Gradle도 마찬가지로 ~/.gradle/caches를 실제로 쓰는지 확인해야 합니다.
5) 실패로 캐시 저장이 안 되는지 확인한다
job이 중간에 실패하면 캐시가 저장되지 않을 수 있습니다. 빌드가 자주 깨지는 브랜치에서 캐시 miss가 계속된다면, 캐시 스텝을 너무 뒤에 둔 것은 아닌지 점검하세요.
자주 터지는 패턴별 처방전
패턴 A: key에 브랜치나 커밋 SHA를 넣어서 매번 miss
다음처럼 github.sha를 넣으면 캐시는 사실상 매 실행 새로 만들어집니다.
key: build-${{ runner.os }}-${{ github.sha }}
해결: “정말로 매 커밋 격리해야 하는 캐시”가 아니라면 SHA는 빼고, 의존성 해시 또는 빌드 입력(예: tsconfig.json, next.config.js) 정도만 반영합니다.
패턴 B: 모노레포에서 루트 락 파일 해시만 쓰다가 특정 패키지 변경에 과민/둔감
모노레포는 전략을 둘 중 하나로 고릅니다.
- 단일 락 파일 기반: 캐시 공유가 쉽지만 변경에 민감
- 패키지별 키 분리: 적중률이 높지만 캐시가 쪼개짐
패키지별로 분리하려면 키에 패키지 경로를 넣습니다.
key: pnpm-${{ runner.os }}-node20-app-${{ hashFiles('apps/web/pnpm-lock.yaml') }}
패턴 C: Gradle 캐시를 .gradle만 잡고 ~/.gradle을 놓침
Gradle은 사용자 홈 캐시 비중이 큽니다.
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-jdk17-${{ hashFiles('**/gradle-wrapper.properties', '**/*.gradle*', '**/gradle.properties') }}
restore-keys: |
gradle-${{ runner.os }}-jdk17-
gradle-${{ runner.os }}-
포인트:
wrapper까지 포함하면 Gradle 배포본 다운로드도 줄어듭니다.- 키 입력 파일은 “빌드 스크립트” 중심으로 제한합니다.
패턴 D: Next.js에서 .next/cache는 복원되는데 빌드가 여전히 느림
Next.js는 빌드 캐시 외에도 패키지 설치, 이미지 최적화, 폰트 다운로드 등이 병목일 수 있습니다. 캐시만으로 해결이 안 되면 LCP나 빌드 단계 병목을 같이 봐야 합니다. 프런트 성능 최적화 관점은 Next.js LCP가 늦는 이유 - 이미지·폰트 최적화도 함께 참고할 만합니다.
“캐시가 복원되었는데도 느린” 경우 체크리스트
캐시 hit과 성능 개선은 동의어가 아닙니다. 아래를 확인하세요.
- 캐시 복원 후 실제 설치 단계가 네트워크를 타는가
- 예: npm이
npm ci로 항상node_modules를 새로 구성
- 예: npm이
- 캐시 경로가 너무 커서 다운로드 자체가 느린가
- 캐시가 수 GB면 복원 시간이 설치 시간과 비슷해질 수 있음
- 캐시가 압축 해제에 오래 걸리는 파일 구조인가
- 작은 파일이 너무 많으면 압축/해제 비용이 커집니다
- 빌드가 CPU 병목인가
- 캐시는 I/O를 줄이지 CPU를 줄이지는 못합니다
이때는 캐시를 “더 많이”가 아니라 “더 적절하게” 잡아야 합니다. 예를 들어 node_modules 대신 패키지 매니저 store를 캐시하거나, Gradle은 wrapper와 dependency cache만 잡고 build output 캐시는 분리하는 식입니다.
캐시 디버깅을 운영 장애 대응처럼 다루는 방법
캐시 miss는 배포 장애처럼 보이지 않지만, CI 시간이 늘어나면 팀 전체의 리드타임이 늘고 배포 빈도도 떨어집니다. 원인 추적 방식은 운영 트러블슈팅과 유사합니다.
- 관측: 키, 복원 여부, 캐시 크기, 설치 로그를 수집
- 가설: 키가 너무 자주 변한다, 경로가 다르다, 이벤트 스코프가 다르다
- 실험: 키 입력을 줄이거나
restore-keys를 추가해 적중률 변화를 확인
이런 “증거 기반” 접근은 인프라 트러블슈팅에서도 동일하게 통합니다. 예를 들어 노드 캐시와 인증 문제를 분리해 진단하는 흐름은 EKS ImagePullBackOff - ECR 인증·IRSA·노드캐시 진단 같은 글의 접근과도 닮아 있습니다.
추천 템플릿: 대부분의 프로젝트에 통하는 캐시 골격
아래는 Node 프로젝트에서 의존성 캐시와 빌드 캐시를 분리한 예시입니다. “의존성 캐시 키”와 “빌드 캐시 키”를 분리하면, 소스 변경이 잦아도 설치 캐시는 안정적으로 hit 시킬 수 있습니다.
name: ci
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store path
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache deps
id: deps-cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: deps-pnpm-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
deps-pnpm-${{ runner.os }}-node20-
deps-pnpm-${{ runner.os }}-
- name: Install
run: pnpm install --frozen-lockfile
- name: Cache build
id: build-cache
uses: actions/cache@v4
with:
path: .next/cache
key: build-next-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml', 'next.config.*', 'tsconfig.json') }}
restore-keys: |
build-next-${{ runner.os }}-node20-
- name: Build
run: pnpm build
- name: Debug cache hits
run: |
echo "deps cache-hit=${{ steps.deps-cache.outputs.cache-hit }}"
echo "build cache-hit=${{ steps.build-cache.outputs.cache-hit }}"
핵심은 다음입니다.
- 의존성 캐시는 락 파일 해시만으로 안정화
- 빌드 캐시는 빌드 입력 파일만 추가해 “필요할 때만” 무효화
- 두 캐시를 분리해 원인(설치 vs 빌드)을 쉽게 분해
마무리: 캐시를 “정확히” 설계하면 CI가 예측 가능해진다
GitHub Actions 캐시는 한 번 맞추면 꾸준히 시간을 아껴주지만, 키가 조금만 흔들려도 즉시 miss가 누적됩니다. 해결의 출발점은 단순합니다.
key를 락 파일 중심으로 안정화restore-keys로 근사치 복원 허용path가 실제로 쓰이는지 확인- 계산된 키와
cache-hit을 로그로 고정
이 네 가지를 지키면 “왜 안 먹는지 모르겠다” 상태에서 벗어나, 캐시를 재현 가능하게 운영할 수 있습니다.