Published on

GitHub Actions 캐시가 안 먹을 때 - 키 전략·디버깅

Authors

서로 다른 워크플로우/브랜치에서 매번 의존성을 다시 받거나 빌드가 풀로 도는 문제는 대부분 cache key 설계와 path 범위, 그리고 “무엇을 캐시해야 하는가”의 판단이 어긋나서 생깁니다. 이 글은 GitHub Actions 캐시가 안 먹을 때(= cache-hit: false가 반복될 때) 가장 먼저 의심해야 할 포인트를 체크리스트처럼 정리하고, 실제로 캐시 키를 어떻게 설계하고 디버깅해야 재발을 막을 수 있는지 다룹니다.

또한 CI 성능을 올리다 보면 인증/권한 이슈(OIDC, AssumeRole) 때문에 캐시 이전 단계에서 실패하는 경우도 많습니다. 그런 케이스는 GitHub Actions OIDC assume-role 실패 원인별 해결도 함께 참고하면 진단 속도가 빨라집니다.

1) GitHub Actions 캐시 동작 원리 한 장 요약

actions/cache는 “키로 스냅샷을 찾아서 지정한 경로를 복원”하고, 작업이 끝날 때 “같은 키가 없으면 저장”합니다.

  • 복원 단계: key로 정확히 매칭되는 캐시가 있으면 히트
  • 폴백 단계: restore-keys 접두사(prefix)로 가장 가까운 캐시를 찾음
  • 저장 단계: 잡(job) 종료 시점에 key로 캐시가 없으면 업로드

중요한 함정은 다음입니다.

  • 캐시는 “업로드 시점”이 잡 종료 시점이므로, 중간에 실패하면 저장되지 않습니다.
  • key가 매번 바뀌면 항상 미스가 납니다(예: 커밋 SHA를 키에 포함).
  • path가 잘못되면 복원해도 효과가 없습니다(예: 패키지 매니저가 실제로 쓰는 캐시 경로가 아님).

2) 캐시가 안 먹는 대표 원인 10가지

2.1 키에 변동성이 큰 값이 들어감

다음 값은 키에 넣는 순간 “거의 매번 미스”가 됩니다.

  • github.sha
  • 빌드 번호/런 ID
  • 타임스탬프

키는 “의존성/빌드 산출물의 의미 있는 변경”이 있을 때만 바뀌어야 합니다.

2.2 hashFiles 대상이 부정확하거나 과도함

hashFiles로 락 파일만 해시해야 하는데, 소스 전체를 해시하면 소스 변경마다 캐시가 갈립니다.

  • Node: package-lock.json, yarn.lock, pnpm-lock.yaml
  • Gradle: gradle.lockfile, build.gradle, settings.gradle(팀 정책에 따라)
  • Maven: pom.xml(멀티 모듈이면 상위/하위 포함 범위 주의)

2.3 path가 실제 캐시 경로가 아님

예를 들어 npm은 보통 ~/.npm을 캐시로 쓰고, pnpm은 ~/.pnpm-store 또는 설정된 store 경로를 씁니다. node_modules만 캐시하면 플랫폼/네이티브 모듈 차이로 깨지거나, 복원해도 설치 시간이 크게 줄지 않는 경우가 많습니다.

2.4 브랜치/PR/기본 브랜치 간 캐시 공유 전략 부재

PR에서 만든 캐시를 기본 브랜치가 못 쓰거나, 반대로 기본 브랜치 캐시를 PR이 못 쓰면 체감이 크게 떨어집니다. restore-keys로 “기본 브랜치 캐시 폴백”을 제공하는 패턴이 실전에서 가장 효과적입니다.

2.5 OS/아키텍처/런타임 버전을 키에 포함하지 않음

ubuntu-latest에서 만든 캐시를 macos-latest가 복원하려 하면 실패하거나, 복원되더라도 바이너리 호환성 문제가 생깁니다.

키에는 최소한 다음을 넣는 것이 안전합니다.

  • ${{ runner.os }}
  • 언어 런타임 버전(예: Node, Java)

2.6 잡이 실패해서 캐시 저장이 안 됨

테스트 실패, 린트 실패, 권한 실패 등으로 잡이 중간에 종료되면 캐시 저장이 안 됩니다. “복원은 되는데 저장이 안 된다”면 이 케이스가 많습니다.

2.7 동시성(concurrency)로 인한 캐시 경쟁

같은 키로 여러 잡이 동시에 실행되면, 먼저 성공한 잡만 캐시를 저장하고 나머지는 저장을 건너뜁니다. 이는 정상 동작이지만, 키 설계가 너무 넓으면 갱신이 잘 안 되는 느낌을 줄 수 있습니다.

