Published on

GitHub Actions 캐시가 안 먹을 때 - cache-hit 0% 원인 정리

Authors

서로 같은 워크플로를 돌리는데도 actions/cache가 매번 cache-hit: false(체감상 0%)로만 찍히면, 대부분은 “캐시가 저장되지 않았다”가 아니라 캐시 키가 매번 달라지거나(미스), 저장은 됐는데 복원 조건을 못 맞추거나(미복원), 저장 단계 자체가 실행되지 않는(미저장) 구조적 문제입니다.

이 글은 원인을 크게 키(key), 경로(path), 실행 조건, 권한/스코프, 플랫폼/런타임, 의존성 도구별 함정으로 나눠서, 로그에서 무엇을 확인해야 하는지와 바로 적용 가능한 수정 예제를 함께 정리합니다.

> 참고: 캐시 관련 오류가 권한 문제(403)로 보이면 토큰/권한부터 확인하세요. 관련 글: GitHub Actions 403 권한오류 해결 - GITHUB_TOKEN·OIDC

1) 먼저 확인: “복원”이 실패인지 “저장”이 안 된 건지

actions/cache는 보통 아래 두 가지 모드로 쓰입니다.

  • restore + save를 분리(권장: v4에서 가능)
  • 단일 uses: actions/cache@...로 restore/save를 한 번에 처리(구버전/간단 구성)

캐시가 안 먹는다고 느낄 때 가장 먼저 볼 것은 첫 실행에서 저장이 되었는지입니다.

체크리스트

  • 첫 실행 로그에 Cache saved successfully가 있는가?
  • post 단계가 실행되었는가? (job이 중간에 실패하면 save가 안 될 수 있음)
  • cache-hit는 “정확히 같은 primary key로 복원했는지”만 의미합니다. restore-keys로 부분 복원되면 cache-hit: false지만 실제로는 캐시가 복원된 상태일 수 있습니다.

v4 권장 패턴(restore/save 분리)

- name: Restore cache
  id: cache
  uses: actions/cache/restore@v4
  with:
    path: |
      ~/.npm
      node_modules
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

- name: Install
  run: npm ci

- name: Save cache
  if: steps.cache.outputs.cache-hit != 'true'
  uses: actions/cache/save@v4
  with:
    path: |
      ~/.npm
      node_modules
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}

이렇게 분리하면 “복원은 됐는데 저장이 안 됨” 문제를 더 명확히 관찰할 수 있습니다.

2) 원인 1: 캐시 키가 매번 달라진다(가장 흔함)

cache-hit 0%의 1순위 원인은 키에 변동 요소가 들어가는 것입니다.

흔한 실수

  • 키에 ${{ github.sha }}를 넣음 → 커밋마다 키가 바뀌니 매번 미스
  • 키에 ${{ github.run_id }} / ${{ github.run_number }} 포함 → 실행마다 미스
  • 키에 날짜/시간을 포함하는 스크립트 결과 포함

나쁜 예

key: npm-${{ github.sha }}

좋은 예(락파일 기반)

key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}

hashFiles() 대상 경로가 잘못됐다

모노레포에서 package-lock.json이 루트가 아니라 apps/web/package-lock.json에 있는데 루트만 해시하면 항상 빈 해시가 나오거나(파일 미발견), 의도와 다른 키가 만들어집니다.

key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}

락파일이 매 실행마다 변경된다

  • npm install이 락파일을 업데이트
  • pnpm installpnpm-lock.yaml을 업데이트
  • yarnyarn.lock을 업데이트

CI에서는 가능하면 락파일을 변경하지 않는 설치 명령을 사용하세요.

  • npm: npm ci
  • pnpm: pnpm install --frozen-lockfile
  • yarn: yarn install --frozen-lockfile

3) 원인 2: path가 틀렸거나, 캐시할 게 없다

캐시는 “키”만 맞아도 “경로”가 잘못되면 의미가 없습니다.

상대경로/작업 디렉터리 함정

  • defaults.run.working-directory를 설정해두고, path: node_modules를 쓰면 의도한 위치가 아닐 수 있습니다.
  • node_modules가 실제로는 apps/web/node_modules에 생기는데 루트만 캐시함.
