Published on

GitHub Actions 캐시 미적중 원인 - key·restore-keys·락파일

Authors

서버리스 CI처럼 보이는 GitHub Actions도 결국은 에페메럴(일회성) 러너 위에서 매번 새로 실행됩니다. 그래서 의존성 설치 시간을 줄이려면 actions/cache가 사실상 필수인데, 막상 적용해보면 로그에 Cache not found for input keys가 반복되며 캐시 미적중(cache miss) 이 빈번하게 발생합니다.

이 글에서는 캐시가 안 맞는 이유를 “key 설계”, “restore-keys 동작 방식”, “락파일(lockfile) 해시”, “경로/OS/권한/동시성” 관점으로 쪼개서 설명하고, 언어/패키지 매니저별로 실전에서 덜 깨지는 구성 예제를 제공합니다.

> CI에서 원인 추적은 결국 로그와 체크리스트 싸움입니다. 비슷한 방식으로 장애 원인을 빠르게 좁히는 접근은 EKS TLS handshake timeout 원인·해결 9가지 같은 글에서도 동일하게 유효합니다.

GitHub Actions 캐시는 무엇을 “키”로 찾나

actions/cache는 간단히 말해:

  1. key(정확히 일치해야 하는 키)로 캐시를 찾고
  2. 없으면 restore-keys(prefix 매칭)로 “가장 가까운” 캐시를 찾고
  3. 잡히면 해당 캐시를 path에 복원합니다.

그리고 job 끝에서(정확히는 해당 step 이후) key가 새로웠을 때만 저장(save)합니다. 즉, 아래 상황이 자주 발생합니다.

  • restore로는 캐시를 가져왔는데, key가 기존과 동일하면 저장이 생략됨
  • restore로는 가져왔지만 실제 설치 결과가 달라져도 key가 같으면 업데이트가 안 됨

이 구조를 이해하면 “왜 매번 미적중이지?”의 70%는 설명됩니다.

1) key 설계가 잘못되어 매번 달라지는 경우

(1) 커밋 SHA/런 ID를 key에 넣어버림

아래처럼 github.shagithub.run_id를 key에 포함하면 매 실행마다 key가 바뀌어 100% 미적중합니다.

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ github.sha }}

해결: “의존성이 바뀔 때만” 키가 바뀌도록 만들어야 합니다. 보통은 락파일 해시가 정답입니다.

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

(2) 너무 많은 변수를 key에 포함

Node 버전/패키지 매니저 버전/워크스페이스 경로/아키텍처 등을 전부 key에 넣으면, 작은 변화에도 캐시가 갈라져 hit율이 급감합니다.

권장 방향:

  • 반드시 포함: OS(또는 runner image), 락파일 해시
  • 상황에 따라 포함: 언어 런타임 주요 버전(예: Node 18/20), 모노레포라면 패키지 매니저 종류
  • 가급적 제외: 커밋/브랜치, run id, 타임스탬프

(3) hashFiles 패턴이 실제 락파일을 못 잡는 경우

모노레포에서 락파일이 루트에만 있는데 hashFiles('**/pnpm-lock.yaml')는 잡히지만, 반대로 워크스페이스별 락파일이 있는데 루트만 해시하는 실수를 하기도 합니다.

디버깅 팁: 해시값을 로그로 출력하세요.

- name: Print lockfile hash
  run: |
    echo "lock-hash=${{ hashFiles('**/pnpm-lock.yaml') }}"

해시가 빈 문자열이면 패턴이 매칭되지 않은 것입니다.

2) restore-keys를 오해해서 “있는데도 못 찾는” 경우

restore-keys는 prefix 매칭이다

restore-keys는 “유사 키”를 찾는 기능이지만, 완전한 정규식/글롭이 아니라 prefix 입니다.

예를 들어:

  • key: npm-Linux-<lockhash>
  • restore-keys: npm-Linux-

