Published on

GitHub Actions 캐시가 안 먹을 때 디버깅 체크리스트

Authors

서론

GitHub Actions에서 빌드 시간이 갑자기 늘어나면 가장 먼저 의심하는 게 캐시(cache)입니다. actions/cache를 붙여놨는데도 로그에 Cache not found가 뜨거나, 분명 저장은 되는데 다음 실행에서 복원이 안 되거나, 로컬에서는 잘 되던 패키지 매니저 캐시가 CI에서는 매번 새로 내려받는 경우가 흔합니다.

캐시는 “잘 되면 빠르고, 안 되면 조용히 느려지는” 성격이라 원인 파악이 어렵습니다. 이 글은 캐시가 안 먹을 때(= hit가 안 날 때) 원인을 체계적으로 좁히는 디버깅 체크리스트입니다. 네트워크/재시도 관점은 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉처럼 “현상이 간헐적일 때”의 접근법과도 유사하니, 간헐 실패가 있다면 함께 참고하면 좋습니다.

1) 먼저 로그에서 “무엇이 안 되는지” 분리하기

캐시는 크게 두 단계입니다.

  1. restore(복원): 이전에 저장된 캐시를 찾아 워크스페이스에 풀어주는 단계
  2. save(저장): 잡(job) 끝에서 현재 상태를 업로드하는 단계

actions/cache는 로그에 힌트를 많이 남깁니다.

  • Cache not found for input keys: ... → restore 단계에서 매칭 실패
  • Cache restored from key: ... → restore 성공
  • Cache saved with key: ... → save 성공
  • Cache already exists. Skipping save. → 동일 key로 이미 저장돼서 save 생략

즉, “저장이 안 되는지 / 복원이 안 되는지 / 둘 다인지”를 먼저 나누면 디버깅 시간이 줄어듭니다.

2) 체크리스트: key 설계가 너무 자주 바뀌지 않는가?

캐시 hit의 80%는 key에서 결정됩니다. 특히 다음 실수들이 잦습니다.

2.1 lockfile 해시가 매번 달라지는가?

보통 hashFiles('**/package-lock.json') 같은 패턴을 쓰는데,

  • lockfile이 PR마다 바뀌는 구조(예: renovate가 자주 업데이트)
  • 모노레포에서 여러 lockfile이 있고 **/로 전부 해시됨
  • 빌드 과정에서 lockfile이 생성/수정됨(절대 하면 안 됨)

이면 캐시가 “항상 미스”가 됩니다.

권장 패턴: lockfile은 정확히 지정하고, 필요하면 restore-keys로 완화합니다.

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

2.2 key에 너무 많은 변수를 섞고 있지 않은가?

다음 값들은 의도치 않게 캐시를 쪼갭니다.

  • ${{ github.sha }} (커밋마다 바뀜 → 사실상 캐시 무력화)
  • ${{ github.run_id }} / ${{ github.run_number }}
  • ${{ github.ref }}를 그대로(브랜치/태그/PR ref가 다름)

원칙: 캐시를 나누고 싶은 축만 key에 넣습니다.

  • OS/아키텍처(필요)
  • 런타임 버전(예: Node 20 vs 22)
  • lockfile hash(의존성 변경 시만 분기)

3) restore-keys를 제대로 쓰고 있는가?

restore-keys는 “정확히 일치하는 key가 없을 때, prefix 매칭으로 가장 가까운 캐시를 가져오는” 장치입니다.

  • lockfile이 조금 바뀌어도 큰 덩어리(예: npm tarball cache, Gradle wrapper 등)는 재사용 가능
  • 단, 너무 넓게 열면 오래된 캐시를 가져와서 오히려 성능/정합성 문제가 생길 수 있음

예: pip 캐시

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

4) path가 “실제로 존재하는 위치”인가?

캐시가 안 먹는 가장 현실적인 원인 중 하나가 경로 오타/환경 차이입니다.

4.1 홈 디렉터리/워크스페이스 혼동

  • ~/.npm, ~/.cache/pip 같은 홈 기반 경로는 보통 OK
  • node_modules처럼 워크스페이스 상대 경로는 체크아웃 위치에 의존

defaults.run.working-directory를 바꿨거나, 모노레포에서 서브디렉터리로 이동했다면 path가 달라집니다.

4.2 캐시 대상이 “잡 끝까지 남아있지” 않은가?

actions/cacherestore는 step 시작에, save는 job 종료 시점에 수행됩니다.

  • 중간 step에서 rm -rf로 지워버리면 저장할 게 없음
  • 빌드가 실패하면 save가 실행되지 않을 수 있음(설정/상황에 따라)

디버깅용으로 캐시 경로를 출력해보면 빠릅니다.

- name: Inspect cache dir
  run: |
    ls -al ~/.npm || true
    du -sh ~/.npm || true

5) OS/아키텍처/런타임 버전이 바뀌었는가?

캐시는 바이너리/네이티브 모듈 때문에 “환경 종속적”일 수 있습니다.

  • runner.os가 바뀜: ubuntu-latest → 어느 순간 24.04로 바뀌어 캐시 미스
  • 아키텍처가 바뀜: x64 ↔ arm64
  • Node/Python/Java 버전이 바뀜

따라서 key에 최소한 다음은 포함하는 편이 안전합니다.

  • ${{ runner.os }}
  • 런타임 버전(예: ${{ matrix.node }})
strategy:
  matrix:
    node: [20, 22]

- uses: actions/setup-node@v4
  with:
    node-version: ${{ matrix.node }}

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

