Published on

GitHub Actions 캐시 안 먹을 때 key·restore-keys 디버깅

Authors

CI가 느려질 때 가장 먼저 의심하는 게 actions/cache인데, 막상 적용해도 매번 Cache not found for input keys가 뜨거나, 히트하는 듯하다가도 빌드 시간이 그대로인 경우가 많습니다. 특히 keyrestore-keys를 대충 구성하면 캐시가 "저장만 되고" 다음 런에서 못 찾거나, 반대로 너무 넓게 복원되어 잘못된 캐시가 섞이는 문제가 생깁니다.

이 글은 GitHub Actions 캐시가 안 먹는 상황을 key·restore-keys 관점에서 재현 가능한 방식으로 디버깅하는 절차를 정리합니다. 로그에서 무엇을 봐야 하는지, 키를 어떻게 쪼개야 하는지, 그리고 언어별(특히 Node, Python, Gradle)로 자주 터지는 함정을 함께 다룹니다.

참고로 캐시 문제는 "캐시" 자체라기보다 "키 설계"와 "경로/권한/런너 차이" 문제인 경우가 대부분입니다. 비슷한 결로, 캐시가 꼬여서 원인 파악이 어려울 때는 Next.js 14 RSC에서 fetch 캐시 꼬임 해결법처럼 "무엇이 캐시 키를 구성하는가"를 분해해서 보는 접근이 효과적입니다.

캐시 동작 원리: key는 정확히 매칭, restore-keys는 접두사 매칭

actions/cache의 핵심 규칙은 단순합니다.

  • key: 완전 일치(exact match)할 때만 히트
  • restore-keys: 접두사(prefix) 일치로 가장 근접한 캐시를 찾아 복원
  • 한 번 저장된 캐시는 기본적으로 **불변(immutable)**에 가깝게 취급됩니다. 같은 key로 다시 저장하려고 해도 "이미 존재"로 간주되어 덮어쓰지 않습니다.

따라서 캐시 전략은 보통 아래의 2단 구조로 설계합니다.

  1. key는 가능한 한 "정확"하게(락파일 해시 등)
  2. restore-keys는 가능한 한 "안전"하게(과복원 방지)

문제는 여기서 restore-keys를 너무 넓게(예: OS만 넣고 끝) 잡으면, 오래된 의존성 캐시가 복원되어 이상한 빌드 실패나 미묘한 성능 저하를 만들 수 있습니다. 반대로 너무 좁게 잡으면 매번 MISS가 납니다.

먼저 확인할 것: 진짜로 캐시가 MISS인가, 복원은 됐는데 체감이 없는가

캐시가 "안 먹는다"는 증상은 크게 두 가지입니다.

  1. 진짜 MISS: 복원 로그에 Cache not found가 뜸
  2. 복원은 됨: Cache restored from key가 뜨는데도 설치/빌드가 느림

2번은 대개 캐시 경로가 잘못됐거나(예: node_modules가 아니라 npm 다운로드 캐시만 복원), 패키지 매니저가 캐시를 사용하지 않는 설정(예: npm ci의 특성, --prefer-offline 미사용 등), 혹은 캐시 복원 시점이 늦어서(설치 후에 restore) 효과가 없는 경우입니다.

디버깅을 위한 최소 로그 체크리스트

워크플로 로그에서 아래 문자열을 먼저 찾으세요.

  • Cache not found for input keys:
  • Cache restored from key:
  • Cache saved successfully
  • Path Validation Error:

Path Validation Error가 보이면 거의 100% 경로 문제입니다(존재하지 않는 경로를 캐시로 지정했거나, 상대 경로 기준이 예상과 다름).

가장 흔한 원인 1: 키에 "매번 바뀌는 값"이 섞여 있다

다음 값이 key에 들어가면 캐시는 사실상 매번 새로 생성됩니다.

  • github.run_id, github.run_number
  • 타임스탬프
  • 커밋 SHA 전체(브랜치마다 캐시를 분리하려는 의도라면 가능하지만, 너무 세분화되면 효율이 급락)

나쁜 예

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

좋은 예: 락파일 해시를 키의 "정확성"으로 사용

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

