Published on

GitHub Actions 캐시 충돌로 CI 간헐 실패 디버깅

Authors

CI가 “가끔” 실패할 때 가장 사람을 미치게 하는 범인이 GitHub Actions 캐시입니다. 로컬에서는 통과하고, 같은 커밋을 다시 돌리면 통과하는데, 특정 타이밍이나 특정 러너에서만 깨집니다. 이런 패턴은 대개 캐시 충돌(collision) 혹은 부정확한 복원(restore) 으로 인해 “예상과 다른 산출물”이 섞여 들어가면서 발생합니다.

이 글은 캐시 충돌을 재현 가능한 문제로 바꾸는 디버깅 절차와, 한 번 고치고 끝내는 키 설계/복원 정책/동시성 제어까지 다룹니다. 캐시 무효화로 빌드가 느려지는 문제는 아래 글과 함께 보면 균형 잡힌 전략을 세우기 좋습니다.

캐시 충돌이 만드는 전형적인 증상

캐시 충돌은 “다른 조건에서 만들어진 캐시”가 “지금 조건”에 복원되면서 생깁니다. 다음 증상이 나오면 캐시를 1순위로 의심하세요.

  • 같은 커밋인데 재실행하면 성공/실패가 바뀜
  • 특정 브랜치에서는 실패, 메인에서는 성공(혹은 반대)
  • 실패 로그가 빌드/테스트 단계에서 애매하게 깨짐
    • 예: 의존성 버전이 맞지 않는 듯한 컴파일 에러
    • 예: 테스트에서 간헐적으로 ClassNotFound, ENOENT, Permission denied
  • actions/cache 단계에서 Cache restored from key: 가 찍히지만, 이후 단계에서 의존성 재해결이나 재다운로드가 발생

핵심은 캐시는 속도를 얻는 대신 “정합성”을 설계로 보장해야 한다는 점입니다.

GitHub Actions 캐시의 동작을 정확히 이해하기

actions/cache 는 대략 다음처럼 동작합니다.

  1. key 로 정확히 일치하는 캐시가 있으면 복원
  2. 없으면 restore-keys 접두사(prefix)로 “가장 근접한” 캐시를 복원할 수 있음
  3. 잡(job)이 끝날 때, 복원된 캐시가 “정확히 같은 key”가 아니라면 새 캐시를 저장하려고 시도

여기서 사고가 나는 지점은 보통 두 가지입니다.

  • restore-keys너무 넓은 범위의 캐시를 복원해 버림(타 OS, 다른 런타임, 다른 lockfile)
  • 여러 잡이 동시에 같은 key 로 저장 경쟁을 하거나, 서로 다른 조건인데 같은 key 를 공유함

1단계: 실패한 런의 캐시 메타데이터를 먼저 수집

디버깅은 “추측”이 아니라 “증거”로 해야 합니다. 우선 캐시가 실제로 어떤 키로 복원되었는지, 히트인지 미스인지부터 로그로 고정하세요.

캐시 히트 여부를 출력하기

actions/cacheid 를 주면 steps.아이디.outputs.cache-hit 로 히트 여부를 제공합니다.

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

- name: Cache debug
  run: |
    echo "cache-hit=${{ steps.cache.outputs.cache-hit }}"
    echo "os=${{ runner.os }}"
    echo "ref=${{ github.ref }}"
    echo "sha=${{ github.sha }}"

여기서 중요한 건 key 구성 요소가 충분한지 확인할 수 있도록, 해시 입력 파일 목록을 명시적으로 잡는 것입니다.

복원된 캐시가 “정말 기대한 것”인지 체크섬으로 검증

캐시 충돌은 복원 단계에서 이미 시작됩니다. 복원 직후에 “캐시 내부 상태”를 간단히 검증하면 원인 규명이 빨라집니다.

예를 들어 Node 프로젝트라면 node_modules 를 캐시하지 않는 것이 일반적이지만(권장), pnpm 스토어 또는 npm 캐시를 쓴다면 아래처럼 확인할 수 있습니다.

- name: Verify lockfile and cache
  run: |
    echo "lockfile sha256:" 
    shasum -a 256 pnpm-lock.yaml | cat
    echo "pnpm store path:" 
    pnpm store path || true

