- Published on
GitHub Actions 캐시 무효화 버그 해결법 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 CI 환경에서 GitHub Actions 캐시는 빌드 시간을 줄이는 가장 강력한 레버입니다. 그런데 어느 날부터 캐시가 “있는데도” 매번 새로 내려받거나, 반대로 “갱신되어야 하는데” 계속 오래된 결과를 재사용하는 일이 생깁니다. 흔히 이를 캐시 무효화(invalidation) 버그라고 부르지만, 실제로는 키 설계/경로/스코프/동시성 같은 조건이 겹치며 발생하는 경우가 많습니다.
이 글은 “캐시가 안 먹는다” 수준을 넘어서, 왜 무효화되는지를 빠르게 분류하고 재발을 막는 7가지 해결법을 코드 중심으로 정리합니다.
관련해서 캐시 자체를 디버깅해 속도를 끌어올린 실전기는 아래 글도 함께 보면 좋습니다.
1) 캐시 키에 “변경 트리거”가 빠진 경우: 락파일 기반 키로 고정
캐시는 기본적으로 key가 같으면 재사용됩니다. 문제는 많은 워크플로가 key: node-modules처럼 고정 키를 써서, 의존성이 바뀌어도 캐시가 갱신되지 않는다는 점입니다. 반대로, key에 너무 많은 변수를 넣으면 매번 키가 달라져 캐시가 무효화됩니다.
가장 안정적인 방식은 락파일 해시 기반입니다.
- name: Cache npm
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
핵심 포인트:
- 락파일이 바뀔 때만 캐시가 무효화되므로 기대 동작과 일치합니다.
restore-keys를 두어 “완전 일치 키가 없을 때” 과거 캐시라도 복구해 다운로드량을 줄입니다.
2) restore-keys를 잘못 써서 “오래된 캐시가 계속 살아남는” 문제
restore-keys는 편하지만, 잘못 쓰면 의도치 않게 오래된 캐시를 계속 복구합니다. 특히 빌드 산출물(예: dist, .next, build)까지 캐싱하면서 restore 범위를 넓게 잡으면, 코드가 바뀌어도 예전 산출물이 섞여 이상한 결과가 나올 수 있습니다.
권장 패턴:
- 패키지 매니저 캐시(예:
~/.npm,~/.pnpm-store)에는restore-keys를 적극 사용 - 빌드 산출물 캐시(예:
.next/cache)에는restore-keys를 보수적으로 사용하거나 아예 제거
예시(Next.js의 .next/cache는 안전한 편이지만, 앱 구조에 따라 조정 필요):
- name: Cache Next.js build cache
uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextcache-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('next.config.js', 'tsconfig.json') }}
Next.js 빌드 캐시/메모리 튜닝은 아래 글도 참고할 만합니다.
3) 캐시 path가 런너/툴 체인과 안 맞는 경우: “진짜 캐시 경로”를 먼저 확인
캐시가 무효화되는 것처럼 보이지만, 실제로는 저장/복원이 다른 경로를 바라보는 경우가 흔합니다.
자주 틀리는 예:
- pnpm을 쓰는데
~/.npm만 캐시함 - yarn berry(PnP)인데
node_modules를 캐시함 - pip 캐시 경로를 OS별로 다르게 써야 하는데 고정함
해결법은 간단합니다. “캐시하려는 툴이 실제로 어디에 저장하는지”를 로그로 확인하세요.
- name: Show cache dirs
run: |
echo "HOME=$HOME"
npm config get cache || true
pnpm store path || true
python -m pip cache dir || true
그 다음 그 경로를 actions/cache의 path에 반영합니다.
pnpm 예시:
- name: Get pnpm store
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
4) OS/아키텍처가 달라서 캐시가 “의도적으로” 무효화되는 문제
캐시 키에 runner.os만 넣고 안심하는 경우가 많은데, 실제로는 같은 OS라도 아키텍처(예: x64, arm64)가 다르면 바이너리/네이티브 모듈이 달라집니다. 특히 다음 상황에서 빈번합니다.
macos-latest에서arm64와x64가 섞임- self-hosted 런너가 섞여 있음
- Docker 기반 빌드에서 플랫폼이 다름
키에 아키텍처를 포함해 분리하세요.
- name: Cache deps
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-${{ runner.arch }}-npm-${{ hashFiles('**/package-lock.json') }}
또한 node_modules 자체를 캐시할 때는 플랫폼 차이로 깨질 가능성이 크므로, 가능하면 ~/.npm 같은 “다운로드 캐시” 중심으로 설계하는 편이 안전합니다.
5) 동시 실행으로 인한 캐시 레이스: concurrency와 “읽기/쓰기 분리”
GitHub Actions 캐시는 한 키에 대해 여러 잡이 동시에 저장을 시도하면, 누가 마지막에 저장했는지에 따라 결과가 흔들릴 수 있습니다. 또는 어떤 잡은 restore만 하고, 어떤 잡은 save를 하면서 서로 꼬이기도 합니다.
해결 전략은 2가지입니다.
(1) 워크플로 동시성을 제한
브랜치 단위로 동시 실행을 묶어 캐시 경쟁을 줄입니다.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
(2) 저장은 한 잡에서만 하도록 설계
예를 들어 matrix 테스트가 여러 개라면, “대표 잡”만 캐시를 저장하게 만들 수 있습니다.
- name: Cache
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
- name: Save cache only on one shard
if: ${{ matrix.shard == 1 }}
run: echo "This shard is the cache writer"
actions/cache는 restore와 save를 내부적으로 처리하지만, 워크플로 구조를 통해 “쓰기 주체”를 줄이면 재현 어려운 캐시 무효화 이슈가 크게 줄어듭니다.
6) 캐시 스코프/브랜치 정책으로 인한 “PR에서는 안 먹는” 현상
캐시가 특정 브랜치나 이벤트에서만 안 먹는다면, 키 문제보다 스코프를 먼저 의심하세요.
대표 케이스:
pull_request에서 생성된 캐시가push에서 재사용되지 않거나 반대- 포크 PR은 보안 정책으로 캐시 접근이 제한될 수 있음
- 브랜치별로 키가 너무 분리되어 히트율이 떨어짐
해결법:
- 캐시 키를 브랜치명에 강하게 종속시키지 말고, 락파일 해시 기반으로 통일
- PR에서도 동일 키로 restore 가능하도록 설계(단, 포크 PR은 기대를 낮추고 별도 전략 필요)
예시(브랜치명을 키에 넣지 않는 패턴):
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
브랜치명까지 넣어야 하는 경우는 “브랜치마다 의존성/툴 체인이 크게 다른” 특수한 레포 구조일 때가 많습니다.
7) “캐시를 지워야만 해결되는” 상황: 버전 핀ning과 강제 무효화 키
가끔은 캐시가 깨져서(부분 손상, 잘못된 산출물 혼입, 툴 버그) 정상적인 키 전략으로는 회복이 느립니다. 이때는 의도적인 강제 무효화가 필요합니다.
(1) 캐시 버전 프리픽스 도입
키에 사람이 올리는 버전 문자열을 포함하면, 문제 발생 시 즉시 전체 캐시를 갈아엎을 수 있습니다.
env:
CACHE_VERSION: v3
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ env.CACHE_VERSION }}-${{ runner.os }}-npm-
CACHE_VERSION만 올리면 “캐시 무효화 버튼”이 됩니다.
(2) 액션/런타임 버전 핀ning
actions/cache@v4 같은 액션은 태그로 쓰되, 문제 재현이 어려우면 커밋 SHA로 핀ning하는 것도 방법입니다(조직 정책에 따라 다름). 또한 Node/Python/Java 버전이 바뀌면 캐시 구조가 달라질 수 있으니, 해당 버전도 키에 포함시키는 편이 안전합니다.
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node20-npm-${{ hashFiles('**/package-lock.json') }}
재현·진단 체크리스트(로그로 빠르게 결론 내기)
캐시 무효화 버그는 “추측”으로 고치기 어렵습니다. 아래를 로그로 확인하면 원인 분류가 빨라집니다.
- restore 단계 로그에
Cache not found for input keys가 뜨는가, 아니면 찾았는데도 효과가 없는가 key가 매 실행마다 바뀌는지(커밋/시간/런ID 등 변수가 섞였는지)path가 실제 툴 캐시 경로와 일치하는지- OS/아키텍처가 섞이는지
- matrix 동시 실행이 같은 키에 쓰기를 경쟁하는지
- 이벤트 스코프(특히 PR/포크) 제한이 있는지
- 강제 무효화가 필요한 “깨진 캐시” 상태인지
마무리: “의존성 캐시”와 “빌드 캐시”를 분리하면 대부분 해결된다
현장에서 가장 효과가 좋았던 결론은 하나입니다.
- 의존성 다운로드 캐시(예:
~/.npm,~/.pnpm-store,~/.cache/pip)는 락파일 해시 기반 + 넓은restore-keys - 빌드 산출물 캐시(예:
dist,build)는 가능하면 피하거나, 정말 필요할 때만 좁은 키로 보수적으로
이 원칙에 OS/아키텍처 분리, 동시성 제어, 강제 무효화 버전을 더하면 “캐시가 가끔 이상해지는” 문제의 재발률이 크게 줄어듭니다.
추가로 GitHub Actions 전반의 배포 인증 이슈(예: OIDC 토큰 오류)처럼, 겉보기엔 캐시 문제로 보이지만 실제로는 다른 원인인 경우도 있습니다. 배포 단계에서 인증이 흔들린다면 아래 글도 같이 점검해 보세요.