여기서 hashFiles는 락파일이 바뀌지 않으면 동일한 값을 유지합니다. 즉, 의존성이 동일한 동안은 캐시가 안정적으로 히트합니다.

가장 흔한 원인 2: restore-keys가 접두사 매칭이라는 걸 모르고 설계한다

restore-keys는 "부분 일치"가 아니라 "접두사"입니다. 예를 들어 아래처럼 구성하면:

restore-keys: |
  npm-${{ runner.os }}-${{ github.ref_name }}-

github.ref_namemain에서 feature-x로 바뀌는 순간, 서로의 캐시를 전혀 복원하지 못합니다. 브랜치별 캐시가 꼭 필요하지 않다면, 브랜치 이름은 restore-keys에서 빼거나 더 상위 접두사를 추가해야 합니다.

권장 패턴: 계층형 restore

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-${{ github.ref_name }}-
      npm-${{ runner.os }}-
  • 1순위: 같은 브랜치에서 근접 캐시
  • 2순위: OS만 같은 범용 캐시

단, 2순위는 "오래된 의존성"이 섞일 수 있으니, 캐시 대상이 안전한지(다운로드 캐시인지, 빌드 산출물인지) 반드시 구분해야 합니다.

가장 흔한 원인 3: 캐시 경로가 런너/툴 기준과 다르다

캐시의 path는 "내가 저장하고 싶은 폴더"가 아니라 "툴이 실제로 읽고 쓰는 캐시 폴더"여야 합니다.

Node.js 예시: ~/.npm(다운로드 캐시) vs node_modules(설치 결과)

  • ~/.npm 캐시는 대체로 안전하고, npm ci에서도 효과가 납니다(다운로드 시간을 줄임).
  • node_modules 캐시는 프로젝트 구조/플랫폼 차이로 깨지기 쉽고, npm ci는 기본적으로 node_modules를 지우고 다시 설치하기 때문에 기대만큼 효과가 없을 수 있습니다.

권장 예시:

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

- run: npm ci

setup-node의 내장 캐시를 쓰면 경로/키 설계를 어느 정도 표준화할 수 있습니다.

Python 예시: ~/.cache/pip

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

- run: pip install -r requirements.txt

인코딩/텍스트 처리 문제로 락파일 생성이 흔들리면 해시가 바뀌어 MISS가 늘어납니다. 윈도우에서 줄바꿈이나 인코딩이 섞일 때는 Python UnicodeDecodeError 원인별 해결 7가지처럼 "입력 텍스트가 환경에 따라 달라지는" 원인부터 정리하는 게 좋습니다.

Gradle 예시: ~/.gradle/caches~/.gradle/wrapper

- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      gradle-${{ runner.os }}-

키 디버깅 핵심: "내가 생각한 키"가 로그에 그대로 찍히게 만들기

키가 왜 매번 달라지는지 감이 안 오면, 키를 구성하는 각 조각을 출력하세요. GitHub Actions는 YAML에서 문자열 합성이 많아지면 실수하기 쉽습니다.

키 구성 요소를 step summary에 남기기

- name: Debug cache key parts
  shell: bash
  run: |
    echo "OS=${{ runner.os }}" >> $GITHUB_STEP_SUMMARY
    echo "REF=${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
    echo "LOCK_HASH=${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_STEP_SUMMARY

이렇게 해두면, PR마다 어떤 값이 바뀌어 캐시가 갈라졌는지 바로 보입니다.

restore-keys를 넓힐수록 위험해지는 캐시 대상: 빌드 산출물 캐시

의존성 다운로드 캐시는 비교적 안전하지만, 빌드 산출물(예: dist, .next/cache, target)은 소스/환경/플러그인 버전에 민감합니다.

예를 들어 Next.js의 .next/cache는 빌드 속도에 도움이 되지만, 프레임워크/플러그인/환경변수 차이로 캐시 오염이 발생하면 "이상한" 증상이 생길 수 있습니다. 이런 경우 restore-keys를 OS 접두사까지 넓히는 건 위험합니다.

권장 예시(Next.js 빌드 캐시를 보수적으로):

- uses: actions/cache@v4
  with:
    path: .next/cache
    key: next-${{ runner.os }}-${{ hashFiles('package-lock.json', 'next.config.*', 'tsconfig.json') }}
    restore-keys: |
      next-${{ runner.os }}-

