Published on

GitHub Actions 캐시 안 먹힘? key 복구 9단계

Authors

서로 다른 브랜치에서 동일한 빌드를 반복하는데도 GitHub Actions 캐시가 매번 cache miss로 뜨면, 대개 “캐시가 없는 것”이 아니라 “찾을 수 없는 키를 만들고 있는 것”입니다. 특히 actions/cache를 처음 적용할 때는 key를 너무 자주 바뀌게 만들거나(과도한 해시), 반대로 너무 뭉뚱그려서(오염된 캐시) 다시 꺼내 쓰지 못하는 상황이 흔합니다.

이 글은 캐시가 안 먹히는 상황에서 key를 다시 안정적으로 설계하고, 실제로 히트율을 올리는 key 복구 9단계 체크리스트입니다. CI를 안정화하는 관점에서는 “재시도/백오프” 같은 회복 설계도 중요하니, 운영 관점이 궁금하면 OpenAI API 429·Rate Limit 재시도 백오프 설계도 함께 참고하면 좋습니다.

캐시가 안 먹히는 대표 증상 3가지

  1. 로그에 Cache not found for input keys가 매번 뜬다
  2. restore-keys를 넣었는데도 항상 miss다
  3. 로컬에서는 잘 되는데 PR이나 pull_request 이벤트에서만 miss가 난다

이때는 “캐시가 저장되었는지”보다 먼저, 캐시를 찾는 규칙이 일관적인지를 확인해야 합니다.

1단계: 캐시 대상이 맞는지부터 확인하기

캐시는 빌드 산출물(dist, build)을 저장하는 용도라기보다, 보통 의존성 다운로드/컴파일 캐시에 효과가 큽니다.

  • Node.js: npm 캐시 디렉터리, pnpm store, Yarn cache
  • Gradle: ~/.gradle/caches, ~/.gradle/wrapper
  • Maven: ~/.m2/repository
  • Python: pip cache, poetry cache

빌드 산출물을 캐시하면, 브랜치/커밋 간 불일치로 오염되기 쉽고 히트율도 떨어집니다. 산출물은 아티팩트(actions/upload-artifact)로 분리하는 편이 안전합니다.

2단계: path가 실제로 존재하는지 로그로 검증하기

actions/cache는 지정한 path가 비어 있거나 존재하지 않아도 “겉보기엔” 잘 동작하는 것처럼 보일 수 있습니다. 다음처럼 캐시 이전/이후에 디렉터리 상태를 출력해 보세요.

- name: Debug cache paths
  run: |
    set -eux
    ls -la
    du -sh ~/.npm || true
    du -sh ~/.cache/pnpm || true

path가 잘못되면, 키가 아무리 좋아도 캐시 효용은 0입니다.

3단계: key에 “너무 자주 바뀌는 값”을 넣었는지 점검하기

가장 흔한 실수는 keygithub.sha 같은 값을 넣는 겁니다. 커밋마다 키가 바뀌니 영원히 miss가 납니다.

나쁜 예(거의 항상 miss):

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ github.sha }}

좋은 예(락파일 기반, 변경될 때만 새 캐시):

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

핵심은 의존성이 바뀔 때만 키가 바뀌게 만드는 것입니다.

4단계: hashFiles 대상 파일이 이벤트에서 보이는지 확인하기

hashFiles('**/pnpm-lock.yaml')를 썼는데 계속 miss라면, 다음 가능성을 체크하세요.

  • 모노레포에서 락파일 위치가 다르다
  • PR 이벤트에서 체크아웃이 얕게 되거나(fetch-depth) 특정 파일이 없다고 판단된다
  • 생성되는 파일(npm install 후 만들어지는 lock)을 해시 대상으로 삼았다

해결 팁:

  • 락파일은 레포에 커밋된 파일만 대상으로
  • 체크아웃을 확실히 수행
- uses: actions/checkout@v4
  with:
    fetch-depth: 0

5단계: restore-keys를 “폴백 계층”으로 설계하기

restore-keys는 완전 일치 키가 없을 때 접두사(prefix) 매칭으로 가장 가까운 캐시를 가져옵니다. 여기서도 자주 하는 실수는 접두사를 너무 구체적으로 잡는 겁니다.

예시(Node + npm):

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-
      npm-
  • 1순위: OS + 락파일 해시 완전 일치
  • 2순위: 같은 OS에서 가장 최근 유사 캐시
  • 3순위: OS도 달라도 일단 가져오기(프로젝트 성격에 따라 비권장)

폴백을 허용할수록 히트율은 오르지만, 오염 가능성도 올라갑니다. 언어나 패키지 매니저 특성에 맞춰 조절하세요.

6단계: 브랜치/PR 스코프를 오해했는지 확인하기

캐시는 “레포 단위로 공유된다”라고만 이해하면 위험합니다. 실제로는 이벤트와 보안 정책에 의해 체감 스코프가 달라집니다.

  • pull_request에서 포크(fork) PR은 캐시 쓰기가 제한되거나 기대와 다르게 동작할 수 있음
  • 브랜치마다 키가 달라지면(예: github.ref_name 포함) 서로 캐시를 못 씀