Gradle이라면 wrapper 버전과 캐시 디렉터리의 일부를 출력해 “다른 버전의 산출물”이 섞였는지 확인합니다.

- name: Verify Gradle wrapper
  run: |
    ./gradlew --version
    ls -la ~/.gradle/wrapper || true

2단계: 충돌의 80%는 키 설계 문제다

캐시 키는 “이 캐시가 유효한 조건”을 표현해야 합니다. 조건을 누락하면 충돌이 납니다.

반드시 키에 포함해야 하는 조건 체크리스트

다음 중 하나라도 키에서 빠져 있으면 간헐 실패로 이어질 확률이 큽니다.

  • OS 및 아키텍처: runner.os, 필요 시 runner.arch
  • 런타임 버전: Node, Java, Python 등
  • lockfile 해시: package-lock.json, pnpm-lock.yaml, yarn.lock, gradle.lockfile, poetry.lock
  • 빌드 툴 버전: Gradle wrapper, Maven wrapper, pnpm 버전 등
  • 모노레포라면 워크스페이스 범위: 특정 패키지의 lockfile 또는 관련 파일

Node 예시: Node 버전을 키에 포함

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

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

steps.node.outputs.node-version 이 제공되지 않는 구성도 있으니, 그 경우 node -v 를 출력해 버전이 고정되어 있는지부터 확인하세요.

Gradle 예시: wrapper 및 빌드 스크립트 해시 포함

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

여기서 j17 같은 런타임 버전은 문자열로라도 넣어두면, 나중에 Java 21로 올릴 때 캐시를 섞지 않게 됩니다.

3단계: restore-keys 는 “편의”가 아니라 “위험한 최적화”다

restore-keys 는 키가 정확히 일치하지 않아도 캐시를 가져옵니다. 즉, 의도적으로 정합성을 약화시키는 장치입니다.

언제 restore-keys 를 쓰면 안 되나

  • lockfile이 바뀌었을 때 의존성 해석 결과가 크게 달라지는 생태계(Node, Python 등)
  • 바이너리/네이티브 모듈이 섞이는 경우(특히 OS 차이)
  • 테스트가 캐시된 산출물에 민감한 경우

이런 경우 restore-keys 를 제거하거나 범위를 매우 좁히는 게 좋습니다.

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

위처럼 런타임까지는 고정하고, lockfile만 다른 경우에만 “부분적으로” 도움을 받게 설계합니다.

4단계: 동시성(concurrency)으로 캐시 저장 경쟁을 끊기

간헐 실패의 또 다른 축은 동시에 여러 워크플로가 같은 키에 저장하려고 하면서 발생하는 레이스입니다. 캐시 저장은 job 종료 시점에 일어나므로, 타이밍에 따라 어떤 캐시가 남는지 달라질 수 있습니다.

브랜치 단위로 동시 실행을 제한

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

이렇게 하면 같은 브랜치에서 여러 번 푸시가 연달아 들어올 때, 앞선 실행을 취소해 캐시/산출물 경합을 줄입니다.

PR과 main이 캐시를 공유하지 않게 분리

PR과 main이 같은 키를 쓰면, PR에서 만들어진 캐시가 main에 영향을 줄 수 있습니다. 최소한 참조(ref) 스코프를 분리하세요.

key: ${{ runner.os }}-${{ github.ref_name }}-gradle-${{ hashFiles('**/gradle-wrapper.properties', '**/*.gradle*') }}

다만 브랜치명을 키에 넣으면 캐시 재사용률이 떨어집니다. “간헐 실패”를 먼저 잡고, 안정화 후 재사용률을 올리는 방향이 현실적입니다.

5단계: 캐시 대상(path) 자체가 잘못된 경우를 의심

캐시 충돌로 보이지만 실제로는 “캐시하면 안 되는 디렉터리”를 캐시해서 깨지는 경우도 많습니다.

캐시하면 위험한 것들(대표)

  • 빌드 산출물 디렉터리: dist, build, target
  • 테스트 결과/스냅샷이 섞이는 디렉터리
  • 워크스페이스 전체를 통째로 캐시
  • node_modules (특히 네이티브 모듈 포함 시)

캐시는 “다운로드/해결 비용이 큰 것” 위주로 제한하세요.

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

6단계: 실패를 재현하기 위한 실전 트릭

