Published on

GitHub Actions 캐시 미스 - 키·경로 디버깅 실전

Authors

서로 다른 브랜치/PR에서 같은 의존성을 매번 새로 받는다면 CI 시간은 눈덩이처럼 불어납니다. GitHub Actions의 actions/cache는 간단해 보이지만, 실제로는 키(key) 생성 규칙, 경로(path) 해석, **실행 컨텍스트(working-directory, runner OS, 권한)**가 조금만 어긋나도 캐시가 “있는 것처럼 보이는데 안 먹는” 상태가 됩니다. 이 글은 캐시가 지속적으로 Cache not found 또는 Cache skipped로 나올 때, 키와 경로를 어떻게 디버깅해야 하는지를 실전 관점에서 정리합니다.

관련해서 더 깊은 “key·dir 충돌” 케이스는 아래 글도 함께 보면 좋습니다.

캐시가 안 먹는 증상을 먼저 분류하기

캐시 문제는 로그 한 줄로 끝나지 않습니다. 우선 증상을 3가지로 분류하면 원인 탐색이 빨라집니다.

  1. 항상 miss: 매 실행마다 Cache not found for input keys가 뜸
  2. 가끔 hit: 브랜치/OS/Node 버전 등에 따라 hit/miss가 섞임
  3. restore는 되는데 효과가 없음: 캐시는 복원되지만, 설치가 다시 일어나거나 빌드가 다시 도는 느낌

각각의 대표 원인은 다음과 같습니다.

  • 항상 miss: key가 매번 바뀜, path가 잘못됨, 저장 단계가 실행되지 않음
  • 가끔 hit: OS/아키텍처/런타임 버전이 key에 포함됨, lockfile이 브랜치마다 다름
  • 효과 없음: 캐시 대상 디렉터리를 잘못 잡음(예: 실제로는 다른 경로에 설치됨), working-directory 불일치

GitHub Actions 캐시 동작 모델(핵심만)

actions/cache@v4는 크게 두 단계입니다.

  • restore: key/restore-keys로 캐시를 찾아 path에 풀어줌
  • save: job이 끝날 때(정확히는 post 단계) path를 압축해 key로 저장

중요한 포인트:

  • save는 “restore에서 exact key hit”이면 스킵됩니다(동일 키로 중복 저장 방지).
  • save는 job이 중간에 실패하면 실행되지 않을 수 있습니다(post 단계가 보장되지 않는 케이스가 있음).
  • path는 runner의 파일시스템 기준이며, ~는 쉘에서처럼 확장되지 않는다고 가정하고 절대경로/환경변수로 명시하는 편이 안전합니다.

1단계: 로그에서 key/path를 “출력”해서 확정하기

캐시 디버깅의 시작은 “내가 의도한 key/path가 실제로 무엇으로 평가되는지”를 확인하는 겁니다. 아래 스니펫처럼 키 구성 요소를 step에서 직접 출력하세요.

- name: Debug cache inputs
  shell: bash
  run: |
    echo "runner.os=$RUNNER_OS"
    echo "runner.arch=$RUNNER_ARCH"
    echo "github.ref=$GITHUB_REF"
    echo "github.sha=$GITHUB_SHA"
    echo "workspace=$GITHUB_WORKSPACE"
    echo "pwd=$(pwd)"
    ls -la
    echo "lockfile hash (package-lock.json):"
    if [ -f package-lock.json ]; then sha256sum package-lock.json; else echo "no package-lock.json"; fi

이 출력이 있어야 “왜 key가 매번 달라지는지”, “path가 존재하는지”를 다음 단계에서 판단할 수 있습니다.

2단계: key 디버깅 — 매번 바뀌는 요소 제거하기

가장 흔한 실수: key에 변동성이 큰 값이 들어감

다음 값이 key에 들어가면 실질적으로 매번 miss가 됩니다.

  • github.sha
  • github.run_id, github.run_number
  • 커밋마다 바뀌는 파일 hash(예: lockfile이 아닌 package.json만 hash)

캐시의 목적이 “의존성 재사용”이라면 key는 보통 아래 조합이 안전합니다.

  • OS/런타임 버전 + lockfile hash

예시(Node + npm):

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