6) PR 이벤트/포크에서 캐시가 제한되는 케이스

캐시가 “특정 상황에서만” 안 먹는다면 이벤트/권한 이슈를 의심하세요.

  • pull_request(특히 fork PR)에서는 보안상 토큰 권한이 제한될 수 있음
  • 조직/리포 설정에 따라 캐시 접근 정책이 다를 수 있음

대응 방향:

  • fork PR에서는 캐시 저장이 안 되는 걸 전제로 시간을 설계(예: 더 작은 작업 단위)
  • 필요 시 pull_request_target를 검토하되, 보안 리스크(신뢰할 수 없는 코드 실행)를 이해하고 사용

7) 동일 key 충돌: “이미 존재”해서 저장이 스킵되는가?

로그에 다음이 나오면 save가 생략됩니다.

  • Cache already exists. Skipping save.

이건 정상일 수도 있지만, 다음 상황이면 문제입니다.

  • key가 너무 넓어서 서로 다른 상태를 같은 key로 저장하려 함
  • 모노레포에서 서로 다른 패키지가 같은 key를 공유

해결:

  • 패키지/디렉터리 이름을 key에 포함
key: npm-${{ runner.os }}-web-${{ hashFiles('apps/web/package-lock.json') }}

8) 캐시 크기/파일 수가 비정상적으로 큰가?

캐시가 너무 크면 저장/복원 시간이 오히려 손해가 되고, 업로드가 실패하거나 불안정해질 수 있습니다.

  • node_modules 전체 캐시는 대체로 비추(용량/플랫폼 종속/깨지기 쉬움)
  • 대신 패키지 매니저의 다운로드 캐시를 저장(~/.npm, ~/.cache/pip, ~/.gradle/caches 일부)

권장:

  • npm: ~/.npm
  • yarn berry: .yarn/cache(프로젝트에 포함되는 구조 고려)
  • pnpm: ~/.pnpm-store
  • pip: ~/.cache/pip
  • gradle: ~/.gradle/caches, ~/.gradle/wrapper

9) 캐시가 “복원은 되는데 빌드가 여전히 느린” 경우

이 경우는 캐시 hit가 아니라, 캐시가 빌드 병목을 덜어주지 못하는 구조일 수 있습니다.

예:

  • npm 캐시를 복원해도 npm ci는 여전히 많은 I/O를 수행
  • 테스트/번들링이 느린데 의존성 다운로드만 빨라짐

이때는 “정말 줄이고 싶은 시간”을 먼저 프로파일링하고, 캐시 대상을 바꾸는 게 낫습니다.

  • 빌드 산출물 캐시: Turborepo, Nx, Gradle build cache 등
  • Docker layer cache(빌드가 컨테이너 중심이라면)

디버깅 체크리스트 글을 좋아한다면, 같은 방식의 문제 분해 접근으로 Assistants API v2 run이 queued나 in_progress에 멈출 때 실전 디버깅 체크리스트도 참고할 만합니다.

10) 실전: “캐시 상태를 출력”하는 최소 디버깅 템플릿

아래는 Node 프로젝트 기준으로, 캐시 키/히트 여부/디렉터리 상태를 로그로 남기는 템플릿입니다.

name: ci
on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Compute cache key inputs
        run: |
          echo "ref=$GITHUB_REF"
          echo "sha=$GITHUB_SHA"
          node -v
          npm -v
          ls -al
          test -f package-lock.json && echo "lockfile exists" || echo "lockfile missing"

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

      - name: Cache result
        run: |
          echo "cache-hit=${{ steps.cache-npm.outputs.cache-hit }}"
          ls -al ~/.npm || true
          du -sh ~/.npm || true

      - run: npm ci
      - run: npm test

여기서 cache-hit=false인데도 ~/.npm 용량이 큰 경우는 “restore는 됐는데 출력이 false” 같은 예외가 아니라, 대개 다른 키로 복원되었거나(restore-keys 매칭) 혹은 경로가 실제로 기대와 다른 경우입니다. 로그에 Cache restored from key: 라인을 반드시 같이 확인하세요.

11) 자주 놓치는 함정 모음(빠른 체크)

  • lockfile이 체크아웃되지 않음(서브모듈/부분 체크아웃/생성 단계 오류)
  • 모노레포에서 hashFiles('**/package-lock.json')로 의도치 않게 전부 해시
  • working-directory 변경으로 path가 엇갈림
  • OS 업데이트로 ubuntu-latest가 바뀌어 캐시가 갈라짐
  • fork PR에서 캐시 저장/복원 제한
  • key에 github.sha를 넣어 매번 미스
  • node_modules 캐시로 용량 폭증 및 플랫폼 종속 문제

결론

GitHub Actions 캐시 디버깅은 결국 (1) key가 안정적인가, (2) restore-keys로 적절히 완화했는가, (3) path가 실제 환경에서 맞는가, (4) 이벤트/권한/환경(OS·버전) 차이로 캐시가 분리되지 않았는가를 순서대로 확인하는 게임입니다.

위 체크리스트대로 로그를 읽고, key 입력(해시 대상)과 path 존재 여부를 출력해보면 대부분의 “캐시가 안 먹는” 문제는 30분 안에 원인이 드러납니다. 캐시가 간헐적으로 실패하거나 외부 요인(네트워크/레이트리밋 등)과 섞여 보인다면 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉에서 다룬 것처럼 실패를 전제로 한 재시도/관측(로그) 전략도 함께 적용해보세요.