Published on

GitHub Actions 캐시로 CI 꼬일 때 진단·해결 가이드

Authors

서론

GitHub Actions의 캐시는 CI 시간을 줄여주지만, 한 번 꼬이기 시작하면 “로컬에서는 되는데 CI만 실패”, “어제까지 되던 테스트가 갑자기 깨짐”, “의존성 업데이트를 했는데도 예전 버전이 계속 사용됨” 같은 형태로 생산성을 크게 갉아먹습니다. 특히 actions/cache정확히 설계된 키(key)와 복원 전략(restore-keys), 그리고 무효화(invalidation) 시나리오가 없으면, 실패가 간헐적(flaky)으로 나타나 원인 추적이 어렵습니다.

이 글은 캐시 때문에 CI가 꼬일 때의 전형적인 증상들을 분류하고, 로그 기반으로 원인을 좁힌 뒤, 안전하게 고치는 방법(키 설계, 스코프 분리, 강제 무효화, 캐시 삭제/회피, 병렬/동시성 이슈)을 단계별로 정리합니다. 동시 실행으로 인해 같은 캐시를 두 잡(job)이 경쟁적으로 업데이트하는 문제는 캐시 이슈와 함께 자주 나타나므로, 필요하다면 GitHub Actions 동시 실행 막힘 해결 - concurrency·cancel-in-progress도 같이 참고하면 좋습니다.

1) 캐시가 CI를 망가뜨리는 대표 증상

캐시 문제는 보통 아래 중 하나로 드러납니다.

1.1 의존성 업데이트가 반영되지 않음

  • package-lock.json/pnpm-lock.yaml을 바꿨는데도 예전 패키지가 설치됨
  • pip/poetry/bundler가 이전 wheel/gem을 계속 사용

원인 후보:

  • 키에 lockfile 해시가 포함되지 않음
  • restore-keys가 너무 넓어 “대충 맞는 오래된 캐시”를 가져옴

1.2 빌드 산출물이 섞여서 링크/테스트가 깨짐

  • TypeScript/Java/Kotlin에서 이전 컴파일 결과가 남아 타입/바이너리 불일치
  • Next.js/Vite/Webpack 캐시가 남아 번들 결과가 이상함

원인 후보:

  • node_modules와 빌드 캐시(.next, dist, .turbo 등)를 한 덩어리로 캐시
  • OS/아키텍처/Node 버전이 다른데 같은 캐시를 복원

1.3 간헐적 실패(Flaky) + 재시도하면 성공

  • 같은 커밋인데 어떤 런에서는 성공, 어떤 런에서는 실패

원인 후보:

  • 여러 워크플로/브랜치가 동일 키로 같은 캐시를 업데이트
  • 병렬 job이 동일 캐시 키를 경쟁적으로 저장

1.4 “Cache hit인데도 느림” 또는 “Cache miss가 계속남”

  • hit인데 설치가 다시 일어남
  • miss가 반복되어 캐시가 전혀 쌓이지 않음

원인 후보:

  • 캐시 경로(path)가 잘못됨(실제 패키지 매니저가 쓰는 디렉터리와 다름)
  • 키가 매번 바뀜(타임스탬프 포함, 커밋 SHA만 사용 등)

2) 먼저 확인할 것: Actions 로그에서 캐시 동작 읽는 법

actions/cache는 로그에 힌트를 많이 남깁니다. 아래를 체크하세요.

  • Cache restored from key:정확히 어떤 키로 복원되었는지
  • Cache not found for input keys: → miss
  • Cache saved with key: → 저장 성공
  • Cache size: → 너무 큰 캐시는 업로드/다운로드로 오히려 느려짐

또한 캐시가 “복원은 되었는데 저장이 안 되는” 경우가 있습니다.

  • 동일 키가 이미 존재하면 저장이 스킵됩니다(동일 키로는 덮어쓰기 불가)
  • 병렬 job이 먼저 저장해버리면, 나중 job은 저장이 안 되거나 의미가 없어집니다

3) 캐시 키 설계의 정석: 재현 가능 + 안전한 무효화

캐시 키는 다음 원칙을 만족해야 합니다.

  1. 환경이 다르면 분리: OS, 아키텍처, 런타임 버전(Node/Java/Python)
  2. 의존성이 바뀌면 무효화: lockfile 해시 포함
  3. 너무 넓은 restore-keys는 지양: 오래된 캐시가 섞일 확률 증가
  4. 빌드 산출물 캐시는 목적별로 분리: 의존성 캐시와 빌드 캐시를 한 키로 묶지 않기

3.1 Node.js(pnpm) 예시: 의존성 캐시만 안전하게

아래 예시는 pnpm store만 캐시합니다. node_modules 자체를 캐시하면 플랫폼/네이티브 모듈/포스트인스톨 스크립트 영향으로 깨질 가능성이 커서, 보통은 store 캐시가 더 안전합니다.