여기서도 restore-keys는 최소화하고, 키에 영향을 주는 설정 파일을 충분히 포함시키는 게 중요합니다.

캐시가 저장은 되는데 다음 런에서 못 찾는 경우: 브랜치/PR 스코프를 의심

GitHub Actions 캐시는 "어떤 ref에서 생성됐는지"에 따라 접근성이 달라질 수 있습니다. 특히 포크 PR에서 권한이 제한되거나, 특정 이벤트에서 캐시 저장이 차단되는 경우가 있습니다.

  • pull_request 이벤트(특히 fork)에서는 보안상 제한이 걸어 저장/복원이 기대와 다를 수 있음
  • 기본 브랜치에서만 캐시가 쌓이고, 기능 브랜치에서는 restore만 되는 구성도 흔함

전략: 기본 브랜치에서 캐시를 만들고, PR에서는 복원만

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

- name: Install
  run: npm ci

- name: Save cache only on main
  if: github.ref_name == 'main'
  run: echo "Cache will be saved by actions/cache automatically when miss" 

actions/cache는 MISS일 때 job 종료 시점에 저장합니다. 이벤트/권한으로 저장이 막히면 로그에 힌트가 남는 편이니, 해당 런의 cache step 로그를 꼭 확인하세요.

캐시 히트인데도 느린 경우: 실제로는 네트워크가 아니라 CPU 작업이 병목

캐시가 다운로드 시간을 줄여도, 빌드가 느린 원인이 CPU 연산(트랜스파일, 번들링, 테스트)이라면 체감이 없을 수 있습니다. 이때는 캐시보다 "병목 지점"을 먼저 계측해야 합니다.

  • 의존성 설치 단계 시간 vs 빌드 단계 시간 분리
  • --verbose나 타임 트레이스 옵션(Gradle build scan, Next.js build trace 등) 활용

운영에서 병목을 "증상"이 아니라 "지표"로 쪼개는 습관은, 예를 들어 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅처럼 로그와 단계별 관측으로 원인을 줄여나가는 방식과 동일합니다.

실전 템플릿: 안전한 키 설계 3종 세트

아래 템플릿은 "MISS를 줄이되, 과복원으로 인한 오염을 최소화"하는 쪽으로 설계했습니다.

1) npm 다운로드 캐시

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

- run: npm ci --prefer-offline

2) pip 캐시

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

- run: pip install -r requirements.txt

3) Gradle 캐시

- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/settings.gradle*') }}
    restore-keys: |
      gradle-${{ runner.os }}-

- run: ./gradlew build --no-daemon

체크리스트: 캐시 MISS/오염을 빠르게 좁히는 순서

  1. 로그에서 Cache restored인지 Cache not found인지 먼저 확정
  2. key에 매번 바뀌는 값이 들어갔는지 확인(런 ID, 시간, 커밋 SHA 등)
  3. hashFiles가 의도한 파일을 실제로 해시하는지 확인(경로 글롭 실수 빈번)
  4. restore-keys가 접두사 매칭임을 전제로 계층형으로 설계
  5. path가 "툴이 실제로 쓰는 캐시"인지 검증(존재 여부 포함)
  6. 캐시 대상이 의존성 캐시인지, 빌드 산출물인지 구분하고 restore-keys 폭 조절
  7. 포크 PR, 이벤트 타입, 권한으로 저장이 막히는지 확인

마무리

GitHub Actions 캐시 디버깅은 결국 "키가 어떻게 만들어지고, 어떤 범위로 복원되는지"를 가시화하는 작업입니다. key는 정확히, restore-keys는 보수적으로, 그리고 캐시 경로는 툴의 실제 동작을 기준으로 잡으면 대부분의 "캐시가 안 먹는다" 문제는 해결됩니다.

다음 단계로는 캐시가 히트했을 때 실제로 어느 단계가 빨라졌는지(설치 vs 빌드 vs 테스트)를 분리 계측해보세요. 캐시 최적화가 필요한지, 아니면 빌드 자체 최적화가 필요한지 판단이 훨씬 쉬워집니다.