Published on

GitHub Actions 캐시가 안 먹을 때 - key·restore-keys 완전 정복

Authors

서버리스 CI처럼 보이는 GitHub Actions도 결국은 “원격 캐시”를 얼마나 정확히 맞추느냐에 따라 체감 속도가 크게 갈립니다. 그런데 캐시가 안 먹을 때 대부분은 actions/cache 자체 문제가 아니라, key 설계가 너무 자주 바뀌거나 restore-keys가 의도대로 동작하지 않는 경우가 많습니다.

이 글에서는 keyrestore-keys를 중심으로 캐시가 미스나는 전형적인 패턴을 정리하고, 언어별로 “안전하게 빠른” 키 설계 방법과 디버깅 루틴을 제공합니다. 캐시라는 주제는 Next.js의 ISR 캐시처럼 “원리와 무효화 조건”을 이해해야 실전에서 흔들리지 않습니다. 필요하다면 Next.js 14 캐시 때문에 ISR 갱신 안 될 때 디버깅도 함께 읽어보면 캐시 사고방식이 더 단단해집니다.

GitHub Actions 캐시가 동작하는 방식 요약

actions/cache는 기본적으로 아래 흐름으로 동작합니다.

  1. key로 캐시를 조회합니다.
  2. 해당 key가 정확히 일치하면 cache hit입니다.
  3. 일치하지 않으면 restore-keys를 prefix 매칭으로 순서대로 시도합니다.
  4. 복원에 성공하면 “부분 hit”로 작업을 진행합니다.
  5. 잡이 끝날 때, 복원에 사용된 키가 아니라 “최종 key”로 캐시를 저장하려고 합니다.
  6. 단, 최종 key가 이미 존재하면 저장을 건너뜁니다.

여기서 중요한 점은 다음 두 가지입니다.

  • restore-keys는 “대체 복원”일 뿐, 저장 키는 항상 key입니다.
  • 캐시는 “정확히 같은 키”가 아니면 기본적으로 miss입니다. 그래서 키가 조금만 흔들려도 매번 새 캐시가 만들어집니다.

캐시가 안 먹는 가장 흔한 원인 10가지

1) key에 너무 자주 바뀌는 값이 들어감

대표적으로 github.shakey에 넣으면 커밋마다 키가 바뀌어 매번 miss가 납니다.

  • 잘못된 예: key: cache-${{ github.sha }}
  • 의도: “커밋마다 완전 격리”라면 맞지만, 대부분은 의도와 다릅니다.

2) restore-keys를 너무 구체적으로 잡음

restore-keys는 prefix 매칭입니다. 그런데 prefix가 너무 길면 사실상 exact match처럼 되어 대체 복원이 거의 일어나지 않습니다.

3) path가 빌드마다 달라지는 디렉터리

예를 들어 path에 워크스페이스 내 임시 폴더, 빌드 산출물(dist)을 넣으면 “복원은 되지만 쓸모가 없거나” 오히려 충돌이 날 수 있습니다.

4) 락파일이 없는 상태에서 hashFiles를 씀

hashFiles('**/package-lock.json')가 빈 문자열이 되면 키가 예상과 달라집니다. 모노레포에서 특히 자주 발생합니다.

5) OS, 아키텍처 차이를 무시

runner.os를 키에 넣지 않으면 Linux에서 만든 캐시를 macOS에서 복원하려다 실패하거나, 더 나쁘게는 복원은 되는데 바이너리가 깨질 수 있습니다.

6) 캐시 대상이 “다운로드”가 아니라 “컴파일 결과”인 경우

예를 들어 Rust의 target이나 Go의 빌드 캐시는 환경 변수가 조금만 달라도 무효화됩니다. 이런 건 키 설계와 함께 “캐시 경로 선택”을 다시 봐야 합니다.

7) actions/setup-node의 캐시와 actions/cache를 중복 사용

setup-nodecache: npmactions/cache~/.npm을 또 캐시하면 기대와 다르게 동작하거나 로그 해석이 어려워집니다.

8) 브랜치 전략과 키 전략 불일치

main과 feature 브랜치가 같은 키를 쓰면 오염이 생길 수 있고, 반대로 브랜치명을 키에 넣으면 브랜치마다 캐시가 갈라져 hit율이 떨어집니다.

