Published on

GitHub Actions 캐시가 안 먹을 때 - key·dir 충돌 디버깅

Authors

서로 다른 워크플로/잡에서 분명 캐시를 저장했는데도 매번 Cache not found for input keys만 뜨는 상황은 생각보다 흔합니다. 특히 Node/PNPM, Python/pip, Gradle, CocoaPods처럼 “캐시할 디렉터리”가 여러 층으로 나뉘는 생태계에서는 cache-dir(혹은 실제 캐시 경로)와 key가 조금만 어긋나도 캐시가 저장되긴 하지만 다음 실행에서 절대 복원되지 않는 상태가 됩니다.

이 글은 “캐시가 안 먹는다”를 감으로 해결하지 않고, cache-dir·key 충돌을 중심으로 재현 → 관찰 → 원인 분리 → 수정까지 디버깅 루틴으로 정리합니다. 운영 환경에서 디스크/GC 이슈가 섞여 캐시가 사라지는 케이스도 함께 다룹니다.

1) 캐시가 ‘안 먹는’ 증상의 유형부터 분류하기

GitHub Actions 캐시 이슈는 크게 3가지로 나뉩니다.

1.1 진짜 미스(Cache miss)

  • 로그: Cache not found for input keys: ...
  • 원인: key가 매번 달라짐(해시 대상 파일이 자주 바뀜), restore-keys가 없음/부정확

1.2 저장은 되는데 복원이 안 됨(경로/권한/시점 문제)

  • 로그: 저장 단계에서 Cache saved successfully가 뜨지만 다음 실행에서 미스
  • 원인: 저장한 경로가 실제로 빌드에 쓰인 캐시 경로가 아님, 혹은 빌드 전에 restore가 실행되지 않음

1.3 복원은 됐는데 체감이 없음(효과 미미)

  • 로그: Cache restored from key: ...는 뜸
  • 원인: 캐시 대상이 잘못됨(예: node_modules 대신 store를 캐시해야 하는데 반대), 패키지 매니저가 다른 위치를 씀, lockfile 변화로 재다운로드 발생

이 글의 핵심은 1.2에서 자주 터지는 “cache-dir·key 충돌”입니다.

2) GitHub Actions 캐시의 동작 원리(디버깅에 필요한 만큼만)

actions/cache는 대략 다음 순서로 동작합니다.

  1. restore 단계에서 key/restore-keys로 캐시를 찾음
  2. 찾으면 지정한 path에 아카이브를 풀어줌
  3. 잡이 끝날 때(정확히는 post 단계) key로 캐시를 저장(단, 동일 key가 이미 존재하면 저장 안 함)

즉, 다음이 성립해야 합니다.

  • 복원 시점: 의존성 설치/빌드보다 먼저 restore가 실행되어야 함
  • 경로 일치: path가 “실제로 패키지 매니저가 쓰는 캐시 위치”와 같아야 함
  • 키 안정성: 같은 상황에서는 같은 key가 만들어져야 함

여기서 cache-dir(pnpm/yarn/pip 등에서 지정하는 캐시 위치)와 actions/cache path가 어긋나면 “저장은 되는데 복원이 의미가 없는” 상태가 됩니다.

3) 실전에서 가장 흔한 충돌 패턴 5가지

3.1 패키지 매니저는 A에 쓰는데 캐시는 B를 저장

예) pnpm은 store를 쓰는데 node_modules를 캐시하거나, 반대로 store를 지정했는데 실제 store 경로가 다름.

  • pnpm의 store는 기본적으로 OS/버전에 따라 달라질 수 있음
  • pnpm config get store-dir 결과를 신뢰해야 합니다

3.2 ~ 확장/심볼릭 링크로 path가 달라짐

path: ~/.cache/pip처럼 작성했는데, 실제 실행 환경에서 확장된 절대경로가 다르거나(드물지만) 캐시가 심볼릭 링크를 따라가며 다른 위치에 저장되는 케이스가 있습니다.