이면, npm-Linux-로 시작하는 캐시 중 가장 최신(정확히는 GitHub가 선택한) 것을 가져올 수 있습니다.

restore-keys를 너무 구체적으로 써서 결국 못 찾는 패턴

아래처럼 restore-keys도 해시를 포함하면, 사실상 key와 다를 바 없어집니다.

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

해결: “점진적으로 넓어지도록” 작성합니다.

restore-keys: |
  npm-${{ runner.os }}-
  npm-

이렇게 하면 OS가 같으면 우선 그 캐시를, 없으면 더 넓게 잡습니다.

restore hit인데도 설치가 느린 이유

restore로 캐시를 받아도, 실제로는 다음 때문에 체감이 안 날 수 있습니다.

  • 캐시한 경로가 설치에 결정적이지 않음(예: npm은 ~/.npm이 효과적이지만, 어떤 도구는 빌드 산출물 캐시가 더 중요)
  • postinstall이 무거운 패키지(예: native addon)가 많아, “다운로드”만 줄고 “빌드”는 그대로
  • node_modules 자체를 캐시했는데, OS/아키텍처/Node ABI가 바뀌어 재빌드 발생

3) 락파일이 캐시 키를 흔드는 대표 원인

캐시 키에 락파일 해시를 쓰는 건 정석이지만, 락파일은 생각보다 자주 변합니다.

(1) package-lock.json / yarn.lock / pnpm-lock.yaml이 자동으로 재작성됨

  • npm 버전이 바뀌면 package-lock.json 포맷/정렬이 달라질 수 있음
  • Yarn classic(1.x) ↔ Yarn berry(3.x+) 전환 시 lock 구조가 크게 변경
  • pnpm 버전 업으로 lockfileVersion이 바뀌면 전체 해시가 변경

대응:

  • CI에서 사용하는 패키지 매니저 버전을 고정
  • actions/setup-nodecache 기능(내장 캐시)을 쓰면 기본 베스트 프랙티스를 따라가기 쉬움
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'
    cache-dependency-path: '**/package-lock.json'

(2) 모노레포에서 “관계없는 패키지 변경”도 전체 락파일을 바꿈

워크스페이스 하나만 바뀌어도 루트 lock이 변해 전체 캐시가 무효화됩니다. 이때는 전략을 선택해야 합니다.

  • 정확성 우선: 루트 락파일 해시로 통합 캐시(단순/안전)
  • hit율 우선: 패키지별로 분리하거나, restore-keys로 완화

예: pnpm에서 store를 캐시하고 restore-keys로 완화

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

4) path가 잘못되어 “저장은 했는데 복원이 안 되는” 경우

(1) 작업 디렉터리/툴이 실제로 쓰는 경로와 다름

자주 하는 실수:

  • npm 캐시는 ~/.npm인데 node_modules만 캐시
  • pnpm은 ~/.pnpm-store(또는 설정된 store-dir)인데 다른 경로 캐시
  • pip는 ~/.cache/pip인데 venv 디렉터리만 캐시

캐시 대상은 “재다운로드/재해결” 비용이 큰 디렉터리를 우선으로 잡는 게 일반적으로 안정적입니다.

(2) ~ 확장/권한 문제

러너에서 ~는 보통 /home/runner이지만, 컨테이너 job이나 사용자 변경이 있으면 홈이 달라질 수 있습니다. 이 경우 cache는 복원되었는데 실제 툴은 다른 홈을 보고 있어 효과가 없습니다.

디버깅:

- name: Debug paths
  run: |
    echo "HOME=$HOME"
    pwd
    ls -la ~

5) OS/아키텍처가 달라서 캐시가 분리되는 경우

runner.os가 다른데 같은 key prefix를 쓰면, 운 좋게 restore-keys가 잡아도 바이너리/네이티브 모듈이 섞여 문제를 만들 수 있습니다. 반대로 OS를 key에 넣지 않으면 더 큰 문제가 납니다.

