Published on

GitHub Actions 캐시가 안 먹을 때 - key·restore-keys·권한

Authors

서론

GitHub Actions에서 actions/cache를 붙였는데도 매번 Cache not found가 뜨거나, 분명 Cache restored가 떴는데 빌드가 여전히 느린 경우가 있습니다. 이 문제는 "캐시가 없다"가 아니라, 캐시 키 설계가 매번 달라지거나, restore-keys가 기대와 다르게 동작하거나, 권한/이벤트 제약으로 저장 자체가 안 되는 경우가 대부분입니다.

이 글에서는 캐시가 안 먹는 상황을 로그로 확정하고, key/restore-keys를 올바르게 설계하며, 권한과 이벤트(특히 PR) 제약까지 포함해 실전에서 안정적으로 캐시를 굴리는 방법을 정리합니다.


1) 먼저 로그로 “안 먹는 이유”를 분류하기

actions/cache@v4는 로그에 힌트를 꽤 많이 남깁니다. 아래 문구를 기준으로 원인을 빠르게 분류하세요.

1-1. 아예 복원이 안 됨: Cache not found

  • Cache not found for input keys: ...
    • key가 매번 바뀌는지
    • restore-keys가 너무 구체적이라 매칭이 안 되는지
    • 경로(path)가 잘못됐는지
    • OS/아키텍처가 바뀌었는지(특히 self-hosted)

1-2. 복원은 됐는데 저장이 안 됨: “not saved” 류

  • Cache restored from key: ...는 떴는데 다음 실행에서 다시 miss
    • 저장 단계에서:
      • Cache save failed: ... (권한/용량/경로)
      • Cache not saved because... (이미 동일 key 존재, 조건문, 실패로 step skip)

1-3. 복원은 됐는데 효과가 없음

  • 캐시 대상이 실제로 빌드가 참조하는 디렉터리가 아닌 경우
  • 패키지 매니저가 캐시를 무시하도록 설정된 경우
  • lockfile이 바뀌어 재설치가 발생하는 경우

캐시 문제는 네트워크/권한/만료처럼 “인프라성” 원인도 많습니다. 비슷한 결의 권한/토큰 이슈 트러블슈팅은 EKS ImagePullBackOff 403 - ECR 권한·토큰 만료 해결처럼 원인 분류 → 로그로 확정 → 최소 재현 순서로 접근하면 빨라집니다.


2) key가 매번 바뀌는 대표 패턴 (캐시 miss 1순위)

캐시가 안 먹는 가장 흔한 이유는 key가 안정적이지 않아서입니다. 특히 아래 패턴을 조심하세요.

2-1. 커밋 SHA, run_id를 key에 넣는 실수

다음처럼 하면 매 실행마다 key가 달라져서 100% miss입니다.

- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ github.sha }}

SHA는 “정확히 동일한 결과물”에만 캐시를 쓰고 싶을 때 일부러 쓰는 경우가 있지만, 의존성 캐시에는 보통 과합니다.