여기서 포인트는:

  • hashFiles('**/package-lock.json')는 lockfile이 바뀔 때만 캐시를 새로 만듭니다.
  • restore-keys를 prefix로 두면 lockfile이 살짝 달라져도 “가장 근접한 캐시”를 가져올 수 있습니다.

hashFiles가 빈 문자열이 되는 케이스

hashFiles는 매칭되는 파일이 없으면 빈 값이 될 수 있습니다. 예를 들어 monorepo인데 lockfile이 서브디렉터리에 있고, 현재 job의 working directory가 루트가 아닌 경우가 그렇습니다.

이 경우 key가 ...-npm-처럼 끝나 버리고, 의도치 않은 충돌/미스가 발생합니다. 해결은 둘 중 하나입니다.

  • lockfile 경로를 정확히 지정
  • working-directory를 통일
- name: Cache npm (monorepo)
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}

3단계: path 디버깅 — “캐시할 폴더가 실제로 생기는가?”

캐시는 존재하는 디렉터리/파일을 저장합니다. 즉, 설치 전에 캐시를 저장하려 하면 저장할 게 없습니다(또는 빈 캐시).

npm/yarn/pnpm은 캐시 경로가 다르다

  • npm: ~/.npm(다운로드 캐시)
  • yarn classic: ~/.cache/yarn
  • pnpm: ~/.pnpm-store 또는 pnpm store path 결과

특히 pnpm은 러너/버전에 따라 store 경로가 달라질 수 있어, 명령으로 경로를 확정하는 게 좋습니다.

- name: Get pnpm store path
  id: pnpm-store
  shell: bash
  run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT

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

path가 워크스페이스 기준 상대경로로 꼬이는 문제

path: node_modules를 캐시하는 구성은 종종 “되는 듯 안 되는” 느낌을 줍니다.

  • 프로젝트가 여러 개(모노레포)면 node_modules가 여러 위치에 생김
  • working-directory가 step마다 다르면, 캐시 restore는 A 위치에 풀고 설치는 B 위치에서 진행

디버깅 방법:

- name: Verify dirs after restore
  shell: bash
  run: |
    echo "PWD=$(pwd)"
    echo "List root"
    ls -la
    echo "Find node_modules (top 3)"
    find . -maxdepth 3 -type d -name node_modules | head

해결 전략은 보통 둘 중 하나입니다.

  • **의존성 캐시(다운로드 캐시)**만 잡는다(~/.npm, ~/.cache/yarn, pnpm store)
  • 모노레포라면 워크스페이스별 node_modules를 명시적으로 나열하거나, 패키지 매니저 권장 캐시 경로를 사용

4단계: “restore는 됐는데 효과가 없다”를 잡는 체크

캐시 hit 로그가 나와도 설치 시간이 줄지 않는다면, 대개 다음 중 하나입니다.

(1) 캐시 대상이 ‘설치 결과물’이 아니라 ‘다운로드 캐시’라서

예: npm의 ~/.npm은 tarball 다운로드 캐시입니다. npm ci는 여전히 파일을 풀고 링크를 구성합니다. 하지만 네트워크 다운로드가 줄어 전체 시간은 줄어야 정상입니다.

  • 네트워크가 빠른 환경이면 체감이 적을 수 있음
  • 반대로 사내 프록시/레이트리밋 환경이면 큰 효과

(2) 설치 커맨드가 캐시를 무시하게 실행됨

  • npm cache clean --force 같은 step이 중간에 있거나
  • pnpm에서 store-dir을 매번 다른 곳으로 지정

설치 커맨드 직전/직후에 실제 캐시 디렉터리의 크기를 확인하면 감이 옵니다.

- name: Check cache dir size
  shell: bash
  run: |
    du -sh ~/.npm || true
    du -sh ~/.cache/yarn || true

(3) 권한/소유자 문제로 restore된 파일을 사용 못함

컨테이너 기반 job이나, sudo로 설치하는 step이 섞이면 소유자가 꼬일 수 있습니다. 이때는 캐시가 풀려도 읽기/쓰기 에러로 다시 다운로드하거나 실패합니다.

  • 컨테이너 job이면 path가 컨테이너 FS에 존재하는지
  • ~가 root의 홈인지 runner 사용자의 홈인지