name: ci
on:
  push:
  pull_request:

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

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

      - name: Enable corepack
        run: corepack enable

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

      - name: Cache pnpm store
        uses: actions/cache@v4
        with:
          path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
          key: pnpm-store-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml') }}

      - name: Install
        run: pnpm install --frozen-lockfile

      - name: Test
        run: pnpm test

포인트:

  • runner.os + node 버전 + hashFiles(lockfile) 조합
  • restore-keys를 일부러 넣지 않았습니다. “대충 이전 캐시”를 복원하면 꼬임이 늘어나는 경우가 많습니다.

3.2 Python(pip) 예시: pip 캐시 + requirements 해시

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

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

- run: pip install -r requirements.txt

4) “restore-keys”가 CI를 꼬이게 만드는 전형적인 패턴

restore-keys는 miss일 때 “가장 가까운 캐시”를 가져오게 해줍니다. 하지만 범위가 넓으면 의존성/런타임이 달라졌는데도 오래된 캐시를 주워오면서 문제를 유발합니다.

4.1 위험한 예

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

이 경우 package-lock.json이 바뀌어 miss가 나면, npm-ubuntu-로 시작하는 아무 캐시나 복원합니다. lockfile이 크게 바뀐 상황(메이저 업그레이드, 네이티브 모듈 변경 등)에서 특히 위험합니다.

4.2 안전한 타협안

정말 restore-keys가 필요하다면, 최소한 런타임 버전까지는 고정하세요.

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

5) 캐시 경로(path) 오류: hit인데도 설치가 다시 되는 이유

캐시 hit인데도 설치가 다시 일어나면, 대개 캐시한 경로와 실제로 사용되는 경로가 다릅니다.

  • pnpm: pnpm store path
  • npm: ~/.npm
  • yarn berry: .yarn/cache 등 설정에 따라 다름
  • gradle: ~/.gradle/caches, ~/.gradle/wrapper

진단 팁:

  • 설치 단계 직전에 ls -al로 캐시 경로에 파일이 실제로 존재하는지 확인
  • 패키지 매니저가 출력하는 캐시 디렉터리 로그를 확인

6) “캐시가 오염됐다” 판단 기준과 빠른 재현법

캐시 오염은 보통 아래 상황에서 발생합니다.

  • 동일 키를 여러 브랜치/PR에서 공유
  • 서로 다른 설정(Feature flag, optional dependency, postinstall 조건)이 같은 키로 저장
  • 병렬 job이 같은 키를 저장하려고 경쟁

6.1 캐시 오염 재현용 디버그 스텝

아래처럼 런타임/락파일/환경 변수를 출력해두면, “왜 같은 키를 썼는지”가 보입니다.

- name: Debug cache context
  run: |
    node -v || true
    python --version || true
    echo "RUNNER_OS=$RUNNER_OS"
    echo "GITHUB_REF=$GITHUB_REF"
    echo "GITHUB_SHA=$GITHUB_SHA"
    echo "LOCK_HASH=${{ hashFiles('pnpm-lock.yaml', 'package-lock.json', 'yarn.lock') }}"

7) 해결 전략 1: 키를 ‘더 촘촘하게’ 만들어 오염을 차단

가장 효과적인 처방은 키에 스코프를 추가하는 것입니다.

  • 브랜치별 분리: ${{ github.ref_name }}
  • 워크플로별 분리: ${{ github.workflow }}
  • job별 분리: ${{ github.job }}
  • 모노레포 패키지별 분리: lockfile 경로/패키지명 포함

예: PR과 main이 같은 캐시를 공유해 꼬인다면

key: pnpm-store-${{ runner.os }}-node20-${{ github.ref_name }}-${{ hashFiles('pnpm-lock.yaml') }}

주의:

  • 브랜치 스코프를 넣으면 캐시 재사용률이 떨어져 비용(시간)이 늘 수 있습니다.
  • 대신 “안정성”이 필요한 파이프라인(릴리즈, 배포)은 분리하는 편이 낫습니다.

8) 해결 전략 2: 강제 무효화(버전 스탬프)로 한 번에 리셋

캐시가 한 번 오염되면, 키가 같으면 계속 재사용됩니다. 이때는 버전 스탬프를 키에 추가해 전체 무효화를 걸 수 있습니다.

env:
  CACHE_VERSION: v3

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

CACHE_VERSION만 올리면 새 캐시로 갈아탑니다. 운영 중인 CI에서 “오늘은 무조건 새로” 같은 응급처치로도 좋습니다.

9) 해결 전략 3: 캐시를 ‘의존성’과 ‘빌드’로 분리