9) 저장 시점 오해

캐시는 “스텝이 성공적으로 끝난 뒤” 저장됩니다. 빌드가 중간에 실패하면 다음 실행에서 캐시가 없는 것처럼 보일 수 있습니다.

10) 캐시 사이즈/정책 문제

캐시는 리포지토리 단위로 저장되며, 오래된 캐시가 정리될 수 있습니다. “어제는 됐는데 오늘은 안 됨” 같은 현상이 여기서도 나옵니다.

key 설계 원칙: 안정성, 격리, 재사용의 균형

좋은 key는 보통 다음 축을 포함합니다.

  • 환경 축: runner.os, 언어 런타임 버전
  • 의존성 축: 락파일 해시
  • 범위 축: 모노레포 패키지 경로, 워크플로 목적

반대로, 다음 값은 웬만하면 피합니다.

  • github.sha (너무 자주 변함)
  • github.run_id (매 실행마다 변함)
  • github.ref 전체 문자열 (브랜치/태그가 난립하면 캐시 파편화)

추천 키 템플릿

  • Node.js 예시: node-${{ runner.os }}-${{ steps.node.outputs.node-version }}-${{ hashFiles('**/package-lock.json') }}
  • Python 예시: pip-${{ runner.os }}-${{ steps.py.outputs.python-version }}-${{ hashFiles('**/requirements*.txt') }}
  • Go 예시: go-${{ runner.os }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('**/go.sum') }}

핵심은 “의존성이 바뀌면 캐시가 갈리고, 의존성이 같으면 웬만하면 재사용”입니다.

restore-keys 제대로 쓰는 법: prefix 계층 만들기

restore-keys는 “정확히 일치하지 않아도 비슷한 캐시를 가져와서 다운로드 시간을 줄이자”는 전략입니다. 따라서 계층을 이렇게 구성하는 게 보통 좋습니다.

  1. 가장 구체적인 키: OS + 런타임 + 락파일 해시
  2. 중간 키: OS + 런타임
  3. 가장 넓은 키: OS

이렇게 하면 락파일이 바뀌어도 “이전 락파일 기반 캐시”라도 가져와서, 패키지 매니저가 증분으로 맞추게 할 수 있습니다.

실전 예제 1: Node.js에서 npm 캐시가 안 먹을 때

아래 예제는 ~/.npm을 캐시합니다. key는 락파일 해시로 고정하고, restore-keys로 단계적 폴백을 구성합니다.

name: ci
on:
  push:
  pull_request:

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

      - name: Setup Node
        id: node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

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

      - name: Install
        run: npm ci

      - name: Test
        run: npm test

여기서 자주 하는 실수

  • pathnode_modules로 잡는 것
    • 모노레포, 네이티브 모듈, postinstall 스크립트가 있는 프로젝트에서 깨질 확률이 높습니다.
    • 보통은 패키지 매니저 캐시(~/.npm, ~/.cache/yarn, pnpm store)를 캐시하는 편이 안전합니다.

실전 예제 2: Python pip 캐시와 해시 설계

pip는 ~/.cache/pip 캐시가 효과가 좋습니다. 단, 파이썬 버전이 바뀌면 휠 호환성이 달라질 수 있으니 키에 포함하는 게 안전합니다.

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

      - name: Setup Python
        id: py
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

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

      - name: Install
        run: |
          python -m pip install -U pip
          pip install -r requirements.txt

데이터 파이프라인이나 pandas 기반 프로젝트라면 의존성 변경이 잦고 캐시 hit율이 성능에 크게 영향을 줍니다. 경고 하나가 파이프라인을 느리게 만드는 것처럼, 캐시 미스도 누적되면 CI가 체감상 “항상 느린 상태”가 됩니다. pandas 경고를 구조적으로 잡는 관점은 pandas SettingWithCopyWarning 완전 정복 - 원인·해결도 참고할 만합니다.

실전 예제 3: 모노레포에서 패키지별 캐시 분리

모노레포는 락파일이 루트에 하나인 경우가 많아 “한 패키지 변경이 전체 캐시 무효화”로 이어질 수 있습니다. 이때는 워크플로 목적에 따라 키를 분리합니다.

  • 앱 A만 빌드하는 잡이면, 앱 A의 락파일 또는 앱 A가 참조하는 워크스페이스 범위를 키에 반영
  • 최소한 “잡 이름”을 키에 넣어 캐시 오염을 줄임