5단계: 저장(save) 자체가 안 되는 케이스

restore는 잘 되는데 다음 실행에서 계속 miss라면 “저장이 안 됐다”는 뜻입니다.

체크 포인트:

  • job이 실패/취소되어 post 단계가 실행되지 않았는가
  • actions/cache step 이후에 exit 1로 끝나는 테스트가 있는가
  • 캐시 크기 제한(리포/조직 정책)에 걸리지 않았는가

실전 팁으로는, 캐시가 반드시 저장돼야 하는 파이프라인이라면 테스트 실패와 캐시 저장을 분리(예: 의존성 설치 job을 먼저 성공시키고, 이후 job에서 테스트)하는 방식도 고려합니다.

6단계: 키 충돌/경로 충돌을 의심해야 하는 순간

가끔은 “hit인데 내용이 이상하다”, “다른 프로젝트 캐시를 가져온 느낌”이 납니다. 보통은 다음 구조에서 발생합니다.

  • 여러 워크플로/여러 프로젝트가 너무 짧은 prefix key를 공유
  • restore-keys가 너무 넓어서 엉뚱한 캐시를 끌고 옴

해결은 key에 최소한의 구분자를 넣는 겁니다.

  • 리포지토리/패키지 경로
  • 패키지 매니저 종류
  • 런타임 버전
key: ${{ github.repository }}-${{ runner.os }}-node-${{ matrix.node }}-web-npm-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
  ${{ github.repository }}-${{ runner.os }}-node-${{ matrix.node }}-web-npm-

이 주제는 케이스가 다양해서, 충돌 패턴 중심으로 정리한 글을 함께 참고하면 시행착오가 줄어듭니다.

예제: “디버깅 포함” 안정적인 캐시 템플릿(Node)

아래는 디버깅 step을 포함한, 재사용하기 좋은 템플릿입니다.

name: ci

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [20]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}

      - name: Debug context
        shell: bash
        run: |
          echo "RUNNER_OS=$RUNNER_OS"
          echo "RUNNER_ARCH=$RUNNER_ARCH"
          echo "GITHUB_REF=$GITHUB_REF"
          echo "GITHUB_WORKSPACE=$GITHUB_WORKSPACE"
          echo "PWD=$(pwd)"
          ls -la
          if [ -f package-lock.json ]; then sha256sum package-lock.json; else echo "no package-lock.json"; fi

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

      - name: Install
        run: npm ci

      - name: Test
        run: npm test

      - name: Check cache dir size
        if: always()
        shell: bash
        run: du -sh ~/.npm || true

이 템플릿은 다음을 보장합니다.

  • key가 커밋마다 흔들리지 않음(lockfile 기반)
  • restore-keys 범위를 리포지토리+OS+Node로 제한
  • 실제 lockfile hash, 실행 위치, 캐시 디렉터리 크기를 로그로 확인 가능

캐시 디버깅 최종 체크리스트

  • key에 sha/run_id 같은 변동 값이 들어가 있지 않은가
  • hashFiles()가 빈 값이 되지 않는가(경로/working-directory)
  • path가 실제로 생성되는가(설치 전/후 확인)
  • path가 의도한 위치인가(컨테이너/권한/홈 디렉터리)
  • restore는 되는데 설치가 다른 디렉터리에서 실행되고 있지 않은가
  • restore-keys가 너무 넓어서 엉뚱한 캐시를 가져오지 않는가
  • job 실패로 save가 실행되지 않는 구조는 아닌가

마무리

GitHub Actions 캐시는 “설정이 맞으면” CI 시간을 가장 싸게 줄이는 장치지만, 안 맞으면 로그만 보고는 감이 오지 않습니다. 그래서 (1) key 구성 요소 출력, (2) path 존재/위치 검증, (3) restore-keys 범위 축소의 3단계를 습관처럼 적용하면 대부분의 캐시 미스는 빠르게 해결됩니다.

캐시가 계속 이상하게 동작한다면, 결국 문제는 keypath 중 하나(혹은 둘 다)에서 “내가 생각한 값”과 “러너가 평가한 값”이 다르기 때문입니다. 그 차이를 로그로 눈에 보이게 만들면, 해결은 생각보다 단순해집니다.