Published on

GitHub Actions 캐시가 안 먹을 때 키·경로·권한

Authors

서로 다른 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) 브랜치/태그에 따른 스코프 차이

캐시는 기본적으로 같은 레포 내에서 공유되지만, 키 설계와 이벤트 흐름에 따라 “사실상 브랜치별 캐시”처럼 동작할 수 있습니다.

  • keygithub.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 캐시가 안 먹을 때 가장 빠른 해결 루트는 다음 순서입니다.

  1. restore vs save 중 어디서 실패하는지 로그로 분리
  2. 키는 lock 파일 해시 + 접두 restore-keys로 안정화
  3. 경로는 “내가 캐시하고 싶은 곳”이 아니라 도구가 실제로 쓰는 캐시 디렉터리로 지정
  4. PR/포크/권한 정책 때문에 저장이 막히는 케이스를 조건문으로 분리

이 네 단계만 체계적으로 밟으면, Cache not found를 “그냥 운이 나쁜” 문제가 아니라 재현 가능한 구성 문제로 바꿀 수 있고, CI 시간을 안정적으로 줄일 수 있습니다.