Published on

GitHub Actions 캐시가 안 먹을 때 - cache-hit 0% 정리

Authors

서로 같은 워크플로를 돌리는데도 cache-hit 이 계속 false 로 찍히고, 매번 의존성 설치가 처음부터 다시 되는 경우가 있습니다. 특히 Node.js npm cipnpm install 같은 단계가 길어지면 CI 시간이 체감으로 폭증합니다.

이 글은 GitHub Actions 캐시가 “왜 안 먹는지”를 cache-hit 0% 관점에서 원인별로 분해하고, 바로 적용 가능한 해결 패턴(키 설계, 경로 검증, restore-keys 전략, 디버깅 방법)을 코드와 함께 정리합니다.

참고: 인증/권한 이슈로 워크플로 자체가 흔들리는 경우는 캐시보다 먼저 잡아야 합니다. OIDC 기반 assume-role 문제가 섞여 있다면 GitHub Actions OIDC assume-role 실패 원인별 해결 도 함께 확인하세요.

1) 먼저 확인할 것: actions/cache 가 “정상 동작” 하고 있는지

캐시가 안 먹는 문제는 크게 두 종류입니다.

  1. 저장은 되는데 다음 실행에서 복원이 안 됨(키 불일치, 스코프 문제)
  2. 저장 자체가 안 됨(경로 문제, 권한/용량/타이밍 문제)

가장 먼저 워크플로 로그에서 아래를 확인하세요.

  • Cache not found for input keys 가 뜨는지
  • Cache restored from key: 가 뜨는지
  • Job 끝에서 Cache saved successfully 가 뜨는지

actions/cache 는 기본적으로 “복원 단계” 와 “저장 단계” 가 분리되어 있고, 저장은 대개 Job 종료 시점에 수행됩니다. 중간에 Job 이 실패하거나 강제 종료되면 저장이 되지 않습니다.

2) 원인 1: 캐시 키가 매번 바뀐다(가장 흔함)

2-1. 락파일이 계속 변한다

키에 hashFiles('**/package-lock.json') 를 넣어두었는데, CI 과정에서 락파일이 생성/수정되면 매번 키가 바뀝니다.

  • npm install 은 락파일을 갱신할 수 있음
  • npm ci 는 락파일을 “그대로” 사용(권장)
  • pnpm 도 lockfile 버전이나 설정에 따라 변동 가능

해결

  • CI에서는 가능한 npm ci 를 사용
  • 락파일을 생성하는 단계가 캐시 복원 이후에 실행되도록 정렬

예시:

- uses: actions/checkout@v4

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

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

- run: npm ci

2-2. 키에 너무 많은 변수를 섞는다

아래처럼 커밋 SHA, run id, timestamp 등을 키에 넣으면 캐시는 사실상 매번 미스가 납니다.

  • ${{ github.sha }}
  • ${{ github.run_id }}
  • ${{ github.run_number }}

해결

  • 키는 “의존성 그래프가 바뀌는 조건” 에만 반응하도록 최소화
  • 브랜치별 격리가 필요하면 github.ref_name 정도까지만 고려

권장 패턴:

key: ${{ runner.os }}-node20-npm-${{ hashFiles('**/package-lock.json') }}

3) 원인 2: path 가 잘못되었거나, 실제로는 비어 있다

캐시는 “경로에 있는 파일” 을 저장합니다. 경로가 틀리면 저장할 게 없고, 복원도 당연히 안 됩니다.

자주 틀리는 케이스:

  • node_modules 를 캐시하려고 했는데 실제 설치는 다른 디렉터리에 됨(모노레포)
  • ~/.npm 대신 ~/.cache/npm 를 써야 하는 환경
  • Windows 런너에서 ~ 확장이 기대대로 안 됨

3-1. 경로를 출력해서 검증하기

캐시 단계 직전에 실제 경로를 찍어보면 원인이 빨리 드러납니다.