- name: Cache npm (web)
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-web-${{ runner.os }}-node20-${{ hashFiles('package-lock.json', 'apps/web/package.json') }}
    restore-keys: |
      npm-web-${{ runner.os }}-node20-
      npm-${{ runner.os }}-node20-

여기서 포인트는 restore-keys에 “더 넓은 공용 캐시”로 폴백하는 줄을 하나 두는 것입니다. 그러면 web 전용 캐시가 없을 때도 최소한 Node 20 공용 캐시를 가져올 수 있습니다.

캐시 디버깅 체크리스트

캐시가 안 먹는다고 느껴질 때는 감으로 고치지 말고, 아래를 순서대로 확인하는 게 빠릅니다.

1) 로그에서 cache hit 여부 확인

actions/cache는 로그에 Cache hit occurred on the primary key 같은 문구를 남깁니다. 이 문구가 없다면 miss입니다.

2) 실제 계산된 key를 출력

YAML에서 키가 길고 복잡해지면 “내가 생각한 문자열”과 “실제 문자열”이 달라지는 순간이 옵니다. 안전하게 출력 스텝을 하나 둡니다.

- name: Print cache key inputs
  run: |
    echo "os=${{ runner.os }}"
    echo "node=${{ steps.node.outputs.node-version }}"

hashFiles 값 자체를 직접 출력할 수는 없지만, 락파일이 실제로 존재하는지 ls로 확인하는 것만으로도 많은 문제가 해결됩니다.

3) path가 올바른지 확인

특히 홈 디렉터리 경로는 러너에 따라 다르게 느껴질 수 있습니다. Linux 기준 ~가 기대대로 확장되는지 확인합니다.

4) 브랜치 간 캐시 공유 전략 확인

  • 캐시를 공유하고 싶으면 브랜치명을 키에 넣지 않습니다.
  • 오염이 걱정되면 “워크플로 목적” 또는 “패키지 범위”로 분리합니다.

5) 저장이 스킵되는지 확인

복원에 성공했지만 저장이 안 되는 경우가 있습니다. 대표적으로 “최종 key가 이미 존재”하면 저장이 스킵됩니다. 이건 정상 동작입니다.

자주 묻는 질문

restore-keys는 여러 개를 넣으면 어떤 걸 고르나

위에서부터 순서대로 prefix 매칭을 시도해서, 가장 먼저 매칭되는 캐시를 가져옵니다. 따라서 “가장 구체적인 것부터, 점점 넓게”가 정석입니다.

캐시가 오히려 빌드를 느리게 할 수도 있나

그럴 수 있습니다. 캐시 압축/업로드/다운로드 비용이 큰데, 실제로는 설치 시간이 짧거나 캐시 hit율이 낮으면 손해입니다. 특히 대용량 빌드 산출물을 캐시하면 이런 일이 잦습니다.

캐시와 아티팩트는 무엇이 다른가

  • 캐시: 재사용을 위한 저장소, 키 기반, best-effort
  • 아티팩트: 실행 결과물 전달, 보통 워크플로 실행 단위로 보존

빌드 결과물을 다음 잡에 전달하려면 캐시보다 아티팩트가 맞는 경우가 많습니다.

정리: 캐시가 안 먹을 때는 키 설계부터 의심하자

  • key는 “자주 변하는 값”을 빼고, 의존성(락파일)과 환경(OS, 런타임)을 중심으로 설계합니다.
  • restore-keys는 prefix 계층을 만들어 “부분 hit”라도 얻어 설치 시간을 줄입니다.
  • 캐시 디버깅은 로그 확인, 키 입력값 출력, 락파일 존재 여부 확인 순서로 접근합니다.

CI가 느려지면 팀 전체 개발 속도에 직접 영향을 줍니다. 캐시가 한 번 제대로 먹기 시작하면, 체감상 가장 큰 생산성 개선 중 하나가 됩니다. 캐시 문제를 구조적으로 디버깅하는 감각은 런타임/인프라 전반에도 그대로 확장됩니다. 예를 들어 네트워크 타임아웃을 원인별로 쪼개는 접근은 Go gRPC context deadline exceeded 원인 9가지 같은 글과도 결이 같습니다.