Published on

GitHub Actions 캐시 미스 원인 7가지와 해결

Authors

서버리스 CI인 GitHub Actions는 매 실행마다 깨끗한 러너에서 시작합니다. 그래서 actions/cache를 제대로 쓰면 의존성 설치 시간을 수십 초~수분 단축할 수 있지만, 반대로 캐시가 계속 MISS 나면 “왜 안 먹지?”만 남습니다.

이 글은 캐시 MISS가 발생하는 패턴을 7가지로 분류하고, 각 케이스별로 어떤 로그를 보고, 어떻게 key/경로/전략을 고쳐야 하는지를 워크플로 코드로 설명합니다.

> 참고: 인증/권한 이슈로 워크플로 자체가 꼬일 때는 OIDC/권한 점검도 함께 하세요. 예: GitHub Actions OIDC 403·권한거부 원인 7가지

GitHub Actions 캐시 동작 요약 (MISS를 이해하는 전제)

actions/cache@v4는 대략 아래 규칙으로 움직입니다.

  • key완전히 동일하면 HIT
  • 동일 key가 없으면 restore-keysprefix 매칭으로 가장 가까운 캐시를 복원(부분 HIT처럼 사용)
  • 캐시는 job 성공 시점에 저장(일반적으로 step이 성공적으로 끝나야 함)
  • 캐시 스코프는 보통 OS/아키텍처/브랜치/키 등의 조합 영향을 받음(키 설계가 사실상 스코프를 결정)

기본 예시 (Node.js)

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

이제부터는 “왜 위처럼 했는데도 MISS가 나나?”를 7가지로 쪼개서 해결합니다.

1) key가 너무 자주 바뀐다 (hashFiles 범위 과다/불안정)

가장 흔한 원인입니다. hashFiles('**/package-lock.json')처럼 범위를 넓히거나, lockfile이 아닌 파일(예: package.json, pom.xml만 해시)로 키를 만들면 사소한 변경에도 key가 매번 달라져 캐시가 사실상 무력화됩니다.

증상

  • 로그에 매번 새로운 key가 출력
  • PR마다/커밋마다 캐시가 새로 생성

해결

  • **진짜 의존성 그래프를 대표하는 파일(락파일)**만 해시
  • 모노레포는 패키지별로 분리 key 또는 “워크스페이스 단위”로 안정화
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
# (Yarn Berry라면 yarn.lock, pnpm이라면 pnpm-lock.yaml)

모노레포 예:

key: ${{ runner.os }}-web-${{ hashFiles('apps/web/package-lock.json') }}

2) restore-keys가 없거나 prefix 설계가 잘못됐다

완전 일치 key가 없으면 restore-keys로 “가장 가까운 캐시”를 당겨와야 하는데, restore-keys가 없거나 너무 구체적이면 MISS가 그대로 납니다.

증상

  • key가 조금만 달라도 항상 MISS
  • 의존성 변경이 잦은 브랜치에서 체감 성능이 매우 나쁨

해결

  • restore-keys를 prefix 기반으로 두고, 단계적으로 넓혀 복원
- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-
      ${{ runner.os }}-

이렇게 하면 lockfile이 바뀌어도 같은 OS의 이전 npm 캐시를 가져와 다운로드를 줄일 수 있습니다.

3) path를 잘못 잡았다 (실제로 캐시할 디렉터리가 없다)

캐시는 “지정한 경로”를 압축해 저장합니다. 그런데 그 경로가 실제로 존재하지 않거나(설치 이전) 다른 폴더에 생성되면, 저장할 게 없어 캐시가 의미가 없어집니다.

흔한 실수

  • Node: node_modules를 캐시하면서 npm ci를 쓰는 경우(재현성은 좋지만 node_modules 캐시는 오히려 충돌/비효율)
  • Java/Gradle: ~/.gradle/caches 대신 프로젝트 내부 .gradle만 캐시
  • Python/pip: ~/.cache/pip 대신 venv 자체를 캐시(플랫폼/파이썬 버전 영향 큼)

해결: “다운로드 캐시”를 우선

Node는 ~/.npm, pnpm은 ~/.pnpm-store, Gradle은 ~/.gradle/caches처럼 패키지 다운로드 캐시가 안정적입니다.

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

- run: npm ci

Gradle 예시:

- 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

4) 캐시 저장 시점에 job이 실패한다 (저장 자체가 안 됨)

actions/cache는 복원은 job 초반에 하지만, **저장은 job 끝(성공 시)**에 수행됩니다. 테스트 실패/빌드 실패가 잦으면 “항상 복원 MISS”처럼 보일 수 있습니다. (첫 성공 실행이 나오기 전까지는 저장된 캐시가 없으니까요.)

증상

  • 캐시 복원 step은 항상 “Cache not found”
  • job이 중간에 실패해서 끝까지 못 감

해결

  • 캐시 생성이 필요한 초기 구간(의존성 설치)은 가능한 한 빨리 성공시키기
  • 경우에 따라 테스트 실패와 무관하게 캐시를 저장하고 싶다면(권장되진 않음) workflow를 분리하거나, 최소한 “의존성 준비 job”을 분리해 안정적으로 성공시키기

예: 의존성 준비를 별도 job으로 분리(아티팩트/캐시 전략 혼합)