- name: Debug cache paths
  shell: bash
  run: |
    echo "HOME=$HOME"
    ls -la "$HOME" || true
    ls -la "$HOME/.npm" || true
    du -sh "$HOME/.npm" || true

3-2. Node 의존성 캐시는 setup-node 내장 캐시도 고려

actions/setup-node@v4cache: 'npm' 같은 내장 캐시를 제공합니다. 직접 actions/cache 를 쓰는 것보다 실수가 줄어드는 경우가 많습니다.

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
    cache-dependency-path: package-lock.json

- run: npm ci

모노레포라면:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
    cache-dependency-path: |
      package-lock.json
      apps/web/package-lock.json
      packages/shared/package-lock.json

4) 원인 3: 브랜치/PR 스코프 때문에 캐시가 “보이지” 않는다

GitHub Actions 캐시는 기본적으로 키가 같아도 “어떤 이벤트에서 생성되었는지” 에 따라 접근이 제한될 수 있습니다.

대표적으로:

  • pull_request 이벤트에서 fork PR 은 캐시 저장/복원이 제한될 수 있음
  • 기본 브랜치에서 만든 캐시가 PR 브랜치에서 기대대로 복원되지 않는다고 느끼는 케이스

해결 전략

  • PR 에서는 restore-keys 로 기본 브랜치 캐시를 “부분 매칭” 하게 설계
  • fork PR 은 보안상 제약이 있으므로 캐시에 과도하게 의존하지 않도록 설계

예시:

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

restore-keys 는 완전 동일 키가 없어도 prefix 매칭으로 “가장 가까운 캐시” 를 찾아옵니다. 의존성 설치가 조금 바뀌어도 체감 속도가 좋아지는 이유가 여기 있습니다.

5) 원인 4: Job 이 끝나기 전에 실패해서 저장이 안 된다

캐시는 Job 종료 시점에 업로드되는 구조라서, npm testbuild 가 실패하면 캐시 저장 단계까지 도달하지 못합니다.

해결

  • 의존성 캐시는 설치 직후 저장되는 게 아니라는 점을 이해하고, “첫 성공 run” 을 만들어야 함
  • 실패가 잦은 단계 전에도 캐시가 저장되게 하려면, 의존성 설치가 끝난 뒤에 캐시 저장이 수행되도록 워크플로를 단순화하거나, 실패 원인을 먼저 줄이기

실무적으로는 “캐시 미스가 원인이 아니라, 빌드 실패가 먼저” 인 경우가 많습니다. 시스템 전체가 불안정할 때의 접근법은 systemd 서비스 재시작 루프, 10분 디버깅 처럼 원인 분해 체크리스트 방식이 잘 통합니다.

6) 원인 5: OS, 아키텍처, 런너가 달라서 캐시가 분리된다

키에 ${{ runner.os }} 를 넣어두면 Linux, Windows, macOS 캐시가 분리됩니다. 의도한 분리라면 문제 없지만, 매트릭스 전략에서 아래 상황이 자주 발생합니다.

  • 로컬은 Linux, CI 는 macOS 로 바뀜
  • ubuntu-latest 가 업그레이드되며 환경이 바뀜
  • self-hosted 런너와 GitHub-hosted 런너를 섞음

해결

  • OS 별 분리가 필요 없다면 키에서 OS 를 제거(단, 바이너리 캐시라면 위험)
  • ubuntu-22.04 처럼 런너를 고정

예시:

runs-on: ubuntu-22.04

7) 원인 6: 캐시하려는 대상이 “재현 불가능” 하거나 “너무 크다”

7-1. node_modules 캐시는 종종 역효과

node_modules 는 파일 수가 많고, OS/Node 버전/네이티브 모듈에 민감해서 캐시 복원에 시간이 더 걸리거나 깨지기 쉽습니다.

권장:

  • ~/.npm (다운로드 캐시)
  • ~/.pnpm-store
  • ~/.cache/yarn

pnpm 예시:

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

- run: pnpm install --frozen-lockfile

7-2. 캐시 업로드/다운로드가 병목

