Published on

GitHub Actions 캐시 미적중? 키 설계 7원칙

Authors

서로 다른 PR인데 매번 Cache not found for input keys가 뜨거나, 반대로 캐시는 맞는데 빌드가 깨지는 경험은 대부분 key 설계에서 시작합니다. GitHub Actions의 actions/cache는 단순히 “폴더를 저장했다가 다음 실행에 꺼내 쓰는 기능”이 아니라, 키로 식별되는 스냅샷을 재사용하는 시스템입니다. 즉, 캐시 적중률과 안전성은 키의 품질에 의해 결정됩니다.

이 글에서는 캐시 미적중을 줄이면서도 “오염된 캐시”로 인한 간헐적 실패를 막는 키 설계 7원칙을 정리합니다. Node.js, Gradle, Docker 레이어 캐시 등 어디에든 적용 가능한 패턴 위주로 설명합니다.

관련해서 CI에서 환경 차이로 런타임 에러가 터질 때는 Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드도 함께 참고하면 좋습니다. 캐시가 “예전 의존성 상태”를 되살리면서 문제를 키우는 경우가 흔합니다.

GitHub Actions 캐시 동작 핵심 3가지

1) 캐시는 key로만 저장되고 조회된다

key가 완전히 동일해야 적중합니다. 다르면 무조건 미적중이며, 대신 restore-keys가 있으면 접두사(prefix) 매칭으로 가장 가까운 캐시를 찾습니다.

2) 같은 키는 덮어쓰지 않는다

한 번 저장된 키는 동일 키로 다시 저장되지 않습니다. 그래서 키가 너무 고정적이면 “오래된 캐시가 영원히 재사용”되는 문제가 생깁니다.

3) 캐시는 브랜치/OS/경로/권한과 엮여서 실패한다

특히 runner.os가 바뀌거나, lockfile이 바뀌거나, 캐시 경로에 절대 경로/심볼릭 링크가 섞이면 적중률이 확 떨어집니다.

원칙 1. 키는 “재현 가능한 입력”만으로 만든다

키에 “실행 시점마다 바뀌는 값”을 넣으면 적중률은 0에 수렴합니다. 대표적인 금지 요소는 다음입니다.

  • github.run_id, github.run_number 같은 실행 고유값
  • 타임스탬프
  • PR 번호 자체

대신 빌드 결과에 영향을 주는 입력으로만 키를 구성합니다.

  • OS, 언어 런타임 버전
  • lockfile 해시
  • 빌드 툴 버전

예시(Node + pnpm):

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

여기서 hashFiles는 파일 내용이 바뀌지 않으면 동일 해시를 내므로, “재현 가능한 입력”에 해당합니다.

원칙 2. 키는 “안전한 최소 단위”로 쪼갠다

캐시를 크게 한 덩어리로 묶으면 적중은 잘 되지만, 오염될 때 피해가 커집니다. 반대로 너무 잘게 쪼개면 적중률이 떨어지고 관리가 어려워집니다.

실전에서는 다음처럼 의존성 캐시빌드 산출물 캐시를 분리하는 편이 안정적입니다.

  • 의존성: npm/pnpm/yarn store, Gradle cache, Maven repository
  • 산출물: Next.js .next/cache, TurboRepo .turbo, Vite cache 등

예시(Next.js 빌드 캐시):

- name: Cache Next.js build cache
  uses: actions/cache@v4
  with:
    path: |
      .next/cache
    key: next-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/yarn.lock', '**/pnpm-lock.yaml') }}
    restore-keys: |
      next-cache-${{ runner.os }}-

.next/cache는 의존성 자체가 아니라 “컴파일 캐시” 성격이라 lockfile 기반 키가 잘 맞습니다. Next.js 성능 최적화 관점은 Next.js 이미지 LCP 개선 - next/image 최적화도 같이 보면 맥락이 이어집니다.

원칙 3. restore-keys는 “접두사 전략”으로 설계한다

restore-keys는 캐시 미적중을 줄이는 강력한 도구지만, 잘못 쓰면 “오래된 캐시”를 끌어와 빌드가 깨집니다. 핵심은 점진적으로 범위를 넓히는 접두사입니다.

좋은 패턴:

  1. 가장 구체적인 키로 시도
  2. lockfile 해시를 제거한 접두사로 fallback
  3. OS 정도만 남긴 가장 넓은 fallback

예시(Gradle):

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

이렇게 하면 “정확히 같은 입력”이면 즉시 적중하고, 입력이 조금 바뀌었을 때도 최소한의 다운로드로 복구할 수 있습니다.

원칙 4. OS, 아키텍처, 런타임 버전은 반드시 키에 포함한다

캐시 미적중의 1순위 원인은 환경 차이입니다. 특히 네이티브 바이너리를 포함하는 의존성은 OS가 다르면 사실상 재사용 불가입니다.