3.3 key에 “변동이 큰 값”이 섞여 매번 다른 key 생성

  • github.run_id, github.run_number, timestamp, 브랜치명 전체(특히 feature/*)를 key에 넣으면 거의 매번 미스가 납니다.
  • 커밋 SHA를 key에 넣는 것도 lockfile이 동일한데도 매번 미스가 날 수 있어 권장되지 않습니다.

3.4 key는 같지만 path가 잡마다 다름(멀티 잡/매트릭스)

매트릭스 빌드에서 OS별/Node 버전별로 캐시 경로가 다르거나, cache-dir이 버전에 따라 달라지는 경우가 있습니다. 이때 key는 같게 만들어 충돌을 유발하거나, 반대로 너무 쪼개서 캐시 재사용이 안 됩니다.

3.5 디스크 압박/GC로 캐시가 ‘생성되기 전에’ 정리됨

러너 디스크가 부족하면 의존성 설치 중간에 캐시 디렉터리가 비정상 상태가 되거나, 아예 공간 부족으로 설치가 느려져 캐시 체감이 사라집니다.

EKS에서 디스크 압박으로 Pod가 축출되는 패턴과 유사하게, 러너에서도 디스크가 병목이면 캐시가 있어도 “복원→설치”가 기대만큼 빨라지지 않습니다. 관련 운영 관점은 EKS DiskPressure로 Pod Evicted 폭주 해결 10가지에서 다룬 디스크/GC 접근법이 참고가 됩니다.

4) 디버깅 루틴: ‘경로’와 ‘키’를 분리해서 검증

4.1 1단계: 실제 캐시 경로를 로그로 고정(필수)

패키지 매니저가 실제로 어느 디렉터리를 쓰는지부터 확정해야 합니다.

pnpm 예시

- name: Show pnpm store dir
  run: |
    pnpm --version
    pnpm config get store-dir
    ls -al "$(pnpm config get store-dir)" || true

pip 예시

- name: Show pip cache dir
  run: |
    python -m pip --version
    python -m pip cache dir
    ls -al "$(python -m pip cache dir)" || true

이 단계에서 “내가 캐시한다고 생각한 경로”와 “실제 경로”가 어긋나는 경우가 가장 많습니다.

4.2 2단계: cache restore 결과를 출력하고 분기 처리

actions/cachecache-hit 출력이 있습니다. 이를 이용해 캐시가 실제로 복원됐는지 잡 내에서 조건 분기를 걸어 확인합니다.

- name: Restore cache
  id: cache
  uses: actions/cache@v4
  with:
    path: |
      ~/.pnpm-store
    key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-

- name: Cache status
  run: |
    echo "cache-hit=${{ steps.cache.outputs.cache-hit }}"
  • cache-hit=true인데도 설치 시간이 그대로면 path가 틀렸거나 캐시 대상이 비효율적일 가능성이 큽니다.
  • cache-hit=false가 계속이면 key가 계속 변하거나 restore-keys가 부정확합니다.

4.3 3단계: key를 ‘관찰 가능’하게 만들기

key 문자열이 길고 복잡하면 어디가 변하는지 놓치기 쉽습니다. key를 구성하는 요소를 출력해두면 원인 분리가 빨라집니다.

- name: Debug cache key inputs
  run: |
    echo "os=${{ runner.os }}"
    echo "ref=${{ github.ref }}"
    echo "lock_hash=${{ hashFiles('**/pnpm-lock.yaml') }}"

여기서 lock_hash가 예상과 다르게 바뀌면(예: lockfile 경로가 여러 개, 모노레포에서 패키지별 lockfile 혼재) key가 매번 바뀌는 원인이 됩니다.

5) cache-dir·key 충돌을 재현하는 최소 예제와 수정

아래는 “pnpm store-dir을 바꿔놓고, 캐시는 기본 경로를 저장하는” 전형적인 충돌 예시입니다.

5.1 문제 있는 워크플로 예시

name: ci
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: corepack enable

      # pnpm store를 프로젝트 내부로 강제
      - name: Configure pnpm store
        run: pnpm config set store-dir .pnpm-store

      # 하지만 캐시는 홈 디렉터리의 기본 store를 저장하려고 함(불일치)
      - uses: actions/cache@v4
        with:
          path: ~/.pnpm-store
          key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}

      - run: pnpm install --frozen-lockfile
      - run: pnpm test

이 경우 캐시는 ~/.pnpm-store를 복원하지만, pnpm은 .pnpm-store를 사용하므로 설치는 매번 새로 받게 됩니다.

5.2 수정: “실제 store-dir”을 캐시 path로 사용

가장 안전한 방법은 pnpm이 말해주는 store-dir을 그대로 캐시하는 것입니다.

- name: Get pnpm store dir
  id: pnpm-store
  shell: bash
  run: echo "dir=$(pnpm config get store-dir)" >> "$GITHUB_OUTPUT"

- name: Restore pnpm cache
  id: cache
  uses: actions/cache@v4
  with:
    path: ${{ steps.pnpm-store.outputs.dir }}
    key: ${{ runner.os }}-node20-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-node20-pnpm-

- name: Install
  run: pnpm install --frozen-lockfile

포인트는 2가지입니다.

  • path는 고정 문자열이 아니라 “실제 값”을 쓰게 만들기
  • key에 Node 버전을 포함해 ABI/네이티브 모듈 차이로 인한 오염을 줄이기

6) key 설계: 충돌(오염) vs 미스(재사용 불가)의 균형