따라서 브랜치를 키에 넣을지 여부는 목적에 따라 결정해야 합니다.

  • 히트율 최우선: 브랜치 제외
  • 격리 최우선(오염 방지): 브랜치 포함

브랜치를 포함하는 예:

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

7단계: 캐시가 “저장되지 않는” 조건을 제거하기

actions/cache는 스텝이 실행되더라도, 워크플로가 실패로 끝나거나(조건에 따라) 저장이 안 되는 케이스가 있습니다. 또한 캐시는 보통 잡 종료 시점에 업로드됩니다.

권장 패턴:

  • 의존성 설치가 실패하면 캐시가 업데이트되지 않는 건 정상
  • 캐시를 저장하려면 잡이 끝까지 가야 함
  • 캐시 스텝에 if를 과하게 걸지 말기

디버깅을 위해 캐시 히트 여부를 출력하세요.

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

- name: Cache debug
  run: |
    echo "cache-hit=${{ steps.cache-npm.outputs.cache-hit }}"

8단계: setup-* 액션의 내장 캐시와 중복 적용을 정리하기

actions/setup-node@v4는 npm/yarn/pnpm에 대해 내장 캐시 옵션을 제공합니다. 이를 actions/cache와 동시에 쓰면

  • 같은 경로를 중복 캐시하거나
  • 서로 다른 키 전략이 충돌해서
  • 기대와 다른 히트율을 만들 수 있습니다.

내장 캐시를 쓸 거면 간결하게:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
    cache-dependency-path: package-lock.json

- run: npm ci

직접 actions/cache로 컨트롤할 거면 내장 캐시는 끄고(설정하지 않고) 한 방식만 유지하세요.

9단계: “키 복구”를 위한 최종 권장 템플릿(모노레포 포함)

여기부터가 실전에서 가장 많이 쓰는 형태입니다.

  • OS 고정
  • 패키지 매니저/런타임 버전 반영
  • 락파일 해시 기반
  • 모노레포는 워크스페이스 락파일 단일화를 전제로 하거나, 필요한 락파일을 명시

Node.js + pnpm 예시

pnpm은 store 경로가 환경에 따라 달라질 수 있으니, store path를 명시적으로 얻어 캐시하는 패턴이 안정적입니다.

name: ci
on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Enable corepack
        run: corepack enable

      - name: Resolve pnpm store path
        id: pnpm-store
        run: |
          set -eux
          echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"

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

      - name: Install
        run: pnpm install --frozen-lockfile

      - name: Test
        run: pnpm test

      - name: Cache debug
        run: echo "cache-hit=${{ steps.cache-pnpm.outputs.cache-hit }}"

이 템플릿이 “키 복구”에 강한 이유는 다음과 같습니다.

  • github.sha 같은 휘발성 값을 제거
  • 런타임 버전(node20)을 키에 포함해 ABI/의존성 차이로 인한 오염을 줄임
  • 락파일 기반으로만 키가 변하므로 히트율이 안정적

자주 묻는 함정: 캐시 히트인데도 빌드가 느린 이유

캐시가 히트해도 느릴 수 있습니다.

  • 캐시 압축/해제 시간이 다운로드 시간보다 길다
  • 캐시 경로가 너무 커서(예: node_modules 전체) 오히려 손해
  • 네트워크가 빠른 러너에서는 캐시 효용이 낮음

이럴 때는 “캐시 크기”를 줄이거나, 캐시 경로를 store/registry 캐시로 바꾸는 게 정답입니다.

운영 관점 팁: 실패를 줄이는 설계로 연결하기

캐시는 CI 안정화의 한 축이고, 나머지는 실패 시 회복 전략입니다. 예를 들어 외부 레지스트리 장애나 일시적 네트워크 오류가 잦다면, 단순 재시도 정책을 설계하는 것이 전체 파이프라인 시간을 줄일 때가 많습니다. 이 관점은 OpenAI API 429·Rate Limit 재시도 백오프 설계처럼 “실패를 전제로 한 설계”와 동일한 결을 가집니다.

또한 Git 기반 워크플로에서 충돌/정리 비용이 커 CI가 자주 깨진다면, 캐시보다 먼저 브랜치 전략과 리베이스 자동화가 더 큰 효과를 낼 수 있습니다. 관련해서는 Git rebase 충돌 자동해결 - rerere·autosquash 실전도 함께 보면 파이프라인 유지보수 비용을 줄이는 데 도움이 됩니다.

마무리: 캐시가 “안 먹히는” 게 아니라 “못 찾는” 경우가 대부분

정리하면, GitHub Actions 캐시 이슈는 대부분 다음 3가지로 귀결됩니다.

  • path가 틀렸다
  • key가 너무 자주 바뀐다
  • 스코프(브랜치/PR/포크)와 폴백(restore-keys) 설계가 없다

위 9단계를 순서대로 적용하면, 캐시가 왜 miss 나는지 원인을 로그로 설명할 수 있고, 그 과정에서 자연스럽게 key를 복구하게 됩니다. 캐시 히트율이 안정화되면, 그때부터는 “얼마나 빠르게”가 아니라 “얼마나 안전하게(오염 없이)” 캐시를 공유할지로 최적화 포인트가 이동합니다.