키에 최소한 다음을 포함하세요.

  • ${{ runner.os }}
  • 언어 런타임 버전(예: Node, Java, Python)
  • 필요하면 아키텍처(예: x64, arm64)

아키텍처 값은 기본 컨텍스트에 바로 없을 수 있으니, uname -m을 출력해 outputs로 전달하는 방식이 실무적입니다.

- name: Detect arch
  id: arch
  run: echo "arch=$(uname -m)" >> $GITHUB_OUTPUT

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

원칙 5. “버전 업그레이드 스위치”를 키에 심어라

캐시 구조를 바꾸거나, 캐시 경로를 바꾸거나, 툴 버전을 크게 올릴 때는 기존 캐시가 오히려 독이 됩니다. 이때 가장 간단하고 확실한 방법이 수동 버전 토큰입니다.

env:
  CACHE_VERSION: v3

- uses: actions/cache@v4
  with:
    path: ~/.pnpm-store
    key: pnpm-${{ env.CACHE_VERSION }}-${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('**/pnpm-lock.yaml') }}

CACHE_VERSION만 올리면 전체 캐시를 안전하게 리셋할 수 있습니다. “왜 갑자기 CI만 실패하지?” 같은 상황에서 매우 유용합니다.

원칙 6. hashFiles 범위를 “의도적으로 좁혀” 변동성을 통제한다

hashFiles('**/*') 같은 식으로 잡으면, README 변경에도 캐시가 무효화됩니다. 반대로 너무 좁히면 의존성이 바뀌었는데 캐시가 유지되어 빌드가 깨질 수 있습니다.

권장 기준:

  • 의존성 캐시: lockfile만 해시
  • 빌드 캐시: lockfile + 빌드 설정 파일 일부
  • 테스트 캐시: 테스트 러너 설정, 스냅샷 등 포함

예시(Node 의존성 캐시):

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

예시(빌드 도구 설정도 포함):

key: build-${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('**/pnpm-lock.yaml', 'next.config.js', 'tsconfig.json') }}

핵심은 “캐시가 영향을 받는 입력만 포함”하는 것입니다.

원칙 7. 캐시 적중/미적중을 로그와 메트릭으로 관찰 가능하게 만든다

캐시는 “되는 것 같긴 한데 체감이 없다”로 끝나기 쉽습니다. actions/cachesteps.XXX.outputs.cache-hit를 제공하므로, 이를 이용해 로그를 남기고 조건 분기를 걸 수 있습니다.

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

- name: Print cache result
  run: |
    echo "cache-hit=${{ steps.cache-deps.outputs.cache-hit }}"

- name: Install dependencies
  if: steps.cache-deps.outputs.cache-hit != 'true'
  run: npm ci

주의할 점은 “캐시가 적중해도 설치 단계가 완전히 불필요한가”입니다. npm/pnpm은 캐시가 있어도 npm cipnpm install이 lockfile 상태를 맞추는 과정이 필요합니다. 캐시는 다운로드를 줄여주는 수단이지, 설치 자체를 생략하는 만능 스위치가 아닙니다.

자주 터지는 패턴별 처방

PR에서는 미적중, main에서는 적중

  • PR에서만 lockfile이 자주 바뀌거나
  • 키에 브랜치명이 들어가 있거나
  • restore-keys가 너무 구체적이라 fallback이 안 되는 경우가 많습니다.

해결: 브랜치명 제거, restore-keys 접두사 확장.

적중은 되는데 빌드가 가끔 실패

  • 캐시 경로에 산출물이 섞여 “오염된 상태”가 재사용될 수 있습니다.

해결: 의존성 캐시와 산출물 캐시 분리, CACHE_VERSION 토큰 도입.

OS 바꾸면 항상 미적중

  • 정상입니다. OS별로 별도 캐시를 가져가야 합니다.

해결: ${{ runner.os }} 포함, 가능하면 matrix로 OS를 분리 운영.

결론: 좋은 키는 “적중률”과 “안전성”을 동시에 올린다

정리하면, GitHub Actions 캐시 키는 다음 7원칙을 따르면 대부분의 미적중과 불안정성을 줄일 수 있습니다.

  1. 재현 가능한 입력만 사용
  2. 안전한 최소 단위로 분리
  3. restore-keys는 접두사로 단계적 확장
  4. OS, 아키텍처, 런타임 버전 포함
  5. 수동 버전 토큰으로 강제 무효화 스위치 마련
  6. hashFiles 범위를 의도적으로 좁혀 변동성 통제
  7. cache-hit를 로그/조건에 연결해 관찰 가능하게 운영

캐시가 잘 설계되면 CI 시간은 줄고, “왜 이번엔 실패하지?” 같은 변동성도 함께 줄어듭니다. 반대로 키가 흔들리면 캐시는 저장소만 차지하는 장식이 됩니다. 지금 워크플로에서 keyrestore-keys부터 점검해 보세요.