- Published on
GitHub Actions 캐시로 CI 꼬일 때 진단·해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
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:→ missCache saved with key:→ 저장 성공Cache size:→ 너무 큰 캐시는 업로드/다운로드로 오히려 느려짐
또한 캐시가 “복원은 되었는데 저장이 안 되는” 경우가 있습니다.
- 동일 키가 이미 존재하면 저장이 스킵됩니다(동일 키로는 덮어쓰기 불가)
- 병렬 job이 먼저 저장해버리면, 나중 job은 저장이 안 되거나 의미가 없어집니다
3) 캐시 키 설계의 정석: 재현 가능 + 안전한 무효화
캐시 키는 다음 원칙을 만족해야 합니다.
- 환경이 다르면 분리: OS, 아키텍처, 런타임 버전(Node/Java/Python)
- 의존성이 바뀌면 무효화: lockfile 해시 포함
- 너무 넓은 restore-keys는 지양: 오래된 캐시가 섞일 확률 증가
- 빌드 산출물 캐시는 목적별로 분리: 의존성 캐시와 빌드 캐시를 한 키로 묶지 않기
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로 삭제할 수 있지만, 실무에서는 보통 아래 순서가 빠릅니다.
- 키에
CACHE_VERSION을 올려 새 캐시로 이동(가장 쉬움) - 저장소 Settings에서 캐시 삭제(필요 시)
- restore-keys를 좁혀 “오래된 캐시를 줍지 않게” 조정
캐시 삭제는 권한/조직 정책에 따라 접근이 번거로울 수 있어, 대부분은 1번이 가장 실용적입니다.
13) 체크리스트: 캐시로 꼬인 CI를 15분 안에 정리하는 순서
- 로그에서
Cache restored from key확인(어떤 키를 쓰는지) - lockfile 해시가 키에 포함되어 있는지 확인
- OS/런타임 버전이 키에 포함되어 있는지 확인
- restore-keys가 너무 넓은지 확인(넓으면 일단 제거)
- 의존성 캐시와 빌드 캐시를 분리
- 동시 실행/병렬 저장 경쟁이 있는지 확인 →
concurrency적용 검토 CACHE_VERSION올려 강제 무효화로 정상화- 이후 재발 방지: 스코프(브랜치/워크플로/job) 분리 여부 결정
결론
GitHub Actions 캐시는 “빠르게 만드는 도구”이지만, 키/스코프/복원 전략이 부정확하면 “불안정성을 증폭시키는 장치”가 됩니다. 핵심은 (1) lockfile 기반 무효화, (2) 환경(OS/런타임) 분리, (3) restore-keys 최소화, (4) 의존성과 산출물 캐시 분리, (5) 동시성 제어입니다. CI가 간헐적으로 흔들릴 때는 캐시를 의심하고, 먼저 키를 읽고(로그), 다음으로 스코프를 조여(키 설계), 마지막으로 강제 무효화(CACHE_VERSION)로 빠르게 안정화하는 흐름으로 접근하면 해결 속도가 크게 빨라집니다.