2.8 캐시 용량/정책 문제

리포지토리 단위 캐시 총량 제한, 오래된 캐시 eviction, 대용량 업로드 실패 등으로 기대대로 유지되지 않을 수 있습니다.

2.9 모노레포에서 “전체 락 파일 하나”만으로 키를 만들지 않음

패키지별 락 파일이 있거나, 워크스페이스 구조가 복잡하면 “작업 단위별 키”가 필요합니다.

2.10 빌드 산출물 캐시와 의존성 캐시를 혼동

의존성 캐시(다운로드/압축 해제 시간 단축)와 빌드 캐시(컴파일/테스트 결과 재사용)는 성격이 다릅니다.

  • 의존성: npm/pip/Gradle 다운로드 캐시
  • 빌드: Gradle build cache, Bazel cache, Next.js .next/cache

3) 키 설계 전략: “정확 키 + 폴백 키”가 정답

캐시 키 설계는 다음 두 줄로 요약됩니다.

  • key: 재현 가능한 정확 매칭 키(락 파일 해시 기반)
  • restore-keys: 최대한 재사용 가능한 폴백(브랜치/기본 브랜치/OS 단위 접두사)

3.1 Node 예시: npm 캐시 키

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

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

- name: Install
  run: npm ci

포인트

  • key는 락 파일 해시로만 변합니다.
  • restore-keys는 해시 없이 접두사로 폴백합니다.
  • node-version이 바뀌면 키도 바뀌어야 합니다.

3.2 pnpm 예시: store 경로를 먼저 알아내기

pnpm은 store 경로가 환경에 따라 달라질 수 있으니, 경로를 출력해 확인한 뒤 캐시하는 편이 안전합니다.

- uses: pnpm/action-setup@v4
  with:
    version: 9

- name: Get pnpm store
  id: pnpm-store
  shell: bash
  run: |
    echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT

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

- run: pnpm install --frozen-lockfile

3.3 Python 예시: pip 캐시

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'

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

- run: pip install -r requirements.txt

requirements.txt가 여러 개인 레포라면 해시 대상 패턴을 더 명확히 하거나, 서비스별로 키를 분리하세요.

3.4 Gradle 예시: 의존성 캐시와 빌드 캐시를 분리

Gradle은 “다운로드 캐시”와 “빌드 캐시”를 분리하면 효과가 좋습니다.

- uses: actions/setup-java@v4
  with:
    distribution: 'temurin'
    java-version: '21'

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

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

빌드 캐시는 Gradle 자체 기능(원격/로컬 빌드 캐시)을 쓰는 편이 더 안정적일 때가 많습니다.

4) 브랜치 전략: PR은 기본 브랜치 캐시를 폴백으로

실무에서 가장 체감이 큰 패턴은 “PR은 기본 브랜치 캐시를 먼저 당겨 쓰고, PR 전용 키로 저장”입니다.

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

주의점

  • 기본 브랜치 이름이 main이 아닐 수 있으니 레포 설정에 맞추세요.
  • github.ref_name을 키에 넣으면 브랜치별로 캐시가 갈리지만, restore-keys로 공유가 됩니다.

5) 디버깅: “왜 miss 났는지”를 로그로 증명하기

5.1 캐시 액션 출력 확인

actions/cache는 요약 로그에 Cache not found for input keys 또는 Cache restored from key 같은 문구를 남깁니다. 먼저 이 메시지를 기준으로 “정확 키 미스인지, 폴백도 실패인지”를 구분하세요.

5.2 실제로 생성된 키를 출력

키가 예상과 다른 경우가 많습니다. 해시 결과와 ref, OS 등을 그대로 출력하세요.

- name: Debug cache key inputs
  shell: bash
  run: |
    echo "ref_name=${{ github.ref_name }}"
    echo "os=${{ runner.os }}"
    echo "lock_hash=${{ hashFiles('**/package-lock.json') }}"

5.3 캐시 경로가 존재하는지 확인

복원은 됐는데 효과가 없다면, 복원된 경로가 실제로 채워졌는지 확인해야 합니다.

- name: Inspect cache dir
  shell: bash
  run: |
    ls -la ~/.npm | head -n 50
    du -sh ~/.npm || true

5.4 패키지 매니저가 캐시를 쓰는지 확인

예를 들어 npm은 다운로드 캐시를 쓰지만, npm cinode_modules를 항상 새로 구성합니다. “다운로드가 빨라졌는지”를 로그로 확인하세요.

  • npm: npm ci --prefer-offline 고려
  • pnpm: store 히트 여부 로그 확인
  • Gradle: --info로 캐시 관련 로그 확인

5.5 잡 실패로 저장이 안 되는지 확인

