왜 캐시가 갑자기 안 맞기 시작하는가

GitHub Actions 캐시는 리포지토리당 총 10GB 한도를 가진다. 이 한도를 넘으면 GitHub는 가장 오래 접근되지 않은(LRU) 캐시 항목부터 자동으로 축출한다. 문제는 축출이 조용히 일어난다는 점이다. 어느 날부터 node_modules 복원이 계속 miss가 나고 CI 시간이 2~3배로 늘어나는데, 워크플로 로그에는 "Cache not found"만 찍힌다. 원인은 대개 브랜치마다 서로 다른 캐시 키를 남발해 10GB를 금방 채우고, 정작 필요한 main 브랜치 캐시가 밀려난 경우다.

캐시 스코프와 격리 규칙 이해하기

캐시는 브랜치(ref) 스코프로 격리된다. 핵심 규칙 두 가지만 기억하면 된다. 첫째, 브랜치에서 만든 캐시는 그 브랜치와 하위 브랜치, 그리고 그 브랜치를 대상으로 하는 PR에서만 읽을 수 있다. 둘째, 기본 브랜치(main)에서 만든 캐시는 모든 브랜치에서 읽을 수 있다.

따라서 PR용 feature 브랜치는 자기 캐시를 못 찾으면 main의 캐시로 폴백한다. 이 구조 때문에 "공용 의존성 캐시는 반드시 main에서 채워두는" 전략이 유효하다. feature 브랜치마다 완전한 캐시를 새로 만들 필요가 없다.

restore-keys로 부분 적중 살리기

정확한 키가 없어도 접두사가 맞으면 가장 최근 캐시를 복원하도록 restore-keys를 계층적으로 둔다. 락파일이 조금 바뀌어도 이전 캐시를 받아 증분만 갱신할 수 있다.

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

주의점: restore-keys로 부분 적중한 뒤 저장 단계에서 key가 이미 존재하면 GitHub는 덮어쓰지 않는다(캐시는 불변). 즉 키가 같으면 오래된 내용이 계속 남으니, 콘텐츠가 바뀌면 키도 반드시 바뀌게 hashFiles를 넣어야 한다.

10GB를 잡아먹는 브랜치 캐시 청소

병합·삭제된 브랜치의 캐시가 방치되면 한도를 잠식한다. gh CLI로 현황을 보고 오래된 것부터 지운다.

#!/usr/bin/env bash
set -euo pipefail
REPO="org/repo"

# 용량 큰 순으로 상위 20개 확인
gh cache list --repo "$REPO" --sort size_in_bytes --order desc --limit 20

# 이미 닫힌 PR(ref: refs/pull/*/merge) 캐시 일괄 삭제
gh api "repos/$REPO/actions/caches?per_page=100" --paginate \
  | jq -r '.actions_caches[] | select(.ref|test("refs/pull/")) | .id' \
  | while read -r id; do
      gh api -X DELETE "repos/$REPO/actions/caches/$id"
    done

자동 정리 워크플로

PR이 닫힐 때 그 브랜치 캐시를 지우는 이벤트 훅을 걸면 수동 청소가 필요 없다. actions:write 권한만 있으면 된다.

on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    permissions:
      actions: write
    steps:
      - name: Delete caches for this PR branch
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
        run: |
          gh api "repos/${{ github.repository }}/actions/caches?ref=$BRANCH" \
            | jq -r '.actions_caches[].id' \
            | xargs -I{} gh api -X DELETE \
                "repos/${{ github.repository }}/actions/caches/{}"

캐시 vs 아티팩트: 무엇을 어디에

큰 산출물을 캐시에 밀어넣으면 10GB가 순식간에 찬다. 용도를 나눠야 한다.

항목캐시(cache)아티팩트(artifact)
목적의존성·빌드 중간물 재사용잡 간 산출물 전달·보관
수명7일 미접근 시 축출기본 90일 보존
한도리포당 총 10GB스토리지 요금제 한도
적합 대상node_modules, ~/.cargo테스트 리포트, 릴리스 바이너리

7일 규칙도 중요하다. 자주 안 도는 브랜치의 캐시는 접근이 없으면 사라진다. 야간에만 도는 파이프라인이 매번 miss라면 이 축출이 원인일 수 있다.

운영 시 체크리스트

  • 공용 의존성 캐시는 main에서 채워 모든 브랜치가 폴백하도록 한다.
  • 키에 hashFiles를 넣어 콘텐츠 변경 시 키가 바뀌게 한다.
  • OS·아키텍처·언어 버전을 키 접두사에 포함해 잘못된 복원을 막는다.
  • PR 종료 훅과 주기적 gh cache list 점검으로 10GB를 상시 여유 있게 유지한다.
  • 대용량 산출물은 캐시가 아니라 아티팩트로 분리한다.

정리하면, 캐시 문제의 대부분은 "키 설계"와 "용량 위생"에서 갈린다. 키를 콘텐츠에 묶고 폴백을 main으로 모으며, 닫힌 브랜치 캐시를 자동으로 걷어내면 10GB 안에서 안정적인 적중률을 유지할 수 있다.