간헐 실패는 재현이 어려우니, “조건을 흔들어” 충돌을 강제로 드러내는 방식이 효과적입니다.

(1) 매트릭스로 OS/런타임을 섞어 돌려보기

strategy:
  fail-fast: false
  matrix:
    os: [ubuntu-latest, macos-latest]
    node: ['18', '20']

runs-on: ${{ matrix.os }}

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

이때 캐시 키에 OS/런타임이 빠져 있으면, 충돌이 높은 확률로 재현됩니다.

(2) 캐시를 일부러 비우고 비교

캐시 문제가 의심되면 “캐시 비활성화 실행”을 한 번 만들어 비교합니다. 가장 간단한 방법은 키에 실행 번호를 섞어 사실상 미스가 나게 하는 것입니다.

key: ${{ runner.os }}-gradle-debug-${{ github.run_id }}

이 실행이 안정적으로 통과한다면, 캐시 정합성 문제가 거의 확정입니다.

7단계: 캐시를 ‘검증 가능한 계약’으로 만들기

캐시가 복원되었을 때 그 내용이 현재 조건과 맞는지, 빠르게 실패시키는 장치를 넣으면 “조용한 오염”을 막을 수 있습니다.

예: 캐시 버전 파일을 함께 저장하고 검증

캐시 경로 안에 작은 메타 파일을 두고, 복원 후 검사합니다.

- name: Write cache marker
  run: |
    mkdir -p .ci
    echo "node=20" > .ci/cache-marker.txt
    echo "os=${{ runner.os }}" >> .ci/cache-marker.txt

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

- name: Validate cache marker
  run: |
    echo "marker:" 
    cat .ci/cache-marker.txt || true

이 방식은 “캐시가 섞였을 때” 티가 나게 만들어 디버깅 시간을 줄입니다.

8단계: 자주 나오는 케이스별 처방

케이스 A: PR에서만 간헐 실패

  • 원인: PR 병렬 실행, base 브랜치와 헤드 브랜치 캐시 혼용
  • 처방: concurrency 적용, 키에 github.ref_name 또는 github.event.pull_request.number 포함(필요 시)
key: ${{ runner.os }}-pr${{ github.event.pull_request.number }}-gradle-${{ hashFiles('**/gradle-wrapper.properties', '**/*.gradle*') }}

케이스 B: 특정 시간대에만 실패

  • 원인: 의존성 레지스트리 장애처럼 보이지만, 실제로는 캐시가 깨져 재다운로드가 자주 발생하며 그때만 실패가 표면화
  • 처방: 캐시 히트율을 올리기보다 “정확한 캐시”로 안정화, 네트워크 재시도는 보조로만

케이스 C: 테스트만 간헐 실패

  • 원인: 테스트가 이전 실행의 산출물/상태에 오염(캐시된 디렉터리, 공유된 작업 디렉터리)
  • 처방: 테스트 산출물은 캐시에서 제외, 작업 디렉터리 정리
- name: Clean workspace leftovers
  run: |
    rm -rf build dist target .pytest_cache || true

9단계: 최종 점검 체크리스트

  • 캐시 키에 OS, 런타임 버전, lockfile 해시가 모두 포함되어 있는가
  • restore-keys 범위가 과도하지 않은가
  • 캐시 대상 경로에 빌드 산출물이나 실행 중 생성되는 파일이 섞이지 않는가
  • 동시 실행이 같은 키에 저장 경쟁을 만들지 않는가(concurrency 적용)
  • 캐시 복원 직후에 버전/체크섬/마커로 검증하는가

마무리: 캐시는 “빠르게”가 아니라 “정확하게”부터

GitHub Actions 캐시는 잘 쓰면 비용과 시간을 크게 줄이지만, 키/범위/동시성 설계를 대충 하면 “가끔 깨지는 CI”라는 최악의 결과를 만듭니다. 디버깅의 핵심은 캐시를 끄고 통과 여부를 비교해 원인을 확정한 뒤, 키를 촘촘히 하고 restore-keys 를 절제하며, 동시성 제어로 경합을 제거하는 것입니다.

간헐 실패를 잡는 과정은 본질적으로 운영 장애 분석과 닮아 있습니다. 비슷한 방식의 원인 분해/재현/격리를 더 보고 싶다면 아래 글도 함께 참고하세요.