- Published on
GitHub Actions 캐시 충돌 시 빌드 완전 초기화 전략
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 브랜치/런/환경에서 재사용되는 GitHub Actions 캐시가 한 번 오염되면, 테스트는 통과하는데 배포 빌드만 깨지거나, 로컬과 CI 결과가 달라지는 등 디버깅이 매우 어려운 상태로 빠집니다. 특히 actions/cache는 “빨라지는 대신, 잘못 설계하면 오래된 산출물이 계속 살아남는” 구조라서, 캐시 충돌(키 설계 충돌)과 캐시 오염(잘못된 디렉터리 캐싱)이 겹치면 빌드가 쉽게 꼬입니다.
이 글은 다음을 목표로 합니다.
- 캐시 충돌/오염의 전형적인 증상과 원인 파악
- 캐시 키 설계로 충돌을 예방하는 방법
- 이미 꼬인 상태에서 완전 초기화(클린 런) 로 복구하는 방법
- 언어/빌드 도구별( Node, Python, Gradle 등) 안전한 캐시 범위 가이드
> CI 문제는 종종 인증/권한 이슈와도 함께 나타납니다. AWS 배포 파이프라인에서 OIDC가 섞여 있다면 캐시 문제와 별개로 토큰 오류가 동반될 수 있으니, 필요하면 GitHub Actions OIDC로 AWS 배포 403 해결 가이드도 함께 확인해보세요.
캐시가 꼬였다는 신호: 증상 체크리스트
다음 증상 중 2개 이상이 동시에 나타나면 캐시 충돌/오염을 강하게 의심할 수 있습니다.
- 같은 커밋인데 재실행(re-run)하면 성공/실패가 번갈아 발생
- 특정 브랜치에서만 실패(특히
main만 실패하거나 PR만 실패) - 의존성 업데이트 후에도 CI가 옛날 버전으로 빌드하는 흔적
node-gyp,pip,gradle에서 바이너리/락파일 불일치 오류가 간헐적으로 발생NoSuchMethodError,ClassNotFound,ModuleNotFound같은 런타임 오류가 CI에서만 발생- 빌드 로그에
Cache restored from key:가 찍히는데, 기대한 키가 아닌restore-keys로 복원됨
핵심은 “캐시가 맞는 것을 가져왔는지”와 “캐시 대상이 안전한지”입니다.
충돌 vs 오염: 원인 분류가 먼저다
1) 캐시 충돌(Cache key collision)
서로 다른 컨텍스트(브랜치, OS, 아키텍처, Node/Python 버전, 빌드 옵션)가 같은 캐시 키를 공유해서 생깁니다.
key: deps-${{ hashFiles('**/package-lock.json') }}처럼 OS/런타임 버전이 빠짐- 모노레포에서 여러 패키지가 같은 키를 씀
restore-keys가 너무 넓어서(접두사 매칭) 엉뚱한 캐시를 주워옴
2) 캐시 오염(Cache poisoning)
캐시 대상 디렉터리에 빌드 산출물, 임시 파일, 환경 의존 바이너리가 섞여 저장되는 경우입니다.
node_modules자체를 캐싱(특히 네이티브 모듈 포함)- Python venv(
.venv)를 통째로 캐싱 - Gradle
build/나 Maventarget/같은 산출물 폴더를 캐싱
일반적으로는 의존성 다운로드 캐시(예: ~/.npm, ~/.cache/pip, ~/.gradle/caches)는 안전한 편이고, 설치 결과물(예: node_modules, venv)은 위험합니다.
1단계: “완전 초기화”를 위한 클린 런 스위치 만들기
캐시가 의심될 때마다 UI에서 캐시를 지우는 건 느리고 번거롭습니다. 가장 현실적인 전략은 워크플로에 클린 런 입력값을 만들어서, 필요할 때 캐시를 강제로 무효화하는 것입니다.
아래 예시는 workflow_dispatch로 clean을 받으면 캐시 키에 clean-<run_id>를 섞어 항상 미스(cache miss) 가 나도록 합니다.
name: CI
on:
push:
pull_request:
workflow_dispatch:
inputs:
clean:
description: "Ignore caches and run clean build"
required: false
default: "false"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Decide cache bust token
id: bust
run: |
if [ "${{ github.event.inputs.clean }}" = "true" ]; then
echo "token=clean-${{ github.run_id }}" >> $GITHUB_OUTPUT
else
echo "token=normal" >> $GITHUB_OUTPUT
fi
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Cache npm download cache
uses: actions/cache@v4
with:
path: ~/.npm
key: |
npm-${{ runner.os }}-node20-
${{ steps.bust.outputs.token }}-
${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
- name: Install
run: npm ci
- name: Test
run: npm test
이 방식의 장점:
- 캐시가 꼬였을 때 버튼 한 번으로 완전 초기화 가능
- 평소에는 정상적으로 캐시 사용
- “캐시를 쓰지 않았을 때 성공한다”는 사실 자체가 원인 규명에 결정적 힌트
2단계: 캐시 키 설계 원칙(충돌 방지)
캐시 키는 “정확히 같을 때만 재사용”되도록 설계해야 합니다. 다음 요소를 최소로 포함하세요.
필수 포함 요소
- OS:
${{ runner.os }}(Linux/Windows/macOS) - 런타임 버전: Node/Python/Java/Go 등
- 락파일 해시:
hashFiles('**/package-lock.json'),poetry.lock,requirements.txt,gradle.lockfile - 모노레포라면 패키지 경로/워크스페이스 구분자
restore-keys는 “정말 필요할 때만”
restore-keys는 접두사 매칭이라, 의도치 않은 캐시를 가져올 수 있습니다. 성능을 조금 포기하더라도 정확한 키 매칭만 사용하는 편이 안정적일 때가 많습니다.
- 빌드가 자주 꼬이는 프로젝트라면
restore-keys를 제거하거나 범위를 좁히세요.
3단계: 무엇을 캐시할 것인가(오염 방지)
아래는 실전에서 안전/위험으로 분류한 가이드입니다.
Node.js
- 안전(권장):
~/.npm,~/.cache/yarn,~/.pnpm-store - 주의:
node_modules(네이티브 모듈, postinstall 스크립트, OS 차이)
가능하면 actions/setup-node의 내장 캐시(cache: npm|yarn|pnpm)를 우선 사용하고, 추가 캐시는 “다운로드 캐시”까지만.
Python
- 안전(권장):
~/.cache/pip, Poetry의 다운로드 캐시 - 주의:
.venv,site-packages전체
pip는 다운로드 캐시만으로도 큰 이득을 봅니다. venv 캐싱은 재현성/오염 리스크가 큽니다.
Java/Gradle
- 안전(권장):
~/.gradle/caches,~/.m2/repository - 주의:
build/,target/
Gradle은 캐시가 잘못되면 증상이 난해해지는데, 이때는 “캐시 초기화 + daemon 끄기”가 빠른 진단 루트가 됩니다.
4단계: 이미 꼬였을 때의 “완전 초기화” 체크리스트
클린 런 스위치로도 해결이 안 되거나, 원인을 더 명확히 하려면 아래를 순서대로 적용합니다.
1) 캐시를 아예 저장하지 않기(1회성)
문제 재현/격리를 위해, 특정 런에서는 캐시 저장을 끄는 게 좋습니다.
- name: Cache pip (disabled when clean)
if: ${{ github.event.inputs.clean != 'true' }}
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py312-${{ hashFiles('**/requirements.txt') }}
2) 도구별 클린 명령 강제 실행
- Node:
npm ci(= clean install), 필요 시rm -rf node_modules package-lock.json은 최후의 수단 - Python:
pip cache purge(진단용), venv 재생성 - Gradle:
./gradlew clean --no-daemon+ 필요 시rm -rf ~/.gradle/caches(진단용)
3) 동시성(Concurrency)로 인한 레이스 제거
같은 브랜치에서 여러 워크플로가 동시에 돌며 캐시를 서로 덮어쓰면 상태가 불안정해질 수 있습니다.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
4) 캐시 대상 경로가 워크스페이스를 침범하는지 확인
캐시 경로에 ${{ github.workspace }} 하위(예: ./.cache, ./dist)를 넣는 순간, 빌드 산출물/임시 파일이 섞이기 쉽습니다. 가능하면 홈 디렉터리(~) 기반의 다운로드 캐시만 사용하세요.
5단계: “캐시가 원인”임을 로그로 증명하는 방법
캐시 문제는 감으로 때려맞히면 오래 걸립니다. 아래처럼 키/복원 여부를 로그로 남기세요.
- name: Restore cache
id: cache
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('**/package-lock.json') }}
- name: Print cache result
run: |
echo "cache-hit=${{ steps.cache.outputs.cache-hit }}"
cache-hit=true인데 빌드가 실패 → 캐시 오염/충돌 가능성 증가cache-hit=false에서만 성공 → 캐시가 원인일 확률 매우 높음
실전 예시: 모노레포에서 충돌을 막는 키 구성
모노레포에서 packages/api와 packages/web가 다른 의존성을 갖는데, 락파일이 루트 하나로만 관리되면 키가 같아져 충돌이 날 수 있습니다. 이때는 워크스페이스 식별자를 키에 포함하세요.
- name: Cache npm for api
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-api-${{ hashFiles('package-lock.json') }}
- name: Cache npm for web
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-web-${{ hashFiles('package-lock.json') }}
또는 락파일을 패키지별로 분리할 수 있다면(pnpm-lock.yaml + workspace) 그게 가장 깔끔합니다.
운영 팁: “캐시 초기화”를 정기 작업으로 만들기
캐시는 시간이 지날수록 누적되고, 특정 시점의 깨진 아카이브가 계속 재사용될 수 있습니다. 다음 운영 팁을 추천합니다.
- 월 1회 또는 큰 마이그레이션(런타임 업그레이드) 시점에
clean=true로 클린 빌드 수행 - 런타임 버전 업(Node 18→20, Python 3.11→3.12 등) 시 키에 버전 반영 필수
- 실패가 간헐적이면, 원인 추적 전 동시성 제한(concurrency) 부터 걸기
장애 분석 관점에서는 “원인 후보를 빠르게 줄이는” 접근이 중요합니다. 이런 사고방식은 서비스 프로세스가 재시작 루프에 빠질 때 체크리스트로 좁혀가는 방식과도 유사합니다. 필요하면 systemd 서비스가 계속 재시작될 때 진단 체크리스트처럼, 단계별로 변수를 제거하는 접근을 참고해도 좋습니다.
결론: 캐시를 ‘성능 기능’이 아니라 ‘상태 관리’로 다뤄라
GitHub Actions 캐시는 빌드를 빠르게 하지만, 잘못 쓰면 CI를 “상태를 가진 시스템”으로 만들어 재현성을 망칩니다. 따라서 전략은 명확합니다.
- 다운로드 캐시만 캐싱하고(오염 방지)
- 키에 OS/런타임/락파일/워크스페이스를 포함해(충돌 방지)
clean스위치로 언제든 완전 초기화 가능한 경로를 마련하고- 필요하면
restore-keys를 과감히 줄여 안정성을 우선한다
이 4가지만 지켜도, “캐시 때문에 가끔 빌드가 꼬이는” 유형의 문제는 대부분 빠르게 종결됩니다.