- Published on
GitHub Actions 캐시 미스 원인 7가지와 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 CI인 GitHub Actions는 매 실행마다 깨끗한 러너에서 시작합니다. 그래서 actions/cache를 제대로 쓰면 의존성 설치 시간을 수십 초~수분 단축할 수 있지만, 반대로 캐시가 계속 MISS 나면 “왜 안 먹지?”만 남습니다.
이 글은 캐시 MISS가 발생하는 패턴을 7가지로 분류하고, 각 케이스별로 어떤 로그를 보고, 어떻게 key/경로/전략을 고쳐야 하는지를 워크플로 코드로 설명합니다.
> 참고: 인증/권한 이슈로 워크플로 자체가 꼬일 때는 OIDC/권한 점검도 함께 하세요. 예: GitHub Actions OIDC 403·권한거부 원인 7가지
GitHub Actions 캐시 동작 요약 (MISS를 이해하는 전제)
actions/cache@v4는 대략 아래 규칙으로 움직입니다.
key가 완전히 동일하면 HIT- 동일 key가 없으면
restore-keys의 prefix 매칭으로 가장 가까운 캐시를 복원(부분 HIT처럼 사용) - 캐시는 job 성공 시점에 저장(일반적으로 step이 성공적으로 끝나야 함)
- 캐시 스코프는 보통 OS/아키텍처/브랜치/키 등의 조합 영향을 받음(키 설계가 사실상 스코프를 결정)
기본 예시 (Node.js)
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
이제부터는 “왜 위처럼 했는데도 MISS가 나나?”를 7가지로 쪼개서 해결합니다.
1) key가 너무 자주 바뀐다 (hashFiles 범위 과다/불안정)
가장 흔한 원인입니다. hashFiles('**/package-lock.json')처럼 범위를 넓히거나, lockfile이 아닌 파일(예: package.json, pom.xml만 해시)로 키를 만들면 사소한 변경에도 key가 매번 달라져 캐시가 사실상 무력화됩니다.
증상
- 로그에 매번 새로운 key가 출력
- PR마다/커밋마다 캐시가 새로 생성
해결
- **진짜 의존성 그래프를 대표하는 파일(락파일)**만 해시
- 모노레포는 패키지별로 분리 key 또는 “워크스페이스 단위”로 안정화
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
# (Yarn Berry라면 yarn.lock, pnpm이라면 pnpm-lock.yaml)
모노레포 예:
key: ${{ runner.os }}-web-${{ hashFiles('apps/web/package-lock.json') }}
2) restore-keys가 없거나 prefix 설계가 잘못됐다
완전 일치 key가 없으면 restore-keys로 “가장 가까운 캐시”를 당겨와야 하는데, restore-keys가 없거나 너무 구체적이면 MISS가 그대로 납니다.
증상
- key가 조금만 달라도 항상 MISS
- 의존성 변경이 잦은 브랜치에서 체감 성능이 매우 나쁨
해결
- restore-keys를 prefix 기반으로 두고, 단계적으로 넓혀 복원
- uses: actions/cache@v4
with:
path: |
~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
${{ runner.os }}-
이렇게 하면 lockfile이 바뀌어도 같은 OS의 이전 npm 캐시를 가져와 다운로드를 줄일 수 있습니다.
3) path를 잘못 잡았다 (실제로 캐시할 디렉터리가 없다)
캐시는 “지정한 경로”를 압축해 저장합니다. 그런데 그 경로가 실제로 존재하지 않거나(설치 이전) 다른 폴더에 생성되면, 저장할 게 없어 캐시가 의미가 없어집니다.
흔한 실수
- Node:
node_modules를 캐시하면서npm ci를 쓰는 경우(재현성은 좋지만 node_modules 캐시는 오히려 충돌/비효율) - Java/Gradle:
~/.gradle/caches대신 프로젝트 내부.gradle만 캐시 - Python/pip:
~/.cache/pip대신 venv 자체를 캐시(플랫폼/파이썬 버전 영향 큼)
해결: “다운로드 캐시”를 우선
Node는 ~/.npm, pnpm은 ~/.pnpm-store, Gradle은 ~/.gradle/caches처럼 패키지 다운로드 캐시가 안정적입니다.
# npm 예시
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- run: npm ci
Gradle 예시:
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- run: ./gradlew test
4) 캐시 저장 시점에 job이 실패한다 (저장 자체가 안 됨)
actions/cache는 복원은 job 초반에 하지만, **저장은 job 끝(성공 시)**에 수행됩니다. 테스트 실패/빌드 실패가 잦으면 “항상 복원 MISS”처럼 보일 수 있습니다. (첫 성공 실행이 나오기 전까지는 저장된 캐시가 없으니까요.)
증상
- 캐시 복원 step은 항상 “Cache not found”
- job이 중간에 실패해서 끝까지 못 감
해결
- 캐시 생성이 필요한 초기 구간(의존성 설치)은 가능한 한 빨리 성공시키기
- 경우에 따라 테스트 실패와 무관하게 캐시를 저장하고 싶다면(권장되진 않음) workflow를 분리하거나, 최소한 “의존성 준비 job”을 분리해 안정적으로 성공시키기
예: 의존성 준비를 별도 job으로 분리(아티팩트/캐시 전략 혼합)
jobs:
deps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- run: npm ci
test:
needs: deps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- run: npm test
5) OS/아키텍처/런타임 버전이 달라서 캐시가 분리된다
캐시는 바이너리/네이티브 모듈 영향을 크게 받습니다. runner.os만 키에 넣었더라도, 실제로는 Node/Python/Java 버전이 달라지면 캐시 재사용성이 떨어집니다. 반대로 버전을 키에 넣지 않으면 “복원은 되는데 빌드가 깨지는” 문제가 생길 수 있습니다.
증상
- ubuntu-latest가 업데이트된 후 갑자기 MISS가 늘어남
- Node 18 → 20 변경 후 캐시 충돌/재빌드
해결
- key에 런타임 버전을 포함해 의도적으로 캐시를 분리
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node20-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node20-npm-
${{ runner.os }}-npm-
Python 예시:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-py312-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-py312-pip-
6) 브랜치/PR 스코프 차이로 “다른 캐시처럼” 보인다
팀에서 자주 겪는 혼란: main에서 캐시를 잘 만들어놨는데 PR에서는 계속 MISS가 난다.
이건 실제로는 “캐시 공유 범위”와 “키 설계”의 결과입니다. 보안/격리 관점에서 PR(특히 fork)과 기본 브랜치의 캐시는 동일하게 취급되지 않거나, 접근이 제한되는 경우가 있습니다. 또한 키에 ${{ github.ref }} 같은 값을 넣으면 브랜치마다 캐시가 갈라집니다.
증상
- main에서는 HIT, feature 브랜치에서는 MISS
- PR from fork에서만 MISS
해결
- 브랜치명을 key에 넣는 것을 신중히 결정(정말 필요할 때만)
- PR에서도 재사용하고 싶다면
restore-keys를 브랜치 독립 prefix로 제공
key: ${{ runner.os }}-npm-${{ github.ref_name }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-${{ github.ref_name }}-
${{ runner.os }}-npm-
브랜치별 격리가 필요 없다면(대부분 다운로드 캐시는 격리 불필요):
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
7) 캐시 대상이 “동시에 갱신”되며 레이스가 난다 (matrix/병렬 실행)
matrix로 Node 18/20, OS별(ubuntu/windows/macos)로 동시에 돌리면 캐시 키가 겹치거나(버전/OS를 키에 안 넣은 경우), 같은 키를 여러 job이 동시에 저장하려고 하면서 비효율이 생깁니다. 어떤 job은 저장에 실패하거나(이미 저장됨), 어떤 job은 매번 MISS처럼 느껴질 수 있습니다.
증상
- 같은 워크플로 실행에서 job마다 캐시 로그가 제각각
- “이미 존재하는 캐시 키” 류의 메시지(또는 저장이 스킵)
해결
- key에 matrix 변수를 포함해 충돌 방지
- 공용으로 쓰고 싶다면 restore-keys로 공유하고, 저장은 대표 job만 하도록 설계
strategy:
matrix:
node: [18, 20]
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node${{ matrix.node }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node${{ matrix.node }}-npm-
${{ runner.os }}-npm-
대표 job만 저장하고 나머지는 복원만 하려면(패턴):
- name: Cache (save only on node 20)
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
if: ${{ matrix.node == 20 }}
MISS를 빠르게 좁히는 체크리스트
아래 6가지만 로그로 확인해도 원인의 80%는 잡힙니다.
- 실제 key 문자열이 매번 바뀌는가?
restore-keys가 존재하며 prefix가 과도하게 구체적이지 않은가?path가 의존성 설치 이후 실제로 생성되는 경로인가?- job이 끝까지 성공해 저장이 수행되는가?
- OS/런타임 버전 변경이 있었는가? (특히
ubuntu-latest) - PR/fork/브랜치 스코프 차이로 캐시가 분리되는가?
실전 권장 템플릿 (Node/Gradle 2종)
Node (npm) 권장 템플릿
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: Cache npm download cache
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node20-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node20-npm-
${{ runner.os }}-npm-
- run: npm ci
- run: npm test
Gradle 권장 템플릿
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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: ${{ runner.os }}-java21-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-java21-gradle-
${{ runner.os }}-gradle-
- run: ./gradlew test --no-daemon
마무리
GitHub Actions 캐시 MISS는 대부분 “키가 너무 자주 바뀜”, “restore-keys 부재”, “path 오지정”에서 끝납니다. 그 다음이 런타임/OS 변화, 브랜치 스코프, 병렬 실행 레이스입니다.
캐시를 잘 쓰는 핵심은 (1) 안정적인 key, (2) 복원 폭을 넓히는 restore-keys, (3) 다운로드 캐시 중심의 path 선택입니다.
워크플로 인증/권한 쪽에서 예외가 발생해 캐시 저장 단계까지 못 가는 경우도 있으니, 배포 파이프라인에서 OIDC를 쓴다면 함께 점검해보세요: GitHub Actions OIDC로 AWS 키 없이 배포하기