jobs:
  deps:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
          restore-keys: ${{ runner.os }}-npm-
      - run: npm ci

  test:
    needs: deps
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
          restore-keys: ${{ runner.os }}-npm-
      - run: npm test

5) OS/아키텍처/런타임 버전이 달라서 캐시가 분리된다

캐시는 바이너리/네이티브 모듈 영향을 크게 받습니다. runner.os만 키에 넣었더라도, 실제로는 Node/Python/Java 버전이 달라지면 캐시 재사용성이 떨어집니다. 반대로 버전을 키에 넣지 않으면 “복원은 되는데 빌드가 깨지는” 문제가 생길 수 있습니다.

증상

  • ubuntu-latest가 업데이트된 후 갑자기 MISS가 늘어남
  • Node 18 → 20 변경 후 캐시 충돌/재빌드

해결

  • key에 런타임 버전을 포함해 의도적으로 캐시를 분리
- uses: actions/setup-node@v4
  with:
    node-version: '20'

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

Python 예시:

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'

- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-py312-pip-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-py312-pip-

6) 브랜치/PR 스코프 차이로 “다른 캐시처럼” 보인다

팀에서 자주 겪는 혼란: main에서 캐시를 잘 만들어놨는데 PR에서는 계속 MISS가 난다.

이건 실제로는 “캐시 공유 범위”와 “키 설계”의 결과입니다. 보안/격리 관점에서 PR(특히 fork)과 기본 브랜치의 캐시는 동일하게 취급되지 않거나, 접근이 제한되는 경우가 있습니다. 또한 키에 ${{ github.ref }} 같은 값을 넣으면 브랜치마다 캐시가 갈라집니다.

증상

  • main에서는 HIT, feature 브랜치에서는 MISS
  • PR from fork에서만 MISS

해결

  • 브랜치명을 key에 넣는 것을 신중히 결정(정말 필요할 때만)
  • PR에서도 재사용하고 싶다면 restore-keys를 브랜치 독립 prefix로 제공
key: ${{ runner.os }}-npm-${{ github.ref_name }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
  ${{ runner.os }}-npm-${{ github.ref_name }}-
  ${{ runner.os }}-npm-

브랜치별 격리가 필요 없다면(대부분 다운로드 캐시는 격리 불필요):

key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-

7) 캐시 대상이 “동시에 갱신”되며 레이스가 난다 (matrix/병렬 실행)

matrix로 Node 18/20, OS별(ubuntu/windows/macos)로 동시에 돌리면 캐시 키가 겹치거나(버전/OS를 키에 안 넣은 경우), 같은 키를 여러 job이 동시에 저장하려고 하면서 비효율이 생깁니다. 어떤 job은 저장에 실패하거나(이미 저장됨), 어떤 job은 매번 MISS처럼 느껴질 수 있습니다.

증상

  • 같은 워크플로 실행에서 job마다 캐시 로그가 제각각
  • “이미 존재하는 캐시 키” 류의 메시지(또는 저장이 스킵)

해결

  • key에 matrix 변수를 포함해 충돌 방지
  • 공용으로 쓰고 싶다면 restore-keys로 공유하고, 저장은 대표 job만 하도록 설계
strategy:
  matrix:
    node: [18, 20]

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

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

대표 job만 저장하고 나머지는 복원만 하려면(패턴):

- name: Cache (save only on node 20)
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: ${{ runner.os }}-npm-
  if: ${{ matrix.node == 20 }}

MISS를 빠르게 좁히는 체크리스트

아래 6가지만 로그로 확인해도 원인의 80%는 잡힙니다.

  1. 실제 key 문자열이 매번 바뀌는가?
  2. restore-keys가 존재하며 prefix가 과도하게 구체적이지 않은가?
  3. path의존성 설치 이후 실제로 생성되는 경로인가?
  4. job이 끝까지 성공해 저장이 수행되는가?
  5. OS/런타임 버전 변경이 있었는가? (특히 ubuntu-latest)
  6. PR/fork/브랜치 스코프 차이로 캐시가 분리되는가?

실전 권장 템플릿 (Node/Gradle 2종)

Node (npm) 권장 템플릿

name: ci
on:
  push:
  pull_request:

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

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

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

      - run: npm ci
      - run: npm test

Gradle 권장 템플릿

name: ci
on:
  push:
  pull_request:

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

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'

      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-java21-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-java21-gradle-
            ${{ runner.os }}-gradle-

      - run: ./gradlew test --no-daemon

마무리

GitHub Actions 캐시 MISS는 대부분 “키가 너무 자주 바뀜”, “restore-keys 부재”, “path 오지정”에서 끝납니다. 그 다음이 런타임/OS 변화, 브랜치 스코프, 병렬 실행 레이스입니다.

캐시를 잘 쓰는 핵심은 (1) 안정적인 key, (2) 복원 폭을 넓히는 restore-keys, (3) 다운로드 캐시 중심의 path 선택입니다.

워크플로 인증/권한 쪽에서 예외가 발생해 캐시 저장 단계까지 못 가는 경우도 있으니, 배포 파이프라인에서 OIDC를 쓴다면 함께 점검해보세요: GitHub Actions OIDC로 AWS 키 없이 배포하기