2-2. 너무 넓은 hashFiles / 너무 좁은 hashFiles

  • 너무 넓음: **/* 같은 범위는 README 변경에도 캐시가 깨집니다.
  • 너무 좁음: lockfile 외에 실제 의존성에 영향을 주는 파일을 빼먹으면, 복원은 되지만 설치가 다시 일어나거나(무효화), 더 나쁘게는 “오염된 캐시”를 쓰게 됩니다.

권장: 의존성 캐시는 lockfile 중심

  • Node: package-lock.json, pnpm-lock.yaml, yarn.lock
  • Python: poetry.lock, requirements.txt(+ constraints), uv.lock
  • Rust: Cargo.lock

2-3. 멀티 OS/멀티 버전에서 키에 버전이 빠짐

Node 18과 20을 매트릭스로 돌리는데 key에 node 버전을 포함하지 않으면, 서로 다른 런타임이 같은 캐시를 공유하며 예측 불가능해집니다.


3) restore-keys 동작 방식: “부분 일치”가 아니라 “prefix 매칭”

restore-keysprefix(접두사) 매칭입니다. 그리고 GitHub는 “가장 최근에 저장된 캐시”를 우선적으로 찾습니다.

3-1. restore-keys를 제대로 설계하는 기본형

아래는 Python pip 캐시의 안정적인 패턴입니다.

- name: Cache pip
  id: cache-pip
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      pip-${{ runner.os }}-py${{ matrix.python-version }}-
      pip-${{ runner.os }}-

- name: Install deps
  run: pip install -r requirements.txt

핵심:

  • key는 “정확히 이 lockfile 조합이면 이 캐시”
  • restore-keys는 “lockfile이 바뀌었어도 최대한 가까운 캐시를 가져와서 다운로드량을 줄이자”

3-2. restore-keys를 너무 구체적으로 쓰면 의미가 사라짐

restore-keys: |
  pip-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}

이건 사실상 key와 동일해서 miss면 restore도 miss입니다.

3-3. restore-keys가 “항상 최신 캐시”를 가져오는 함정

prefix가 넓을수록(예: pip-linux-) 의도치 않게 오래된/다른 브랜치의 캐시를 가져올 수 있습니다.

대규모 모노레포라면 브랜치/워크스페이스 단위 prefix를 고려하세요.

restore-keys: |
  pnpm-${{ runner.os }}-${{ github.ref_name }}-
  pnpm-${{ runner.os }}-

단, 브랜치명을 넣으면 브랜치가 많을 때 캐시가 분산되어 효율이 떨어질 수 있습니다. “속도 vs 히트율 vs 오염 위험”의 트레이드오프입니다.


4) 권한/이벤트 제약: PR에서 캐시가 저장되지 않는 케이스

캐시는 복원(restore)저장(save) 이 별개입니다. 특히 저장은 권한/이벤트에 영향을 받습니다.

4-1. pull_request(fork)에서는 저장이 막히는 경우가 많다

외부 fork에서 들어오는 PR은 보안상 토큰 권한이 제한됩니다. 이때는 캐시 저장이 실패하거나 아예 시도되지 않을 수 있습니다.

대응 전략:

  • fork PR에서는 캐시 restore만 허용하고 save는 하지 않기
  • 또는 pull_request_target를 쓰되(주의 필요), 체크아웃/스크립트 실행 보안 모델을 엄격히 분리

예: fork PR에서는 저장 스텝을 건너뛰기

- name: Cache node_modules
  id: cache-node
  uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      .next/cache
    key: node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      node-${{ runner.os }}-

- name: Build
  run: npm ci && npm run build

# 저장은 actions/cache가 자동으로 처리하지만,
# 보안/권한 이슈가 있다면 아예 job 자체를 분리하거나
# fork PR에서는 캐시 job을 돌리지 않는 방식이 안전합니다.

4-2. GITHUB_TOKEN permissions 설정 확인

일부 조직/레포는 기본 권한이 축소되어 있습니다. 워크플로 상단에 명시적으로 권한을 선언해 문제를 줄일 수 있습니다.

permissions:
  contents: read
  actions: read

캐시는 내부적으로 Actions 서비스와 상호작용합니다. 환경에 따라 actions: write가 필요하다고 오해하는 경우가 있는데, 보통은 그렇지 않습니다. 다만 조직 정책/엔터프라이즈 설정으로 인해 제약이 생길 수 있으니, 실패 로그의 HTTP 코드/메시지를 기준으로 판단하세요.

권한 문제는 증상이 “그냥 안 됨”으로 나타나서 시간을 많이 잡아먹습니다. AWS에서 STS 토큰 만료로 403이 나는 케이스처럼(EKS Pod에서 STS 403 ExpiredToken 해결법) 실패 조건을 먼저 확정하는 게 핵심입니다.


5) path가 틀리면: 복원은 됐는데 빌드는 여전히 느리다

캐시의 path는 “존재하는 디렉터리”이면서 “실제로 도구가 읽는 위치”여야 합니다.

5-1. Node/Next.js에서 자주 하는 실수

  • node_modules를 캐시: 가능은 하지만 용량이 크고 깨지기 쉬움
  • 더 나은 선택: 패키지 매니저 캐시 + 빌드 캐시

예: Next.js는 .next/cache가 체감이 큽니다.

- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      .next/cache
    key: next-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('next.config.*', 'tsconfig.json') }}
    restore-keys: |
      next-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
      next-${{ runner.os }}-

Next.js 빌드/하이드레이션 이슈를 잡는 과정에서도 “캐시로 가려진 문제”가 나올 수 있습니다. 프론트 빌드가 이상하게 느리거나 경고가 반복되면 캐시를 의심하기 전에 증상을 분리해보세요: Next.js 14 Hydration failed 경고 10분 해결법

5-2. Python에서 pip/poetry/uv 캐시 위치 확인

  • pip: ~/.cache/pip
  • poetry: ~/.cache/pypoetry 또는 ~/Library/Caches/pypoetry(mac)
  • uv: ~/.cache/uv

도구 버전이 바뀌면 캐시 구조가 달라져 히트해도 효과가 줄 수 있습니다.


6) 캐시 키 설계 레시피 (실전용)

아래 원칙을 지키면 대부분의 “캐시 안 먹음”은 사라집니다.

6-1. 의존성 캐시 키는 lockfile 해시 + 런타임 버전

  • Node:
    • key: node-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('pnpm-lock.yaml') }}
  • Python:
    • key: pip-${{ runner.os }}-py${{ matrix.python }}-${{ hashFiles('requirements.txt') }}

6-2. restore-keys는 2단계 정도로만

너무 넓히면 오염 위험이 커집니다.

  • 1단계: 같은 OS+런타임
  • 2단계: 같은 OS

6-3. 캐시 대상은 “다운로드/컴파일 비용이 큰 것” 위주

  • 패키지 매니저 다운로드 캐시
  • 빌드 캐시(Next .next/cache, Gradle ~/.gradle/caches, Rust target는 신중)

6-4. 캐시가 커지면 오히려 느려진다

캐시는 압축/업로드/다운로드 비용이 있습니다.

  • 캐시가 수 GB면 네트워크가 병목이 되어 이득이 줄어듭니다.
  • node_modules 캐시가 대표적입니다.

7) “정말로 저장이 됐는지” 확인하는 최소 점검 체크리스트

  1. 같은 브랜치에서 연속 2번 실행해보기(첫 실행 save, 두 번째 restore 확인)
  2. 로그에서 아래를 확인
    • restore: Cache restored from key:
    • save: job 끝에서 Cache saved successfully
  3. key에 변동 요소가 없는지 점검
    • ${{ github.sha }}, ${{ github.run_id }}, 날짜 등을 제거
  4. hashFiles() 범위가 적절한지
    • lockfile/의존성 정의 파일만 포함
  5. PR(fork) 이벤트인지 확인
    • 저장이 제한될 수 있음
  6. path가 실제로 존재하고, 도구가 참조하는 경로인지

8) 결론: 캐시 문제는 “키 설계 + 저장 조건” 문제다

GitHub Actions 캐시가 안 먹을 때는 대개 세 갈래입니다.

  • key가 불안정해서 매번 miss
  • restore-keys를 오해해서 매칭이 안 됨(혹은 너무 넓어서 오염)
  • 권한/이벤트 제약으로 저장이 안 됨(특히 fork PR)

가장 빠른 해결법은 “추측으로 YAML을 바꾸기”가 아니라, 로그로 restore/save 여부를 먼저 확정하고, 그 다음에 key/restore-keys를 최소 단위로 단순화해 재구성하는 것입니다. 이 원칙만 지키면 캐시는 ‘가끔 되는 마법’이 아니라, 재현 가능한 성능 도구가 됩니다.