- Published on
GitHub Actions 캐시 무효화로 빌드가 느릴 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 PR에서는 3분이면 끝나던 CI가, 어느 순간부터 매번 15~20분씩 걸린다면 대개 **캐시 미스(cache miss)**가 반복되고 있는 상황입니다. GitHub Actions의 actions/cache는 강력하지만, 키(key) 설계를 조금만 잘못해도 “매번 새 캐시 생성 → 다운로드/압축/업로드 비용 증가 → 빌드 체감 속도 급락”으로 이어집니다.
이 글에서는 캐시가 왜 무효화되는지를 원인별로 분해하고, 키/restore-keys/경로 분리로 재사용률을 끌어올리는 방법을 예제와 함께 정리합니다. (문제의 본질은 ‘캐시를 쓰느냐’가 아니라 ‘캐시를 재사용 하느냐’입니다.)
GitHub Actions 캐시가 무효화되는 대표 원인
1) 키에 변동성이 큰 값(커밋 SHA, run_id 등)을 넣었다
가장 흔한 실수입니다. 아래처럼 키에 github.sha를 넣으면 커밋마다 키가 바뀌어 항상 미스가 납니다.
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ github.sha }}
이 경우 캐시는 “매 커밋마다 새로 만들기”가 되어, 빌드가 느려지는 것은 물론 캐시 저장소도 빠르게 비대해집니다.
원칙: 키는 “의존성이 바뀔 때만” 바뀌게 설계합니다.
2) lockfile이 자주 바뀌는데, 키가 lockfile에 종속되어 있다
hashFiles('**/package-lock.json'), hashFiles('**/poetry.lock') 같은 패턴은 올바른 접근이지만, lockfile이 자주 변하는 저장소라면 캐시가 자주 갈아엎어집니다.
이때는 ‘완전 일치 캐시’만 바라보지 말고, restore-keys로 부분 일치 캐시를 받아오도록 설계해야 합니다.
3) 캐시 경로(path)가 불안정하거나 너무 넓다
- 워크스페이스 내부
node_modules처럼 설치 방식/OS/Node 버전에 민감한 경로를 통째로 캐시 - 빌드 산출물(
dist/,build/)까지 같이 캐시 - 모노레포에서 여러 패키지의 캐시를 한 덩어리로 묶음
이런 경우 캐시 적중률이 떨어지고, 압축/업로드 비용이 커져 오히려 느려질 수 있습니다.
4) matrix(버전/OS)별로 키를 분리하지 않았다
예: Node 18과 Node 20이 같은 캐시를 공유하려 하면, 결과적으로 서로의 캐시를 오염시키거나(설치 결과가 다름) 미스가 늘어납니다.
5) 캐시 저장/복원이 “의미 있는 단계”와 맞물리지 않는다
예를 들어 의존성 설치 전에 캐시를 복원하지 않으면 설치가 매번 풀로 돌고, 설치 후에 캐시를 저장하지 않으면 다음 런에서 재사용이 안 됩니다. 순서가 중요합니다.
캐시 설계의 핵심: 키를 2단으로 나누기
캐시 키는 보통 아래 2계층으로 설계하면 운영이 편합니다.
- 정밀 키(Exact key): lockfile 해시까지 포함 → 완전 일치 시 가장 빠름
- 폴백 키(Restore keys): lockfile 해시는 제외 → 비슷한 캐시라도 가져와서 설치 시간을 단축
Node.js(npm) 예시: restore-keys로 캐시 재사용률 올리기
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm' # setup-node 내장 캐시(기본)도 활용 가능
# 내장 캐시 외에, 더 세밀하게 제어하고 싶다면 actions/cache를 직접 사용
- name: Cache npm
uses: actions/cache@v4
with:
path: |
~/.npm
key: npm-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node${{ matrix.node }}-
npm-${{ runner.os }}-
- run: npm ci
- run: npm test
포인트:
github.sha같은 변동 값 제거runner.os,node버전으로 오염 방지- lockfile 해시가 달라져도
restore-keys로 “가까운 캐시”를 가져와npm ci가 다운로드를 덜 하게 만듦
Python(pip/poetry) 캐시: 다운로드 캐시와 가상환경을 분리하라
Python은 특히 가상환경(venv) 자체를 캐시하면 깨지기 쉽습니다(절대 경로, Python 마이너 버전, 플랫폼 태그 등). 대신 다운로드 캐시를 캐시하는 편이 안정적입니다.
pip 예시: wheel 다운로드 캐시만 캐시
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip
uses: actions/cache@v4
with:
path: |
~/.cache/pip
key: pip-${{ runner.os }}-py311-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
pip-${{ runner.os }}-py311-
pip-${{ runner.os }}-
- run: pip install -r requirements.txt
Poetry 예시: .venv보다 poetry/pip 캐시 중심
- name: Install poetry
run: pipx install poetry
- name: Cache poetry/pip
uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.cache/pypoetry
key: poetry-${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
poetry-${{ runner.os }}-py311-
poetry-${{ runner.os }}-
- run: poetry install --no-interaction --no-root
Gradle/Maven 캐시: “의존성 캐시”와 “빌드 캐시”를 구분
Java 계열은 캐시 대상이 큽니다. 무턱대고 ~/.gradle 전체를 캐시하면 효과는 있지만 업로드/다운로드가 커질 수 있어, 프로젝트 규모에 따라 조정이 필요합니다.
Gradle 예시
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Cache gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-jdk21-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-jdk21-
gradle-${{ runner.os }}-
- run: ./gradlew test --no-daemon
팁:
- 키에
build.gradle/settings.gradle/wrapper 설정을 포함해 “의존성 그래프가 바뀔 때”만 갱신 --no-daemon은 CI에서 흔히 권장되지만, 캐시/환경에 따라 전략은 달라질 수 있습니다
캐시가 느려지는 역설: “캐시가 너무 커서” 더 느릴 수 있다
캐시는 공짜가 아닙니다.
- 복원: 다운로드 + 압축 해제
- 저장: 압축 + 업로드
캐시가 수 GB로 커지면, 네트워크/압축 비용 때문에 클린 빌드보다 느린 캐시 빌드가 나올 수 있습니다.
체크리스트: 캐시가 오히려 느린지 확인
- Workflow 로그에서
Cache restored from key:가 뜨는지 확인 Cache Size:(액션 로그/요약) 또는 업/다운로드 시간을 확인- 캐시 경로에 빌드 산출물(
dist,build,target)이 섞여 있는지 확인 - 모노레포라면 패키지별로 캐시를 쪼갤지 검토
모노레포에서 캐시 키를 “패키지 단위”로 분리하기
모노레포에서 **/package-lock.json을 해시하면, 한 패키지의 락파일 변경이 전체 캐시를 무효화할 수 있습니다.
패키지별 캐시 예시(워크스페이스 경로 포함)
- name: Cache web npm
uses: actions/cache@v4
with:
path: |
~/.npm
key: npm-${{ runner.os }}-node20-web-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-web-
npm-${{ runner.os }}-node20-
- run: npm ci
working-directory: web
핵심은 변경 범위를 키에 반영하는 것입니다.
“캐시 무효화”를 의도적으로 트리거하는 방법(긴급 대응)
가끔은 캐시가 꼬여서(잘못된 아카이브, 깨진 바이너리, 잘못된 레이어) 계속 실패하거나 성능이 급락합니다. 이때는 의도적으로 캐시를 갈아엎어야 합니다.
1) 키에 수동 버전 문자열을 추가
env:
CACHE_BUSTER: v3
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ env.CACHE_BUSTER }}-${{ hashFiles('**/requirements.txt') }}
CACHE_BUSTER만 올리면 전체 캐시를 새로 만들 수 있어 운영 대응이 쉽습니다.
2) 브랜치별 캐시 분리(필요할 때만)
기본적으로는 재사용성을 위해 브랜치 간 공유가 유리하지만, 릴리즈 브랜치/실험 브랜치가 캐시를 오염시키는 경우에는 브랜치 스코프를 포함할 수 있습니다.
key: npm-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/package-lock.json') }}
단, 브랜치가 많으면 캐시가 분산되어 적중률이 떨어질 수 있습니다.
캐시 히트율을 올리는 운영 팁
1) “의존성 설치”와 “빌드”를 분리해서 측정
빌드가 느린 원인이 캐시인지 확인하려면, 최소한 아래 2구간의 시간을 분리해서 봐야 합니다.
- 의존성 설치 시간
- 빌드/테스트 시간
이를테면 데이터베이스 데드락을 재현/탐지/해결할 때도 구간을 분리해 병목을 찾듯이(관측 없이는 최적화가 불가능), CI도 단계별로 시간을 분해해야 합니다. 참고로 병목을 관측/재현하는 접근은 PostgreSQL 데드락 40P01 재현·탐지·해결에서 설명한 방식과 결이 같습니다.
2) 캐시 대상은 “재다운로드 비용이 큰 것”부터
- npm/pip/gradle의 다운로드 캐시
- 빌드 툴의 wrapper
- 컴파일 캐시(Gradle build cache 등)는 프로젝트 특성에 따라
반대로 아래는 대개 비추천입니다.
- 결과물이 환경에 민감한
node_modules전체(가능하면 다운로드 캐시 위주) - 테스트 산출물/로그
3) 네트워크 이슈가 캐시 실패로 보일 때도 있다
캐시가 계속 미스처럼 보이는데 실제로는 네트워크/egress 문제가 원인일 수 있습니다(다운로드 실패 → 재시도 → 타임아웃 → 캐시 저장 실패). 쿠버네티스 환경에서 egress가 막히면 증상이 비슷하게 나타나므로, 인프라 CI 러너를 직접 운영 중이라면 EKS에서 Pod는 정상인데 egress만 막힐 때 점검 같은 체크리스트 관점으로 네트워크 경로를 점검해보는 것도 도움이 됩니다.
자주 겪는 실패 패턴과 해결 요약
- 패턴 A: 커밋마다 캐시가 새로 생김 → 키에서
sha/run_id제거, lockfile 기반으로 변경 - 패턴 B: lockfile이 바뀌면 무조건 느려짐 →
restore-keys추가로 부분 일치 캐시 사용 - 패턴 C: 캐시를 쓰는데도 느림 → 캐시 경로 축소(다운로드 캐시 중심), 캐시 크기 측정
- 패턴 D: 모노레포 전체가 함께 무효화 → 패키지 단위로 키/경로 분리
- 패턴 E: 캐시가 가끔 깨짐 →
CACHE_BUSTER로 강제 무효화 루틴 마련
결론: 캐시 최적화는 “키 설계”가 80%다
GitHub Actions에서 빌드가 느려질 때 캐시를 켜는 것만으로는 해결되지 않습니다. 키가 너무 자주 바뀌면 캐시는 존재해도 매번 새로 만들게 되고, 반대로 캐시가 너무 크면 복원/저장 비용이 빌드 시간을 잡아먹습니다.
가장 효과가 큰 순서로 정리하면 다음과 같습니다.
- 키에서 변동 값 제거(커밋 SHA 금지)
- lockfile 해시 +
restore-keys로 2단 키 구성 - 캐시 경로를 다운로드 캐시 중심으로 축소
- matrix/모노레포는 스코프를 명확히 분리
CACHE_BUSTER로 운영상 강제 무효화 수단 확보
이 5가지만 지켜도 “캐시가 있는데도 느린 CI”에서 빠져나오는 경우가 대부분입니다.