- Published on
GitHub Actions 캐시가 안 먹을 때 디버깅 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
GitHub Actions에서 빌드 시간이 갑자기 늘어나면 가장 먼저 의심하는 게 캐시(cache)입니다. actions/cache를 붙여놨는데도 로그에 Cache not found가 뜨거나, 분명 저장은 되는데 다음 실행에서 복원이 안 되거나, 로컬에서는 잘 되던 패키지 매니저 캐시가 CI에서는 매번 새로 내려받는 경우가 흔합니다.
캐시는 “잘 되면 빠르고, 안 되면 조용히 느려지는” 성격이라 원인 파악이 어렵습니다. 이 글은 캐시가 안 먹을 때(= hit가 안 날 때) 원인을 체계적으로 좁히는 디버깅 체크리스트입니다. 네트워크/재시도 관점은 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉처럼 “현상이 간헐적일 때”의 접근법과도 유사하니, 간헐 실패가 있다면 함께 참고하면 좋습니다.
1) 먼저 로그에서 “무엇이 안 되는지” 분리하기
캐시는 크게 두 단계입니다.
- restore(복원): 이전에 저장된 캐시를 찾아 워크스페이스에 풀어주는 단계
- save(저장): 잡(job) 끝에서 현재 상태를 업로드하는 단계
actions/cache는 로그에 힌트를 많이 남깁니다.
Cache not found for input keys: ...→ restore 단계에서 매칭 실패Cache restored from key: ...→ restore 성공Cache saved with key: ...→ save 성공Cache already exists. Skipping save.→ 동일 key로 이미 저장돼서 save 생략
즉, “저장이 안 되는지 / 복원이 안 되는지 / 둘 다인지”를 먼저 나누면 디버깅 시간이 줄어듭니다.
2) 체크리스트: key 설계가 너무 자주 바뀌지 않는가?
캐시 hit의 80%는 key에서 결정됩니다. 특히 다음 실수들이 잦습니다.
2.1 lockfile 해시가 매번 달라지는가?
보통 hashFiles('**/package-lock.json') 같은 패턴을 쓰는데,
- lockfile이 PR마다 바뀌는 구조(예: renovate가 자주 업데이트)
- 모노레포에서 여러 lockfile이 있고
**/로 전부 해시됨 - 빌드 과정에서 lockfile이 생성/수정됨(절대 하면 안 됨)
이면 캐시가 “항상 미스”가 됩니다.
권장 패턴: lockfile은 정확히 지정하고, 필요하면 restore-keys로 완화합니다.
- name: Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
2.2 key에 너무 많은 변수를 섞고 있지 않은가?
다음 값들은 의도치 않게 캐시를 쪼갭니다.
${{ github.sha }}(커밋마다 바뀜 → 사실상 캐시 무력화)${{ github.run_id }}/${{ github.run_number }}${{ github.ref }}를 그대로(브랜치/태그/PR ref가 다름)
원칙: 캐시를 나누고 싶은 축만 key에 넣습니다.
- OS/아키텍처(필요)
- 런타임 버전(예: Node 20 vs 22)
- lockfile hash(의존성 변경 시만 분기)
3) restore-keys를 제대로 쓰고 있는가?
restore-keys는 “정확히 일치하는 key가 없을 때, prefix 매칭으로 가장 가까운 캐시를 가져오는” 장치입니다.
- lockfile이 조금 바뀌어도 큰 덩어리(예: npm tarball cache, Gradle wrapper 등)는 재사용 가능
- 단, 너무 넓게 열면 오래된 캐시를 가져와서 오히려 성능/정합성 문제가 생길 수 있음
예: pip 캐시
- uses: actions/cache@v4
with:
path: |
~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-
4) path가 “실제로 존재하는 위치”인가?
캐시가 안 먹는 가장 현실적인 원인 중 하나가 경로 오타/환경 차이입니다.
4.1 홈 디렉터리/워크스페이스 혼동
~/.npm,~/.cache/pip같은 홈 기반 경로는 보통 OKnode_modules처럼 워크스페이스 상대 경로는 체크아웃 위치에 의존
defaults.run.working-directory를 바꿨거나, 모노레포에서 서브디렉터리로 이동했다면 path가 달라집니다.
4.2 캐시 대상이 “잡 끝까지 남아있지” 않은가?
actions/cache는 restore는 step 시작에, save는 job 종료 시점에 수행됩니다.
- 중간 step에서
rm -rf로 지워버리면 저장할 게 없음 - 빌드가 실패하면 save가 실행되지 않을 수 있음(설정/상황에 따라)
디버깅용으로 캐시 경로를 출력해보면 빠릅니다.
- name: Inspect cache dir
run: |
ls -al ~/.npm || true
du -sh ~/.npm || true
5) OS/아키텍처/런타임 버전이 바뀌었는가?
캐시는 바이너리/네이티브 모듈 때문에 “환경 종속적”일 수 있습니다.
runner.os가 바뀜:ubuntu-latest→ 어느 순간 24.04로 바뀌어 캐시 미스- 아키텍처가 바뀜: x64 ↔ arm64
- Node/Python/Java 버전이 바뀜
따라서 key에 최소한 다음은 포함하는 편이 안전합니다.
${{ runner.os }}- 런타임 버전(예:
${{ matrix.node }})
strategy:
matrix:
node: [20, 22]
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('package-lock.json') }}
6) PR 이벤트/포크에서 캐시가 제한되는 케이스
캐시가 “특정 상황에서만” 안 먹는다면 이벤트/권한 이슈를 의심하세요.
pull_request(특히 fork PR)에서는 보안상 토큰 권한이 제한될 수 있음- 조직/리포 설정에 따라 캐시 접근 정책이 다를 수 있음
대응 방향:
- fork PR에서는 캐시 저장이 안 되는 걸 전제로 시간을 설계(예: 더 작은 작업 단위)
- 필요 시
pull_request_target를 검토하되, 보안 리스크(신뢰할 수 없는 코드 실행)를 이해하고 사용
7) 동일 key 충돌: “이미 존재”해서 저장이 스킵되는가?
로그에 다음이 나오면 save가 생략됩니다.
Cache already exists. Skipping save.
이건 정상일 수도 있지만, 다음 상황이면 문제입니다.
- key가 너무 넓어서 서로 다른 상태를 같은 key로 저장하려 함
- 모노레포에서 서로 다른 패키지가 같은 key를 공유
해결:
- 패키지/디렉터리 이름을 key에 포함
key: npm-${{ runner.os }}-web-${{ hashFiles('apps/web/package-lock.json') }}
8) 캐시 크기/파일 수가 비정상적으로 큰가?
캐시가 너무 크면 저장/복원 시간이 오히려 손해가 되고, 업로드가 실패하거나 불안정해질 수 있습니다.
node_modules전체 캐시는 대체로 비추(용량/플랫폼 종속/깨지기 쉬움)- 대신 패키지 매니저의 다운로드 캐시를 저장(
~/.npm,~/.cache/pip,~/.gradle/caches일부)
권장:
- npm:
~/.npm - yarn berry:
.yarn/cache(프로젝트에 포함되는 구조 고려) - pnpm:
~/.pnpm-store - pip:
~/.cache/pip - gradle:
~/.gradle/caches,~/.gradle/wrapper
9) 캐시가 “복원은 되는데 빌드가 여전히 느린” 경우
이 경우는 캐시 hit가 아니라, 캐시가 빌드 병목을 덜어주지 못하는 구조일 수 있습니다.
예:
- npm 캐시를 복원해도
npm ci는 여전히 많은 I/O를 수행 - 테스트/번들링이 느린데 의존성 다운로드만 빨라짐
이때는 “정말 줄이고 싶은 시간”을 먼저 프로파일링하고, 캐시 대상을 바꾸는 게 낫습니다.
- 빌드 산출물 캐시: Turborepo, Nx, Gradle build cache 등
- Docker layer cache(빌드가 컨테이너 중심이라면)
디버깅 체크리스트 글을 좋아한다면, 같은 방식의 문제 분해 접근으로 Assistants API v2 run이 queued나 in_progress에 멈출 때 실전 디버깅 체크리스트도 참고할 만합니다.
10) 실전: “캐시 상태를 출력”하는 최소 디버깅 템플릿
아래는 Node 프로젝트 기준으로, 캐시 키/히트 여부/디렉터리 상태를 로그로 남기는 템플릿입니다.
name: ci
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Compute cache key inputs
run: |
echo "ref=$GITHUB_REF"
echo "sha=$GITHUB_SHA"
node -v
npm -v
ls -al
test -f package-lock.json && echo "lockfile exists" || echo "lockfile missing"
- name: Cache npm
id: cache-npm
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node22-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node22-
- name: Cache result
run: |
echo "cache-hit=${{ steps.cache-npm.outputs.cache-hit }}"
ls -al ~/.npm || true
du -sh ~/.npm || true
- run: npm ci
- run: npm test
여기서 cache-hit=false인데도 ~/.npm 용량이 큰 경우는 “restore는 됐는데 출력이 false” 같은 예외가 아니라, 대개 다른 키로 복원되었거나(restore-keys 매칭) 혹은 경로가 실제로 기대와 다른 경우입니다. 로그에 Cache restored from key: 라인을 반드시 같이 확인하세요.
11) 자주 놓치는 함정 모음(빠른 체크)
- lockfile이 체크아웃되지 않음(서브모듈/부분 체크아웃/생성 단계 오류)
- 모노레포에서
hashFiles('**/package-lock.json')로 의도치 않게 전부 해시 working-directory변경으로 path가 엇갈림- OS 업데이트로
ubuntu-latest가 바뀌어 캐시가 갈라짐 - fork PR에서 캐시 저장/복원 제한
- key에
github.sha를 넣어 매번 미스 node_modules캐시로 용량 폭증 및 플랫폼 종속 문제
결론
GitHub Actions 캐시 디버깅은 결국 (1) key가 안정적인가, (2) restore-keys로 적절히 완화했는가, (3) path가 실제 환경에서 맞는가, (4) 이벤트/권한/환경(OS·버전) 차이로 캐시가 분리되지 않았는가를 순서대로 확인하는 게임입니다.
위 체크리스트대로 로그를 읽고, key 입력(해시 대상)과 path 존재 여부를 출력해보면 대부분의 “캐시가 안 먹는” 문제는 30분 안에 원인이 드러납니다. 캐시가 간헐적으로 실패하거나 외부 요인(네트워크/레이트리밋 등)과 섞여 보인다면 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉에서 다룬 것처럼 실패를 전제로 한 재시도/관측(로그) 전략도 함께 적용해보세요.