key는 너무 세분화되면 매번 미스가 나고, 너무 뭉뚱그리면 서로 다른 환경이 같은 캐시를 공유해 오염됩니다.

6.1 권장 key 구성 요소

  • OS: ${{ runner.os }}
  • 런타임 버전: Node/Python/Java 버전
  • 락파일 해시: hashFiles('**/pnpm-lock.yaml'), hashFiles('**/poetry.lock'), hashFiles('**/gradle.lockfile')

예시:

key: ${{ runner.os }}-py311-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
  ${{ runner.os }}-py311-pip-

6.2 피해야 할 요소

  • ${{ github.sha }}: 같은 lockfile이어도 커밋마다 캐시 미스
  • ${{ github.run_id }}: 100% 미스
  • 브랜치 전체를 key에 포함: feature 브랜치가 많으면 캐시가 분산

6.3 restore-keys를 “의도적으로” 느슨하게

락파일이 바뀌었더라도 비슷한 캐시를 가져와 다운로드를 줄일 수 있습니다.

  • 단, 네이티브 모듈/ABI가 민감한 생태계는 느슨한 restore가 오히려 문제를 만들 수 있어 런타임 버전까지는 반드시 포함하는 편이 안전합니다.

7) 모노레포에서 자주 터지는 함정: hashFiles 범위

모노레포에서 패키지별 lockfile이 있거나, 루트 lockfile 하나로 관리하는지에 따라 hashFiles 범위를 잘못 잡으면 key가 예상과 다르게 변합니다.

7.1 루트 lockfile 하나면

key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}

7.2 패키지별 lockfile이 있으면(권장하진 않지만 현실적으로 존재)

key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}

이때도 “테스트 잡”과 “빌드 잡”이 서로 다른 작업 디렉터리에서 실행되면 lockfile 탐색 결과가 달라질 수 있으니, actions/checkout 이후 pwd, ls로 기준 디렉터리를 고정하는 것이 좋습니다.

8) 캐시가 있는데도 느린 경우: 캐시 대상 재선정

캐시를 node_modules로 잡으면 다음 문제가 생깁니다.

  • OS/Node 버전/네이티브 모듈에 민감
  • 파일 수가 많아 압축/해제 비용이 큼

대부분은 패키지 매니저의 “다운로드 캐시(store)”를 캐시하는 편이 효율적입니다.

  • pnpm: store-dir
  • pip: pip cache dir
  • Gradle: ~/.gradle/caches(단, 크기 관리 필요)

디스크가 빡빡한 러너에서 캐시 압축/해제 비용이 오히려 병목이 되기도 합니다. 이런 경우 디스크 압박/GC 관점의 점검이 필요하며, 유사한 디스크 문제 접근은 EKS에서 nodefs ImageGC로 Pod가 Evicted될 때도 참고할 만합니다.

9) 체크리스트: 10분 안에 원인 좁히기

  1. actions/cache가 의존성 설치 이전에 실행되는가?
  2. 패키지 매니저가 쓰는 실제 캐시 경로를 로그로 확인했는가?
  3. path가 그 경로와 완전히 동일한가?(절대경로/심볼릭 링크 포함)
  4. cache-hit 출력이 실제로 true/false인지 기록했는가?
  5. key 구성 요소(hashFiles, OS, 런타임 버전)가 예상대로 고정되는가?
  6. key에 sha, run_id 같은 변동값이 섞이지 않았는가?
  7. restore-keys가 너무 엄격해서 “근접 캐시”도 못 가져오는 건 아닌가?
  8. 매트릭스 빌드에서 OS/버전별로 key가 충돌하거나 반대로 과도 분산되지 않는가?
  9. 캐시 대상이 node_modules처럼 비효율적인 디렉터리로 잡혀 있지 않은가?
  10. 러너 디스크 용량/IO 병목으로 캐시 효과가 상쇄되지 않는가?

10) 결론: ‘경로를 확정’하고 ‘키를 관찰’하면 대부분 끝난다

GitHub Actions 캐시 문제는 복잡해 보이지만, 실전에서 가장 많은 원인은 단순합니다.

  • 패키지 매니저의 cache-dir/store-diractions/cache path가 다르다
  • key가 너무 자주 바뀌거나(미스), 너무 뭉뚱그려져 오염된다(충돌)

가장 재현성 높은 해결법은 다음 2줄로 요약됩니다.

  1. 실제 캐시 디렉터리를 명령으로 조회해 path에 넣기
  2. OS + 런타임 버전 + lockfile 해시로 key를 설계하고, restore-keys로 완충하기

이 루틴을 적용하면 “캐시가 안 먹는다”는 막연한 상태에서 벗어나, 로그로 원인을 증명하면서 안정적으로 CI 시간을 줄일 수 있습니다.