권장: 최소한 OS는 반드시 포함.

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

ARM64 러너(예: macOS arm64)까지 섞이면 runner.arch도 고려하세요.

6) 동시성/브랜치 전략 때문에 “경쟁적으로 저장”이 꼬이는 경우

GitHub 캐시는 불변(immutable) 에 가깝습니다. 같은 key로는 다시 저장할 수 없고, 먼저 저장한 캐시가 승자가 됩니다. PR이 동시에 여러 개 돌면:

  • A job이 오래 걸려 늦게 끝났는데, 이미 B job이 같은 key로 저장 완료
  • A job은 저장 단계에서 “이미 존재”로 스킵

이 자체는 정상 동작이지만, 기대와 다르면 key 설계를 바꿔야 합니다.

전략 예:

  • 기본은 lockfile hash로 고정(정확성)
  • 브랜치별 캐시를 원하면 prefix에 github.ref_name을 넣되, restore-keys로 main 캐시를 fallback
key: npm-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
  npm-${{ runner.os }}-main-
  npm-${{ runner.os }}-

7) 실전 권장 패턴: Node(npm/pnpm/yarn) 예제

아래는 “미적중 원인”을 최소화하는 형태로, key는 lockfile 기반 + restore-keys는 넓게, 캐시 경로는 패키지 매니저 캐시를 대상으로 합니다.

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

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

      - run: npm ci
      - run: npm test

pnpm (store 캐시)

- uses: pnpm/action-setup@v4
  with:
    version: 9

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

- run: pnpm install --frozen-lockfile

yarn (Berry 기준, cache 폴더가 repo에 있을 수도)

Yarn Berry는 .yarn/cache를 repo에 커밋하는 전략도 있어 CI 캐시 의존도가 낮아질 수 있습니다. 커밋하지 않는다면 아래처럼 캐시 디렉터리를 명시하세요.

- uses: actions/cache@v4
  with:
    path: |
      .yarn/cache
      .yarn/unplugged
      .pnp.cjs
    key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
    restore-keys: |
      yarn-${{ runner.os }}-
      yarn-

- run: yarn install --immutable

8) 체크리스트: 캐시 미적중을 10분 안에 좁히기

  1. 로그에서 Cache key: / restore keys: / Cache not found 메시지 확인
  2. hashFiles(...)가 빈 값인지 출력해서 확인
  3. key에 커밋/런ID 같은 변동값이 들어갔는지 확인
  4. restore-keys가 prefix 형태로 “점진적으로 넓어지는지” 확인
  5. 캐시 path가 실제 툴이 쓰는 경로인지 확인(HOME 포함)
  6. OS/arch/런타임 버전 변경이 있었는지 확인
  7. 동시에 여러 workflow가 같은 key를 만들고 있는지 확인

원인 추적을 이렇게 단계화해두면, CI 캐시 문제도 다른 운영 이슈처럼 빠르게 수렴합니다. 예를 들어 API 호출에서 415/422 같은 에러를 만났을 때도 입력(헤더/바디/스키마)을 분해해 체크리스트로 좁히는 방식이 유사합니다: OpenAI Responses API 415 Unsupported Media Type 해결.

결론

GitHub Actions 캐시 미적중의 대부분은 “캐시가 없다”가 아니라 내가 만든 key가 캐시를 못 찾게 설계되어 있거나, 락파일/환경 변화로 key가 자주 바뀌거나, restore-keys가 기대만큼 fallback을 못 하거나, path가 실제로 쓰이는 위치가 아닌 경우입니다.

  • key는 락파일 해시 중심으로 단순하게
  • restore-keys는 prefix 기반으로 넓게
  • 캐시 path는 패키지 매니저가 실제로 쓰는 캐시 디렉터리로
  • 런타임/패키지 매니저 버전은 가능한 고정

이 4가지만 지켜도 hit율과 CI 속도가 눈에 띄게 안정화됩니다.