Published on

GitHub Actions 캐시가 안 먹힐 때 원인 9가지

Authors

서버리스 CI에서 가장 흔한 착각 중 하나가 캐시를 걸었는데도 매번 새로 설치되는 것 같다 입니다. GitHub Actions 캐시는 설정 자체는 단순하지만, 키 설계와 경로, 이벤트, 권한, 러너 환경 차이 때문에 cache miss가 반복되기 쉽습니다.

이 글은 왜 캐시가 안 먹히는지를 원인별로 빠르게 좁히는 데 목적이 있습니다. 각 항목마다 증상, 확인 포인트, 해결 팁을 함께 적었습니다.

참고로, 이런 류의 문제는 로그와 조건을 체크리스트로 좁혀가는 접근이 가장 빠릅니다. 같은 방식의 트러블슈팅 감각은 리눅스 OOM Killer로 프로세스 죽을 때 진단법 같은 글에서도 그대로 통합니다.

캐시가 동작하는 방식 먼저 정리

GitHub Actions의 actions/cache는 대략 다음 흐름으로 동작합니다.

  1. key로 캐시를 조회한다
  2. 있으면 지정한 path로 복원한다
  3. 잡이 끝날 때 key에 해당하는 캐시가 없었던 경우에만 저장한다

여기서 중요한 포인트는 다음입니다.

  • key가 조금이라도 달라지면 다른 캐시로 취급된다
  • path가 정확히 일치하지 않으면 복원해도 효과가 없다
  • 이미 동일 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-

원인 1) 키에 매번 바뀌는 값이 들어간다

증상

  • 로그에 늘 Cache not found for input keys가 뜬다
  • 캐시가 쌓이긴 하는데 재사용이 거의 없다

확인 포인트

  • keygithub.sha, run_id, run_number, 현재 시각, 브랜치마다 달라지는 문자열이 들어가 있는지 확인

해결 팁

  • 의존성 버전을 대표하는 파일의 해시를 키로 쓴다
  • 브랜치별로 완전히 분리할 필요가 없다면 브랜치명은 키에서 제외하거나 restore-keys로 완화
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
  ${{ runner.os }}-pnpm-

원인 2) hashFiles 대상 파일이 워크스페이스에 없다

증상

  • 키가 의도와 다르게 고정되거나, 반대로 예상 못한 값으로 바뀐다
  • 모노레포에서 특정 패키지 lock 파일을 못 찾는다

확인 포인트

  • actions/checkout이 먼저 수행되었는지
  • hashFiles('**/package-lock.json') 같은 글로브가 실제 파일을 포함하는지

해결 팁

  • 체크아웃 이후에 캐시 스텝을 둔다
  • 모노레포면 lock 파일 위치를 명확히 지정한다
- uses: actions/checkout@v4

- name: Cache
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

원인 3) path가 틀렸거나, 캐시할 필요 없는 디렉터리를 캐시한다

증상

  • cache hit인데도 설치 시간이 줄지 않는다
  • 복원은 되는데 빌드가 여전히 느리다

확인 포인트

  • 패키지 매니저가 실제로 쓰는 캐시 경로가 맞는지
  • node_modules 같은 산출물을 캐시하면서 오히려 압축 해제 비용이 커지는지

해결 팁

  • 가능하면 다운로드 캐시를 캐시하고, 산출물 캐시는 신중히
  • npm은 ~/.npm, pnpm은 ~/.pnpm-store, yarn berry는 .yarn/cache 등이 핵심
- name: Get pnpm store path
  shell: bash
  run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- name: Cache pnpm store
  uses: actions/cache@v4
  with:
    path: ${{ env.STORE_PATH }}
    key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}

원인 4) restore-keys를 안 써서 부분 매칭 복원이 안 된다

증상

  • lock 파일이 조금만 바뀌어도 항상 miss
  • 의존성 대부분은 그대로인데도 재다운로드가 크게 발생

확인 포인트

  • restore-keys가 비어 있거나 너무 구체적인지

해결 팁

  • restore-keys는 최신 완전 일치 캐시가 없을 때, 접두사 매칭으로 가장 가까운 캐시를 복원한다
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
  ${{ runner.os }}-gradle-

원인 5) 저장이 안 되는 이벤트에서 실행 중이다 (PR from fork 등)

증상

  • PR에서는 항상 miss인데, main 브랜치에서는 가끔 hit
  • 로그에 저장 단계가 생략되거나 권한 관련 문구가 나온다

확인 포인트

  • pull_requestfork에서 온 PR인지
  • 보안 정책상 fork PR은 토큰 권한이 제한되어 캐시 저장이 막히는 케이스가 있다

해결 팁

  • 신뢰된 컨텍스트에서만 캐시를 저장하게 분기
  • 또는 pull_request_target 사용은 보안 리스크가 있으니 매우 신중히
