Published on

GitHub Actions 캐시 무효화 안됨 - key·restore-keys 디버깅

Authors

CI가 느려서 actions/cache를 붙였는데, 어느 순간부터 캐시가 무효화되지 않거나(의존성 바꿨는데도 예전 캐시 사용), 반대로 매번 미스가 나서(항상 새로 설치) 속도가 안 나오는 경우가 자주 있습니다. 문제의 대부분은 key 설계와 restore-keysprefix 매칭 동작을 정확히 이해하지 못한 데서 시작합니다.

이 글에서는 캐시가 “안 바뀌는 것처럼 보이는” 전형적인 상황을 재현하고, 어떤 로그를 봐야 하며, keyrestore-keys를 어떻게 설계해야 안전한지 실전 기준으로 정리합니다.

관련해서 파이프라인에서 원인 추적을 체계화하는 방법은 Jenkins 파이프라인 AbortException 원인·해결도 함께 참고하면 접근 방식이 비슷해 도움이 됩니다.

GitHub Actions 캐시의 핵심 규칙 3가지

actions/cache는 단순해 보이지만, 아래 규칙을 정확히 알아야 디버깅이 됩니다.

1) 캐시는 “정확히 일치하는 key”가 있으면 그대로 복원

  • key가 정확히 일치하면 해당 캐시를 복원합니다.
  • 이때 이미 존재하는 캐시는 덮어쓰지 않습니다.
    • 동일한 key로는 새 캐시를 저장할 수 없고, 저장 단계에서 사실상 스킵됩니다.

즉, 키가 안 바뀌면 캐시도 안 바뀝니다.

2) restore-keys는 “prefix 매칭”으로 가장 가까운 캐시를 찾음

  • restore-keys는 줄 단위로 후보 prefix를 제공합니다.
  • GitHub Actions는 prefix로 시작하는 key 중에서 가장 적절한(가장 최근/가장 구체적인) 캐시를 선택해 복원합니다.

이 때문에 restore-keys를 너무 넓게 잡으면, 의존성이 바뀌었는데도 “그럴듯한 옛 캐시”가 복원되어 문제를 숨깁니다.

3) 캐시는 브랜치/OS/경로/툴체인 차이로 쉽게 오염됨

캐시 키에 OS, 런타임 버전, lockfile 해시가 들어가지 않으면 다음 문제가 생깁니다.

  • Ubuntu에서 만든 캐시를 macOS에서 복원 시도
  • Node 18에서 만든 node_modules를 Node 20에서 복원
  • lockfile이 바뀌었는데도 prefix 캐시가 복원

“캐시 무효화가 안 된다”에서 자주 보는 착각

착각 A: lockfile을 바꿨는데도 캐시가 그대로다

대부분은 정확 일치 key가 아니라 restore-keys로 복원되고 있는 상황입니다.

예를 들어 아래 설정은 위험합니다.

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

package-lock.json이 바뀌면 key는 달라져서 원래는 미스가 나야 정상입니다. 하지만 restore-keysnpm-OS- prefix로 너무 넓게 열려 있어서, 과거 캐시가 복원됩니다.

이게 왜 문제냐면, npm 캐시 자체는 비교적 안전한 편이지만, yarn/pnpm의 store나 node_modules를 캐시하는 경우엔 의존성 불일치가 실제 빌드 결과를 망가뜨릴 수 있습니다.

착각 B: key를 바꿨는데도 캐시가 “갱신”되지 않는다

actions/cache는 동일 key에 대해 “업데이트 저장” 개념이 없습니다. key가 같으면 저장이 스킵됩니다.

따라서 캐시를 새로 만들고 싶다면 반드시 key를 바꿔야 합니다.

  • lockfile 해시
  • 툴 버전
  • 수동 bump 값

같은 것들을 키에 포함시키는 이유가 여기 있습니다.

착각 C: 캐시가 맞게 복원됐는데도 설치가 다시 돈다

캐시 대상 경로가 잘못됐거나, 패키지 매니저가 기대하는 경로와 불일치할 가능성이 큽니다.

예:

  • npm: ~/.npm (다운로드 캐시), node_modules는 보통 캐시 비추천
  • pnpm: ~/.pnpm-store 또는 pnpm store path 결과
  • yarn berry: .yarn/cache

디버깅 1단계: cache hit 여부와 “실제 사용된 key” 확인

워크플로 로그에서 다음을 확인해야 합니다.

  • Cache restored from key: ...
  • Cache not found for input keys: ...
  • Cache hit occurred on the primary key ... 같은 메시지

actions/cache@v4는 출력값도 제공합니다.

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

- name: Print cache result
  run: |
    echo "cache-hit=${{ steps.cache.outputs.cache-hit }}"

여기서 cache-hittrueprimary key가 정확히 일치한 것입니다. false인데도 뭔가가 복원됐다면, restore-keys로 복원됐을 가능성이 큽니다.

디버깅 2단계: hashFiles가 예상대로 바뀌는지 검증

가장 흔한 원인은 hashFiles가 생각한 파일을 못 읽어서, 해시가 고정되는 경우입니다.

예:

  • 모노레포인데 lockfile 경로가 다름
  • package-lock.json이 없고 pnpm-lock.yaml을 쓰는데도 잘못 지정
  • 체크아웃 이전에 hashFiles를 계산하려고 함(대부분은 순서 문제)

아래처럼 해시를 로그로 찍어 확인하세요.

- uses: actions/checkout@v4

- name: Debug hash
  run: |
    echo "lock-hash=${{ hashFiles('package-lock.json') }}"
    ls -la

모노레포면 glob을 더 명시적으로:

- name: Debug hash
  run: |
    echo "lock-hash=${{ hashFiles('**/pnpm-lock.yaml') }}"

해시가 빈 문자열로 나오면(또는 항상 같은 값이면) 파일 매칭이 실패했을 가능성이 큽니다.

디버깅 3단계: restore-keys를 의도적으로 좁혀서 문제 재현

캐시 무효화가 안 되는 상황을 잡으려면, 일단 restore-keys를 제거하고 “정확 매치만 허용”해보는 것이 가장 빠릅니다.

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
  • 이렇게 했는데도 계속 hit가 뜨면, lockfile 해시가 안 바뀌고 있는 것입니다.
  • 이렇게 했더니 miss가 뜨고 설치가 정상 동작하면, 기존 문제는 restore-keys가 너무 넓어서 발생한 것입니다.

안전한 key 설계 패턴

패턴 1: OS + 런타임 + lockfile 해시

Node 예시:

- name: Use Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '20'

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

여기서 restore-keys는 같은 OS/런타임 범위로만 제한합니다.

패턴 2: 모노레포는 “패키지별 스코프”를 키에 포함

모노레포에서 모든 패키지가 lockfile을 공유하지 않는다면, 서비스 단위로 키를 나누는 것이 안전합니다.

env:
  SERVICE: apps/api

- uses: actions/cache@v4
  with:
    path: |
      ${{ env.SERVICE }}/node_modules
    key: nm-${{ runner.os }}-${{ env.SERVICE }}-${{ hashFiles(format('{0}/package-lock.json', env.SERVICE)) }}

다만 node_modules 캐시는 플랫폼/네이티브 모듈/후처리 스크립트로 인해 깨지기 쉬워, 가능하면 다운로드 캐시(예: ~/.npm) 위주로 구성하는 것을 권장합니다.

패턴 3: 강제 무효화를 위한 manual bump

“원인을 모르겠는데 일단 캐시를 날리고 싶다”는 운영 요구가 있습니다. 이때는 키에 버전 값을 넣습니다.

env:
  CACHE_BUSTER: v3

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ env.CACHE_BUSTER }}-${{ hashFiles('package-lock.json') }}

CACHE_BUSTER만 올리면 새 캐시가 생성됩니다.

restore-keys를 쓸 때의 체크리스트

1) prefix가 너무 넓으면 “오염된 캐시”를 부른다

다음은 피하는 편이 좋습니다.

  • restore-keys: npm-
  • restore-keys: ${{ runner.os }}-

최소한 런타임 버전까지는 포함하세요.

2) restore-keys는 “성능 최적화”이지 “정확성 보장”이 아니다

restore-keys는 캐시 미스 시에도 비슷한 캐시를 가져와 시간을 줄이는 장치입니다. 하지만 빌드 재현성이 중요한 파이프라인에서는 restore-keys가 오히려 장애를 만들 수 있습니다.

빌드가 자주 깨지거나, 의존성 불일치가 의심되면 restore-keys를 과감히 제거하고 원인을 좁히는 것이 좋습니다.

캐시가 계속 미스 나는 경우(매번 새로 설치) 원인

1) key가 매번 달라지는 값에 의존

예:

  • key: cache-${{ github.sha }}
  • key: cache-${{ github.run_id }}

이러면 매 실행마다 새로운 키가 되어 캐시가 쌓이기만 하고 히트가 나지 않습니다.

2) path가 빌드 중 생성되지 않거나 권한 문제가 있음

캐시 저장은 잡 종료 시점에 path를 아카이브합니다. path가 없으면 저장이 스킵될 수 있습니다.

디버깅용으로 캐시 대상 디렉터리 존재 여부를 확인하세요.

- name: Debug cache path
  run: |
    ls -la ~/.npm || true
    du -sh ~/.npm || true

3) 다른 워크플로/잡에서 같은 key를 선점

동일 key는 한 번만 저장됩니다. 여러 잡이 경쟁하면 한쪽은 저장이 스킵될 수 있습니다.

이때는 job 이름이나 매트릭스 값을 키에 포함해 충돌을 피합니다.

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

추천 디버깅 절차(현업용)

  1. restore-keys를 잠시 제거하고 재현
  2. hashFiles 값 로그 출력으로 lockfile 매칭 검증
  3. steps.cache.outputs.cache-hit로 primary hit 여부 확인
  4. 키에 OS/런타임/lockfile 해시가 모두 포함됐는지 점검
  5. 필요 시 CACHE_BUSTER로 강제 무효화

이런 식으로 “관측 가능한 값”으로 좁혀가면, 캐시 문제는 대부분 10분 내로 결론이 납니다. 운영 환경에서 원인 추적이 꼬일 때는 systemd 서비스가 계속 재시작될 때 원인 추적법처럼 로그를 기준으로 가설을 검증하는 접근이 특히 유효합니다.

마무리: 캐시의 목표는 속도, 전제는 재현성

GitHub Actions 캐시는 CI 시간을 줄이는 강력한 도구지만, restore-keys를 넓게 잡거나 key에 중요한 축(OS, 런타임, lockfile)을 빼먹으면 “빨라 보이지만 불안정한 파이프라인”이 됩니다.

  • 캐시 무효화가 안 되는 것처럼 보이면, 실제로는 restore-keys로 옛 캐시를 복원하고 있을 확률이 높습니다.
  • 캐시가 갱신되지 않으면, 동일 key로는 덮어쓰기 저장이 불가하다는 점을 먼저 의심해야 합니다.

위의 디버깅 체크리스트와 안전한 키 패턴을 적용하면, 캐시를 성능 최적화 수단으로 쓰면서도 빌드 재현성을 유지할 수 있습니다.