- uses: actions/cache/restore@v4
  with:
    path: |
      apps/web/node_modules
      ~/.npm
    key: ${{ runner.os }}-web-${{ hashFiles('apps/web/package-lock.json') }}

설치가 실패/스킵되어 디렉터리가 비어 있다

캐시는 지정한 path에 파일이 없으면 저장할 게 없습니다. 설치 단계가 조건문으로 스킵되거나, 이전 단계에서 실패했는지 확인하세요.

캐시하면 안 되는 것까지 캐시함

node_modules 자체를 캐시하면 플랫폼/네이티브 모듈 때문에 오히려 깨지는 경우가 있습니다. Node 생태계에서는 보통 아래 조합이 안정적입니다.

  • npm: ~/.npm만 캐시 + npm ci
  • pnpm: ~/.pnpm-store 캐시
  • yarn: .yarn/cache (Berry)

4) 원인 3: OS/아키텍처/런타임이 바뀌었다

키에 OS를 포함하지 않으면, Linux에서 저장한 캐시를 Windows/macOS에서 복원하려다 실패하거나(혹은 더 나쁘게는 잘못 복원)합니다.

key: ${{ runner.os }}-node${{ matrix.node }}-npm-${{ hashFiles('package-lock.json') }}

추가로 네이티브 바이너리 의존성이 있는 프로젝트라면 architecture까지 고려해야 합니다(특히 self-hosted, ARM64 등).

5) 원인 4: 브랜치/PR/포크 스코프 때문에 캐시가 안 보인다

GitHub Actions 캐시는 보안/격리 정책 때문에 “다른 컨텍스트의 캐시”를 마음대로 읽지 못합니다. 대표 케이스:

  • pull_request(포크)에서 base repo의 캐시를 못 읽음
  • 브랜치별로 캐시 접근이 제한되어 기대한 캐시가 안 나옴

해결 방향

  • 포크 PR에서 캐시를 기대하지 말고, base 브랜치(main)에서 warm-up 후 PR은 restore-keys로만 부분 복원 기대
  • 또는 pull_request_target로 전환(단, 보안상 매우 주의: 신뢰할 수 없는 코드 실행 위험)

6) 원인 5: 저장 단계가 실행되지 않는다(실패/취소/조건문)

actions/cache는 job이 끝날 때 post 단계에서 저장이 이뤄지는 패턴이 많습니다. 따라서 아래 상황이면 저장이 안 됩니다.

  • 설치/테스트 단계에서 job이 실패하여 종료
  • if: 조건 때문에 캐시 step 자체가 스킵
  • continue-on-error/fail-fast 조합으로 의도치 않게 조기 종료

팁: 실패해도 캐시는 저장하고 싶다

설치까지는 되었는데 테스트 실패로 저장이 안 되는 경우, save step을 분리하고 if: always()로 저장을 강제할 수 있습니다(단, 깨진 상태를 저장하지 않도록 조건을 더 엄격히 하세요).

- name: Save cache (even if tests fail)
  if: always() && steps.cache.outputs.cache-hit != 'true'
  uses: actions/cache/save@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}

7) 원인 6: 권한/토큰 문제로 캐시 API 호출이 막힌다

캐시는 내부적으로 GitHub의 cache 서비스 API를 호출합니다. 권한이 꼬이면 저장/복원 단계에서 403/404 유사 증상이 나타날 수 있습니다.

  • GITHUB_TOKEN 권한이 제한됨
  • 조직 정책으로 Actions 권한 제한
  • permissions:를 너무 타이트하게 설정

예를 들어 아래처럼 permissions를 최소화하다가 캐시가 안 되는 경우가 있습니다.

permissions:
  contents: read
  # actions: read  (조직/환경에 따라 필요할 수 있음)

권한 이슈가 의심되면 관련 글(GitHub Actions 403 권한오류 해결 - GITHUB_TOKEN·OIDC)의 체크리스트대로 토큰 스코프/조직 설정을 먼저 정리하세요.

8) 원인 7: restore-keys를 잘못 이해했다(“cache-hit false”지만 복원은 됨)