- name: Cache
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
  if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}

권한 문제를 로그로 추적하고 최소 권한으로 다듬는 접근은 AWS IAM AccessDenied 스택추적과 정책 최소화에서 소개한 방식과 유사합니다.

원인 6) concurrency 취소로 잡이 끝까지 안 가서 저장이 안 된다

증상

  • cache hit이 거의 없고, 캐시가 새로 쌓이지도 않는다
  • 푸시를 자주 하면 이전 워크플로가 취소된다

확인 포인트

  • concurrencycancel-in-progress: true가 설정되어 있는지
  • 캐시는 기본적으로 잡 종료 시점에 저장되므로, 중간 취소되면 저장이 안 된다

해결 팁

  • 캐시가 중요한 워크플로는 취소 정책을 조정하거나, 캐시 저장이 필요한 job을 분리
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: false

원인 7) matrix나 OS별로 키가 분리되어 의도한 공유가 안 된다

증상

  • macOS, ubuntu, windows에서 각각 miss
  • Node 버전 매트릭스에서 버전마다 miss

확인 포인트

  • 키에 runner.os, matrix.node 등이 들어가 있는지
  • 반대로, 들어가야 하는데 안 들어가서 서로 다른 환경이 같은 캐시를 공유하며 깨지는지

해결 팁

  • OS가 다르면 바이너리 호환성 때문에 분리하는 편이 안전
  • Node 버전은 패키지 매니저/네이티브 모듈 영향이 있으면 분리 권장
key: ${{ runner.os }}-node${{ matrix.node }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
  ${{ runner.os }}-node${{ matrix.node }}-npm-
  ${{ runner.os }}-npm-

원인 8) 캐시 크기 제한 또는 파일 수 과다로 저장이 실패한다

증상

  • 복원은 되는데 저장이 안 되거나, 저장 시간이 비정상적으로 길다
  • 로그에 용량 제한, 업로드 실패, 타임아웃 류 메시지가 보인다

확인 포인트

  • 캐시하려는 경로에 빌드 산출물, 로그, 대용량 바이너리가 섞여 있는지
  • 패키지 매니저 캐시가 아닌 전체 디렉터리를 통째로 잡고 있는지

해결 팁

  • 캐시 경로를 최소화하고, 산출물은 actions/upload-artifact로 분리
  • 언어별로 불필요한 캐시 디렉터리를 제외

예를 들어 Gradle은 보통 아래 정도만으로도 효과가 납니다.

- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      ${{ runner.os }}-gradle-

원인 9) 캐시를 복원했지만 설치 명령이 캐시를 활용하지 않는다

증상

  • Cache restored successfully인데도 네트워크 다운로드가 계속 발생
  • 설치 커맨드가 매번 클린 설치로 강제된다

확인 포인트

  • npm에서 npm ci는 캐시 디렉터리를 활용하지만, lock 불일치나 스크립트에서 삭제가 발생할 수 있다
  • pnpm에서 store 경로를 잘못 캐시하면, 매번 다시 받는다
  • pip에서 --no-cache-dir를 사용하면 캐시를 무시한다

해결 팁

  • 설치 커맨드와 캐시 전략을 같이 설계한다
# pip 예시
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

- name: Install
  run: |
    python -m pip install -r requirements.txt
# npm 예시
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}

- run: npm ci --prefer-offline

빠른 진단 체크리스트

아래 순서대로 보면 대부분의 케이스가 10분 안에 정리됩니다.

  1. actions/cache 로그에서 keymatched key를 확인한다
  2. hashFiles 대상 파일이 실제로 존재하는지 확인한다
  3. path가 실제 캐시 경로인지 확인한다
  4. restore-keys로 부분 매칭 복원이 가능한지 본다
  5. fork PR, 권한 제한 이벤트인지 확인한다
  6. concurrency 취소로 저장이 누락되는지 확인한다
  7. matrix 분리 기준이 의도와 맞는지 확인한다
  8. 캐시 크기와 파일 수를 줄인다
  9. 설치 커맨드가 캐시를 쓰는 옵션인지 점검한다

마무리: 캐시는 설정이 아니라 설계다

GitHub Actions 캐시는 한 줄 추가로 끝나는 기능처럼 보이지만, 실제로는 키 설계경로 최소화, 이벤트 권한, 러너 차이까지 함께 맞춰야 성과가 납니다. 특히 cache hit 자체보다도, 복원된 캐시가 설치 단계에서 실제로 재사용되는지를 반드시 확인하세요.

비슷한 결의 실무 트러블슈팅 글로는 Node.js ESM에서 require 오류 해결 9가지도 함께 참고하면, 로그를 근거로 원인을 분류하는 방식에 도움이 됩니다.