많이 하는 실수는 다음을 한 캐시에 넣는 것입니다.

  • 의존성(~/.npm, pnpm store, ~/.m2, ~/.gradle)
  • 빌드 산출물(dist, .next, build, target)

산출물 캐시는 프로젝트/브랜치/빌드 옵션에 민감합니다. 의존성 캐시와 분리하고, 산출물 캐시는 더 촘촘한 키로 관리하세요.

예: TurboRepo/Next.js 빌드 캐시

- name: Cache turbo
  uses: actions/cache@v4
  with:
    path: .turbo
    key: turbo-${{ runner.os }}-node20-${{ github.ref_name }}-${{ hashFiles('**/package.json', '**/pnpm-lock.yaml', 'turbo.json') }}

10) 해결 전략 4: 동시성/병렬 실행으로 인한 캐시 경쟁 막기

다음 상황에서 캐시가 특히 잘 꼬입니다.

  • 같은 브랜치에서 push가 연속으로 발생 → 여러 workflow run이 겹침
  • matrix 전략으로 여러 job이 동일 캐시 키를 저장

대응:

  • concurrency로 “같은 브랜치의 이전 실행을 취소”
  • 캐시는 복원만 공유하고, 저장은 대표 job만 수행(조건부 저장)

10.1 concurrency로 겹치는 실행 취소

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

동시 실행 제어는 캐시뿐 아니라 전체 CI 안정성에 큰 영향을 주므로, 자세한 패턴은 GitHub Actions 동시 실행 막힘 해결 - concurrency·cancel-in-progress에서 더 확장해 볼 수 있습니다.

10.2 matrix에서 저장은 한 번만

strategy:
  matrix:
    shard: [1,2,3]

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

# ... 테스트 실행 ...

- name: Save cache only on shard 1
  if: matrix.shard == 1
  run: echo "(actions/cache는 post 단계에서 저장되므로, shard별 키 분리나 동시성 제어가 더 확실합니다)"

참고로 actions/cache는 기본적으로 스텝의 post 단계에서 저장이 일어나므로, “저장 자체를 특정 샤드에서만” 완전히 통제하려면 샤드별로 키를 분리하거나 워크플로를 분리하는 게 더 확실합니다.

11) 응급 처치: 캐시를 끄고 원인 분리하기

캐시가 원인인지 확신이 안 설 때는, 일단 캐시를 비활성화해서 신호를 분리하세요.

11.1 입력으로 캐시 토글

on:
  workflow_dispatch:
    inputs:
      use_cache:
        type: boolean
        default: true

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

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

      - run: pnpm install
      - run: pnpm test

캐시를 끄면 문제가 사라진다 → 캐시 키/경로/스코프 설계 문제일 확률이 매우 큽니다.

12) “캐시 삭제”가 필요할 때: 현실적인 방법

GitHub Actions 캐시는 UI/API로 삭제할 수 있지만, 실무에서는 보통 아래 순서가 빠릅니다.

  1. 키에 CACHE_VERSION을 올려 새 캐시로 이동(가장 쉬움)
  2. 저장소 Settings에서 캐시 삭제(필요 시)
  3. restore-keys를 좁혀 “오래된 캐시를 줍지 않게” 조정

캐시 삭제는 권한/조직 정책에 따라 접근이 번거로울 수 있어, 대부분은 1번이 가장 실용적입니다.

13) 체크리스트: 캐시로 꼬인 CI를 15분 안에 정리하는 순서

  1. 로그에서 Cache restored from key 확인(어떤 키를 쓰는지)
  2. lockfile 해시가 키에 포함되어 있는지 확인
  3. OS/런타임 버전이 키에 포함되어 있는지 확인
  4. restore-keys가 너무 넓은지 확인(넓으면 일단 제거)
  5. 의존성 캐시와 빌드 캐시를 분리
  6. 동시 실행/병렬 저장 경쟁이 있는지 확인 → concurrency 적용 검토
  7. CACHE_VERSION 올려 강제 무효화로 정상화
  8. 이후 재발 방지: 스코프(브랜치/워크플로/job) 분리 여부 결정

결론

GitHub Actions 캐시는 “빠르게 만드는 도구”이지만, 키/스코프/복원 전략이 부정확하면 “불안정성을 증폭시키는 장치”가 됩니다. 핵심은 (1) lockfile 기반 무효화, (2) 환경(OS/런타임) 분리, (3) restore-keys 최소화, (4) 의존성과 산출물 캐시 분리, (5) 동시성 제어입니다. CI가 간헐적으로 흔들릴 때는 캐시를 의심하고, 먼저 키를 읽고(로그), 다음으로 스코프를 조여(키 설계), 마지막으로 강제 무효화(CACHE_VERSION)로 빠르게 안정화하는 흐름으로 접근하면 해결 속도가 크게 빨라집니다.