restore-keys는 prefix 매칭으로 “가장 가까운 캐시”를 가져옵니다. 이때 primary key가 정확히 일치하지 않으면 cache-hitfalse입니다.

즉, cache-hit: false가 항상 “캐시가 전혀 없음”을 의미하지 않습니다.

진단 방법

  • 로그에서 Cache restored from key: 문구 확인
  • steps.<id>.outputs.cache-primary-key / cache-matched-key 확인(v4)
- name: Debug cache keys
  run: |
    echo "primary: ${{ steps.cache.outputs.cache-primary-key }}"
    echo "matched: ${{ steps.cache.outputs.cache-matched-key }}"

9) 패키지 매니저별 베스트 프랙티스(안정성 중심)

npm

  • 캐시 대상: ~/.npm
  • 설치: npm ci
- uses: actions/cache/restore@v4
  id: npm-cache
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

- run: npm ci

pnpm

  • 캐시 대상: ~/.pnpm-store 또는 pnpm store path 결과
  • 설치: pnpm install --frozen-lockfile
- name: Get pnpm store
  id: pnpm-store
  run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT

- uses: actions/cache/restore@v4
  id: pnpm-cache
  with:
    path: ${{ steps.pnpm-store.outputs.path }}
    key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-

- run: pnpm install --frozen-lockfile

Yarn Berry(2+)

  • 캐시 대상: .yarn/cache
  • 설치: yarn install --immutable
- uses: actions/cache/restore@v4
  id: yarn-cache
  with:
    path: .yarn/cache
    key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}
    restore-keys: |
      ${{ runner.os }}-yarn-

- run: yarn install --immutable

10) 실전 진단 순서(10분 컷)

1) 로그에서 “저장 성공”이 있었는지 확인

  • 없다면: job 실패/취소/조건문/권한 문제부터

2) 키를 출력해 눈으로 비교

- name: Print cache key material
  run: |
    echo "os=${{ runner.os }}"
    echo "lockHash=${{ hashFiles('package-lock.json') }}"
  • lockHash가 빈 값이면 파일 경로/checkout 문제

3) restore-keys로 복원은 되는지 확인

  • restore-keys: ${{ runner.os }}-npm- 같은 prefix를 추가
  • 그래도 안 되면 path/스코프/권한을 의심

4) path가 실제로 존재하는지 확인

- name: Inspect paths
  run: |
    ls -la ~
    ls -la ~/.npm || true
    ls -la node_modules || true

11) 자주 묻는 함정 Q&A

Q1. cache-hit가 계속 false인데도 빌드가 빨라졌다

restore-keys로 부분 복원된 경우입니다. 정확히 같은 키가 아니면 cache-hit는 false지만, 복원은 되었을 수 있습니다. cache-matched-key를 출력해 확인하세요.

Q2. 캐시가 어느 순간부터 갑자기 안 먹는다

대개 아래 중 하나입니다.

  • 락파일 포맷/경로 변경(모노레포 구조 변경)
  • Node 버전/OS 매트릭스 추가로 키 설계가 깨짐
  • npm install로 바뀌면서 락파일이 매번 갱신

Q3. self-hosted runner에서만 캐시가 이상하다

self-hosted는 디스크 권한/홈 디렉터리/사용자 계정이 매번 다를 수 있습니다. ~가 가리키는 위치가 기대와 다른지부터 확인하세요.

12) 결론: “키 안정성” + “저장 보장” + “스코프 이해”

cache-hit 0%를 끝내려면 다음 3가지를 고정하면 됩니다.

  1. 키는 락파일 해시 기반으로 만들고 변동 요소(sha/run_id)를 넣지 않는다.
  2. 저장 단계가 실제로 실행되도록 restore/save를 분리하고, 실패/조건문에 의해 저장이 누락되지 않게 한다.
  3. 브랜치/PR/포크 스코프 때문에 캐시가 보이지 않을 수 있음을 이해하고, 기대치를 조정하거나 워크플로 이벤트를 재설계한다.

캐시가 권한 문제로 보이면 먼저 토큰/권한을 정리한 뒤 캐시를 재점검하세요: GitHub Actions 403 권한오류 해결 - GITHUB_TOKEN·OIDC