- Published on
GitHub Actions 캐시가 안 먹을 때 키·경로·권한
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 PR/브랜치에서 actions/cache를 써도 매번 Cache not found가 뜨거나, 분명 Cache restored인데 빌드 시간이 줄지 않는 경우가 있습니다. GitHub Actions 캐시는 키(key), 경로(path), 권한/이벤트 제약(permissions & security model) 세 축에서 문제가 생기며, 대부분은 “캐시가 없어서”가 아니라 “찾을 수 없게 설계되었거나 저장이 막힌 것”입니다.
이 글에서는 캐시가 안 먹는 상황을 로그 기반으로 분류하고, 각 분류별로 바로 적용 가능한 YAML 패턴과 체크리스트를 제공합니다. (문제 원인 추적 관점은 systemd 서비스가 반복 재시작될 때 원인 추적법처럼, 증상→관측→가설→검증 순서로 접근하는 게 가장 빠릅니다.)
1) 먼저: 캐시가 “복원/저장” 중 어디서 깨지는지 확인
actions/cache는 크게 두 단계입니다.
- restore(복원): 키/restore-keys로 기존 캐시를 찾음
- save(저장): job 성공 시점에 path를 업로드하여 key로 저장
따라서 로그에서 아래를 구분해야 합니다.
- restore 단계:
Cache not found for input keys:→ 키/스코프/경로 문제 가능성 - restore 단계:
Cache restored from key:→ 복원은 됨, 그런데 빌드가 느리다면 경로가 빗나갔거나 tool이 캐시를 무시 - save 단계:
Cache saved with key:가 안 보임 → job 실패/조건문/권한/이벤트 제약으로 저장이 안 됨
디버그 로그 켜기
ACTIONS_STEP_DEBUG를 켜면 cache 액션이 어떤 키를 만들었는지 더 명확히 보입니다.
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
env:
ACTIONS_STEP_DEBUG: true
steps:
- uses: actions/checkout@v4
- name: Restore cache
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
2) 키(key) 설계가 캐시 히트를 망치는 대표 패턴
캐시는 “정확히 같은 키”일 때만 hit합니다. restore-keys는 접두(prefix) 매칭으로 fallback을 허용합니다.
2-1) 키에 변동성이 큰 값(커밋 SHA, run_id)을 넣어 매번 미스
다음은 거의 100% 미스가 납니다.
key: ${{ runner.os }}-${{ github.sha }}
커밋이 바뀌면 키가 바뀌므로 캐시를 재사용할 수 없습니다. 보통은 의존성 잠금 파일의 해시를 키로 씁니다.
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- lock 파일이 같으면 hit
- lock 파일이 바뀌면 miss하지만,
restore-keys로 이전 캐시를 부분 재사용(“가까운 캐시”) 가능
2-2) 해시 대상 파일이 워크스페이스에 없어서 빈 해시가 되는 경우
hashFiles('package-lock.json')는 기본적으로 workspace 기준입니다. 체크아웃 전에 실행하면 해시가 빈 문자열이 되거나 의도치 않은 값이 됩니다.
해결: 반드시 actions/checkout 이후에 cache restore를 하세요.
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
2-3) 모노레포에서 해시 범위를 잘못 잡아 캐시가 과도하게 분리
예를 들어 **/package-lock.json이 여러 개면, 서비스 A 변경이 서비스 B 캐시까지 갈라놓을 수 있습니다.
대안
- 서비스별로 job을 분리하고 해당 디렉터리의 lock만 해시
- 또는
key에 서비스명을 넣되, 해시 범위를 좁힘
key: ${{ runner.os }}-web-npm-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-web-npm-
2-4) restore-keys를 너무 구체적으로 써서 fallback이 안 됨
restore-keys는 “접두”로만 동작합니다. 아래는 접두가 아니라서 기대대로 매칭되지 않을 수 있습니다.
restore-keys: |
ubuntu-npm-${{ hashFiles('package-lock.json') }}
권장: 접두만 남겨 폭을 넓히고, key에서만 정확도를 확보합니다.
restore-keys: |
ubuntu-npm-
3) 경로(path) 문제: 복원은 됐는데 효과가 없는 이유
캐시가 “먹는지”는 hit 여부가 아니라 실제 빌드가 그 디렉터리를 사용하느냐로 결정됩니다.
3-1) 캐시 경로가 도구가 실제로 쓰는 위치가 아님
대표적으로 Node는 패키지 매니저/설정에 따라 캐시 위치가 다릅니다.
- npm 캐시: 보통
~/.npm - yarn 캐시: Yarn v1은
~/.cache/yarn, Berry는 프로젝트 내.yarn/cache - pnpm:
~/.pnpm-store또는 설정에 따라 다름
예: pnpm store path를 명시하고 그 경로를 캐시하는 패턴
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store dir
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- run: pnpm install --frozen-lockfile
3-2) 상대경로/절대경로 혼동, 또는 $HOME가 기대와 다름
path는 여러 개를 줄 수 있지만, 실제로 runner에서 존재하는 경로여야 합니다. 특히 컨테이너 job(container:)에서는 홈 디렉터리가 달라질 수 있습니다.
점검용 스텝을 넣어 실제 경로를 확인하세요.
- name: Inspect paths
run: |
echo "HOME=$HOME"
pwd
ls -al ~
ls -al .
3-3) 캐시 대상에 빌드 산출물(dist)을 넣는 실수
캐시는 재생성 가능한 의존성/컴파일 캐시에 적합합니다.
- 좋은 예:
~/.cache/pip,~/.m2/repository, Gradle cache, pnpm store - 나쁜 예:
dist/,build/(빌드/환경에 따라 오염되기 쉬움)
산출물을 재사용하려면 캐시보다 artifact가 더 적절한 경우가 많습니다.
4) 권한·이벤트 제약: PR에서는 저장이 막히는 케이스
캐시가 “안 먹는다”는 말의 상당수는 사실 저장이 안 되는 문제입니다. 특히 pull_request와 포크 PR에서 자주 발생합니다.
4-1) 포크 PR에서는 캐시 저장이 제한될 수 있음
보안 모델상 포크 PR은 토큰 권한이 제한되며, 캐시 저장이 실패하거나 아예 수행되지 않을 수 있습니다. 이때는 로그에 Cache saved...가 안 보이거나, 권한 관련 메시지가 나타납니다.
대응 전략
- 포크 PR에서는 restore만 시도하고 save는 스킵
- 또는
pull_request_target를 신중히 사용(체크아웃/실행 코드에 따라 보안 리스크 큼)
예: 포크 PR에서는 저장 스킵
- name: Restore cache
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
- name: Save cache (skip on fork PR)
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: actions/cache/save@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
> actions/cache@v4는 내부적으로 restore/save를 처리하지만, 위처럼 분리 액션(restore, save)을 쓰면 조건 제어가 더 명확해집니다.
4-2) permissions 설정이 너무 제한적임
조직/레포 정책으로 GITHUB_TOKEN 권한을 최소화해둔 경우, 캐시 접근에 필요한 권한이 부족할 수 있습니다.
워크플로 상단에 다음처럼 명시해 문제를 분리하세요.
permissions:
contents: read
actions: read
캐시 저장/복원은 내부적으로 Actions 관련 권한과 연동되며, 환경/정책에 따라 더 필요할 수 있습니다. 최소 권한 원칙을 유지하되, 캐시가 필요한 워크플로에 한해 권한을 올리는 식으로 조정합니다.
권한 문제는 네트워크/인증과 얽혀 “증상이 비슷한데 원인이 다른” 케이스가 많습니다. 쿠버네티스에서 exec/logs가 안 될 때도 RBAC/포트/웹소켓 등 원인이 갈라지듯(EKS에서 kubectl exec·logs가 안 될 때 진단법), Actions도 이벤트/토큰/정책을 분리해서 봐야 합니다.
5) 자주 놓치는 운영 포인트: 캐시 스코프와 무효화
5-1) 브랜치/태그에 따른 스코프 차이
캐시는 기본적으로 같은 레포 내에서 공유되지만, 키 설계와 이벤트 흐름에 따라 “사실상 브랜치별 캐시”처럼 동작할 수 있습니다.
key에github.ref_name을 넣으면 브랜치별로 분리됨- 반대로 브랜치 간 공유를 원하면 ref를 키에서 빼고
restore-keys로 완충
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
5-2) 캐시가 오염되었을 때 강제 무효화(bust)
도구 버전 업, ABI 변경, 네이티브 모듈 문제 등으로 “캐시가 있으면 오히려 실패”하는 경우가 있습니다. 이때는 키에 명시적 버전을 추가해 무효화합니다.
key: ${{ runner.os }}-npm-v2-${{ hashFiles('package-lock.json') }}
이 방식은 장애 대응에서 특히 유용합니다. (API 스펙 변화로 파싱이 깨질 때 원인을 좁히는 과정이 중요하듯, 캐시도 “언제부터 깨졌는지”를 버전으로 절단하면 추적이 빨라집니다: OpenAI Responses API tool_calls 파싱 실패 해결법)
6) 실전 체크리스트: 5분 안에 원인 좁히기
6-1) 키 점검
-
github.sha,run_id같은 고변동 값이 들어갔나? -
hashFiles()대상이 정확한가? (체크아웃 이후 실행?) - 모노레포에서 해시 범위가 과도하게 넓지 않나?
-
restore-keys가 접두로 충분히 넓게 열려 있나?
6-2) 경로 점검
- 캐시 경로가 실제 도구 캐시 경로와 일치하나?
- 컨테이너 job에서
$HOME가 달라지지 않았나? - restore는 됐는데 설치 커맨드가 캐시를 쓰도록 설정되어 있나?
6-3) 저장/권한 점검
- job이 실패해서 save가 스킵된 건 아닌가?
- 포크 PR에서 save가 제한되는 상황 아닌가?
-
permissions가 과하게 잠겨 있지 않나?
7) 추천 템플릿: 언어별 “안전한 캐시” 구성 예시
7-1) Python(pip)
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- run: pip install -r requirements.txt
7-2) Java(Gradle)
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- 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
결론: 캐시는 “키·경로·저장 조건” 3가지만 맞추면 된다
GitHub Actions 캐시가 안 먹을 때 가장 빠른 해결 루트는 다음 순서입니다.
- restore vs save 중 어디서 실패하는지 로그로 분리
- 키는 lock 파일 해시 + 접두 restore-keys로 안정화
- 경로는 “내가 캐시하고 싶은 곳”이 아니라 도구가 실제로 쓰는 캐시 디렉터리로 지정
- PR/포크/권한 정책 때문에 저장이 막히는 케이스를 조건문으로 분리
이 네 단계만 체계적으로 밟으면, Cache not found를 “그냥 운이 나쁜” 문제가 아니라 재현 가능한 구성 문제로 바꿀 수 있고, CI 시간을 안정적으로 줄일 수 있습니다.