캐시는 잡 끝에서 저장됩니다. 테스트가 실패하면 저장이 안 되니, 캐시를 “반드시 남겨야 하는” 워크플로우에서는 캐시 저장 이전에 실패하지 않도록 단계 분리 또는 조건을 고민해야 합니다.

예: 의존성 설치까지만 별도 워크플로우로 분리해 성공률을 높이는 방식.

6) 무엇을 캐시할 것인가: 의존성 vs 빌드 산출물

6.1 의존성 캐시(다운로드 캐시)가 우선

대부분의 레포에서 가장 안전하고 효과가 꾸준한 건 “다운로드 캐시”입니다.

  • npm: ~/.npm
  • pip: ~/.cache/pip
  • Gradle: ~/.gradle/caches, ~/.gradle/wrapper

이 방식은 OS/런타임만 맞으면 재현성이 높고, 깨질 확률이 낮습니다.

6.2 빌드 산출물 캐시는 신중하게

예를 들어 Next.js의 .next/cache 같은 것은 잘 먹히면 매우 빠르지만, 키 설계가 조금만 어긋나도 “묘하게 느리거나, 가끔 깨지는” 원인이 됩니다.

빌드 캐시 키에는 최소한 다음이 들어가야 합니다.

  • OS
  • 런타임 버전
  • 락 파일 해시
  • 빌드에 영향을 주는 환경 변수(예: NEXT_PUBLIC_* 중 빌드 시점에 고정되는 값)

7) 캐시 관련 자주 하는 실수와 교정 패턴

7.1 실수: 키를 너무 촘촘하게

  • 증상: 매번 miss
  • 교정: 커밋 SHA 제거, 락 파일 해시 중심으로 재설계

7.2 실수: 키를 너무 넓게

  • 증상: 히트는 되는데 빌드가 이상하거나 오래됨(오염된 캐시)
  • 교정: OS/런타임/주요 설정을 키에 포함

7.3 실수: restore-keys를 안 씀

  • 증상: 락 파일이 조금만 바뀌어도 완전 miss
  • 교정: 접두사 폴백으로 “가까운 캐시”를 재사용

7.4 실수: 캐시 경로를 추측으로 넣음

  • 증상: 히트여도 속도 개선이 없음
  • 교정: 도구가 실제로 쓰는 캐시 경로를 커맨드로 확인

8) 운영 팁: 캐시가 아니라 실패 원인부터 제거하기

캐시 문제로 보이지만 실제로는 워크플로우 자체가 불안정해서(권한, 네트워크, 토큰 만료) 캐시 저장까지 못 가는 경우가 있습니다. 특히 클라우드 인증이 끼면 “가끔 실패”가 치명적입니다.

  • OIDC/AssumeRole 실패로 잡이 중단되면 캐시 저장도 중단
  • 의존성 다운로드가 네트워크 문제로 흔들리면 캐시가 있어도 체감이 줄어듦

인증 계층이 의심되면 GitHub Actions OIDC assume-role 실패 원인별 해결을 같이 점검하세요.

또한 캐시는 결국 “시간 최적화” 수단입니다. 병목이 캐시가 아닌 테스트/렌더링/빌드 자체라면, 캐시만 만지다 시간을 낭비할 수 있습니다. 성능 병목을 찾는 접근 자체는 Chrome 렌더링 느림 - Long Task 잡는 법처럼 “측정 가능한 지표로 원인을 좁히는 방식”이 CI에도 그대로 적용됩니다.

9) 최종 체크리스트

  • keygithub.sha 같은 고변동 값이 들어가 있지 않은가
  • hashFiles가 락 파일 중심으로 구성되어 있는가
  • ${{ runner.os }}와 런타임 버전이 키에 포함되어 있는가
  • restore-keys로 폴백 전략이 있는가(특히 PR에서 기본 브랜치)
  • path가 실제 캐시 경로가 맞는가(커맨드로 검증)
  • 잡이 끝까지 성공해서 캐시 저장이 수행되는가
  • 캐시 히트가 “실제 속도 개선”으로 이어지는지 로그로 확인했는가

10) 결론

GitHub Actions 캐시는 “한 번 맞추면 계속 빨라지는 마법”이 아니라, 키와 경로를 정확히 설계하고 로그로 검증해야 안정적으로 먹습니다. 가장 좋은 패턴은 락 파일 해시 기반의 정확 키를 두고, restore-keys로 브랜치/기본 브랜치/OS 단위 폴백을 제공하는 것입니다. 여기에 키 입력값 출력, 캐시 디렉터리 검사 같은 디버깅 단계를 추가하면, 캐시 미스가 발생해도 원인을 재현 가능하게 좁히고 빠르게 수정할 수 있습니다.