캐시는 네트워크 전송이므로, 압축/해제 및 전송 시간이 커지면 “캐시가 있어도 느린” 상황이 됩니다. 특히 대형 모노레포에서 빌드 산출물까지 캐시하려다 보면 역전이 자주 발생합니다.

이때는 빌드 파이프라인을 캐시로만 해결하려 하지 말고, 빌드 자체를 줄이는 튜닝이 더 효과적일 수 있습니다. Next.js 빌드/서빙 성능 이슈가 섞여 있다면 Next.js 14 App Router TTFB 폭증 잡는 RSC 튜닝 같은 접근이 더 큰 시간을 절약합니다.

8) 원인 7: 모노레포에서 hashFiles 패턴이 엉뚱한 파일을 잡는다

hashFiles('**/package-lock.json') 는 리포 전체의 모든 락파일을 해시합니다. 모노레포에서 일부 패키지만 바뀌어도 전체 키가 바뀌어 캐시가 자주 무효화됩니다.

해결

  • 실제로 설치에 영향을 주는 락파일만 포함
  • 워크스페이스별로 Job 을 나눠 키를 분리

예시:

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

9) 원인 8: 워크플로 파일 변경으로 키가 달라진다(간접 요인)

직접 키에 워크플로 경로를 넣지 않았는데도, 아래처럼 “설치되는 툴 버전” 이 바뀌면 캐시가 사실상 무효가 될 수 있습니다.

  • Node 버전 변경
  • pnpm 버전 변경
  • Python 버전 변경

해결

  • 키에 런타임 버전을 명시적으로 포함
key: ${{ runner.os }}-node20-npm-${{ hashFiles('**/package-lock.json') }}

매트릭스라면:

strategy:
  matrix:
    node: [18, 20]

# ...
key: ${{ runner.os }}-node${{ matrix.node }}-npm-${{ hashFiles('**/package-lock.json') }}

10) 실전 디버깅 체크리스트(로그만으로 끝내기)

아래 순서로 보면 대부분 10분 안에 결론이 납니다.

  1. 캐시 복원 로그에 Cache restored from key 가 있는가
  2. 없다면 key 가 매번 바뀌는지 확인(특히 락파일 해시)
  3. restore-keys 를 넣어도 복원이 안 되는가
  4. path 가 실제로 존재하고 용량이 있는지 ls, du 로 확인
  5. Job 이 성공해서 저장까지 갔는지 Cache saved successfully 확인
  6. 이벤트가 pull_request 이고 fork 인지 확인(스코프/권한 제약)
  7. OS/Node/패키지매니저 버전이 바뀌지 않았는지 확인

디버그용으로 키 재현 값을 출력하면 훨씬 빠릅니다.

- name: Print cache key inputs
  shell: bash
  run: |
    echo "ref=${GITHUB_REF}"
    echo "sha=${GITHUB_SHA}"
    echo "os=${RUNNER_OS}"
    echo "lockfile hash follows from actions/cache logs"

11) 추천 워크플로 템플릿(Node.js 기준)

npm 기준으로 “안 깨지고, 캐시 히트율이 높은” 형태입니다.

name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-22.04

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: package-lock.json

      - run: npm ci
      - run: npm test

actions/cache 를 직접 써야 한다면:

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

- run: npm ci

마무리: cache-hit 0% 는 “키와 경로” 문제로 수렴한다

GitHub Actions 캐시가 안 먹는 문제는 복잡해 보이지만, 실제로는 다음 두 축으로 대부분 설명됩니다.

  • 키가 안정적인가(락파일, 버전, 불필요 변수)
  • 저장할 경로가 맞는가(존재, 용량, OS 차이)

여기에 PR 스코프 제약과 Job 실패로 인한 저장 누락까지 점검하면, cache-hit 를 의미 있게 끌어올릴 수 있습니다. 캐시가 정상화되면 CI 시간은 체감상 가장 먼저 개선되는 영역이니, 위 체크리스트대로 하나씩 제거해 보세요.