Published on

GitHub Actions 캐시가 안 먹을 때 속도 3배 올린 실전

Authors

서론

GitHub Actions를 쓰다 보면 “분명 cache를 걸었는데 매번 설치를 다시 한다”는 상황을 한 번쯤 겪습니다. 캐시가 안 먹으면 CI 시간은 선형으로 늘고, PR 피드백 루프는 길어지며, 팀 전체 생산성까지 같이 떨어집니다.

이 글은 캐시 hit율이 0%에 가깝던 파이프라인을 디버깅해서 체감 3배(예: 1215분 → 45분)까지 줄였던 방법을, 재현 가능한 체크리스트와 함께 정리한 실전 기록입니다. 단순히 actions/cache@v4 예제 붙여넣기가 아니라, “왜 miss가 나는지”를 로그로 확인하고 키/경로/전략을 바꾸는 과정에 초점을 둡니다.


캐시가 ‘안 먹는’ 증상부터 명확히 정의하기

캐시 문제는 대개 아래 3가지 중 하나입니다.

  1. 저장(restore) 자체가 안 됨: 항상 Cache not found for input keys만 뜸
  2. 복원은 되는데 효과가 없음: restore 로그는 있는데 설치 시간이 그대로
  3. 간헐적으로만 hit: 어떤 브랜치/이벤트에서는 hit, 어떤 경우엔 miss

먼저 워크플로 로그에서 actions/cache 출력이 어떻게 찍히는지 확인합니다.

  • miss 예시: Cache not found for input keys: ...
  • hit 예시: Cache restored from key: ...
  • 저장 예시(잡 끝날 때): Cache saved with key: ...

여기서 중요한 건 restore 단계가 성공해도 실제로 우리가 원하는 디렉터리가 복원됐는지는 별개의 문제라는 점입니다. (경로가 틀리면 “복원 성공”이어도 효과가 없습니다.)


1단계: 키(key) 설계부터 의심하기 (가장 흔한 원인)

흔한 실수 1) 키에 너무 많은 변수를 넣어 매번 바뀜

예를 들어 다음처럼 커밋 SHA를 키에 넣으면, 캐시는 사실상 매번 새로 만들어집니다.

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

이 경우 hit될 가능성은 거의 0입니다.

권장 패턴: lockfile 기반 + restore-keys

의존성 캐시는 lockfile 해시가 정답에 가깝습니다.

- name: Cache npm
  uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      node_modules
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-
  • key: lockfile이 바뀌지 않으면 동일 키
  • restore-keys: lockfile이 바뀌어도 OS 단위로 “가까운 캐시”를 가져옴(부분 hit)

모노레포라면 lockfile 경로를 더 엄격히

모노레포에서 **/package-lock.json는 예기치 않게 여러 파일이 매칭되어 해시가 흔들릴 수 있습니다. 실제로는 루트 lockfile만 쓰고 싶다면:

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

또는 패키지별로 캐시를 분리하려면:

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

2단계: path가 맞는지 ‘실제 파일’로 검증하기

캐시는 결국 “특정 경로의 파일을 tar로 묶어서 저장/복원”하는 동작입니다. 경로가 틀리면 아무리 키가 좋아도 효과가 없습니다.

디버깅용: 캐시 대상 디렉터리 존재/용량 출력

- name: Inspect cache paths
  run: |
    set -eux
    ls -la ~/.npm || true
    du -sh ~/.npm || true
    ls -la node_modules || true
    du -sh node_modules || true

여기서 자주 발견되는 문제:

  • node_modules가 실제로는 다른 경로(예: apps/web/node_modules)에 생김
  • pnpm/yarn을 쓰는데 npm 경로를 캐시하고 있음
  • 설치 전에 path를 찍어보면 디렉터리가 아예 없음(설치 후에만 생김)

패키지 매니저별 캐시 경로 정리

  • npm: ~/.npm
  • yarn classic: ~/.cache/yarn
  • yarn berry(PnP): .yarn/cache (프로젝트 내부)
  • pnpm: ~/.pnpm-store 또는 pnpm store path로 확인

pnpm은 특히 러너/버전에 따라 store 경로가 달라질 수 있어, 아래처럼 동적으로 구하는 게 안전합니다.

- name: Get pnpm store directory
  id: pnpm-cache
  run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT

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

3단계: 이벤트/브랜치/권한 때문에 저장이 막히는 케이스

“restore는 되는데 save가 안 된다” 혹은 “PR에서는 hit가 안 된다”면 권한/이벤트를 의심해야 합니다.

PR from fork에서 캐시 저장이 제한될 수 있음

외부 포크 PR은 보안상 토큰 권한이 제한됩니다. 이때는 캐시 저장이 실패하거나 기대대로 동작하지 않을 수 있습니다.

대응 전략:

  • 포크 PR에서는 캐시를 restore만 기대하고, save는 메인 브랜치 머지 후에만 수행
  • 혹은 pull_request_target 사용(단, 보안 위험이 커서 매우 신중해야 함)

permissions 설정이 너무 빡빡한 경우

일반적으로 actions/cache는 별도 권한이 크게 필요하진 않지만, 워크플로에서 전반적으로 권한을 최소화하다가 예상치 못한 문제가 생기는 경우가 있습니다. 의심되면 일단 아래 수준으로 맞춰 확인합니다.

permissions:
  contents: read

또한 엔터프라이즈/조직 정책으로 캐시 사용이 제한될 수도 있으니, 저장 실패 로그가 있다면 함께 확인해야 합니다.


4단계: 캐시 hit인데도 느리면 ‘압축/아카이브 비용’을 본다

캐시가 복원되더라도, 복원 자체가 오래 걸리면 전체 시간은 줄지 않습니다. 특히 node_modules처럼 파일 수가 많은 디렉터리는 tar 압축/해제가 병목이 됩니다.

실전 결론: node_modules 캐시보다 “패키지 매니저 캐시”가 이길 때가 많다

  • node_modules는 파일 수가 많아 아카이브 비용이 큼
  • 반면 ~/.npm, pnpm store는 다운로드 아티팩트 중심이라 상대적으로 효율적

그래서 저는 다음처럼 전략을 바꿨습니다.

  1. node_modules 캐시를 제거하거나 최소화
  2. 패키지 매니저 캐시만 저장
  3. 설치 명령은 “오프라인 우선” 옵션을 사용

예: npm

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

- name: Install
  run: npm ci --prefer-offline --no-audit

예: pnpm

- name: Install
  run: pnpm install --frozen-lockfile --prefer-offline

이렇게 바꾸면 “캐시 tar 해제”보다 “로컬 store에서 링크”가 빨라져 체감이 크게 좋아집니다.


5단계: 키가 자주 바뀌는 진짜 원인 찾기 (hashFiles 함정)

hashFiles는 매칭된 파일들의 해시를 계산합니다. 여기서 자주 터지는 함정:

  • lockfile 외에 **/*.lock 같은 과매칭
  • 빌드 산출물이 repo에 생겨서 해시에 포함
  • 줄바꿈/정렬이 달라져 lockfile이 자주 변함(예: 자동 포맷/정렬)

디버깅: 실제로 어떤 파일이 매칭되는지 출력

GitHub Actions 자체로 “hashFiles가 어떤 파일을 잡았는지”를 직접 출력하긴 어렵지만, 대체로 다음 방식이 유효합니다.

- name: List lockfiles
  run: |
    set -eux
    git ls-files | grep -E 'package-lock\.json$|pnpm-lock\.yaml$|yarn\.lock$' || true

이 결과를 보고 hashFiles 패턴을 좁혀 키를 안정화합니다.


6단계: 캐시를 ‘나눠서’ 저장하면 hit율과 속도가 같이 오른다

한 덩어리 캐시는 편하지만, 변경이 잦은 부분 때문에 전체 캐시가 무효화됩니다. 그래서 아래처럼 분리하면 효과가 큽니다.

  • 의존성 캐시(자주 변경) vs 빌드 캐시(상대적으로 안정)
  • 테스트 도구 캐시(예: Playwright 브라우저) 분리

예: Playwright

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

예: Next.js/Turbo 빌드 캐시

- name: Cache build artifacts
  uses: actions/cache@v4
  with:
    path: |
      .next/cache
      .turbo
    key: ${{ runner.os }}-build-${{ github.ref_name }}-${{ hashFiles('package-lock.json', 'next.config.*', 'turbo.json') }}
    restore-keys: |
      ${{ runner.os }}-build-${{ github.ref_name }}-
      ${{ runner.os }}-build-

포인트는:

  • 빌드 캐시는 브랜치 영향이 커서 ref_name을 섞어 오염을 줄이고
  • 그래도 fallback을 위해 restore-keys를 단계적으로 둡니다.

7단계: 로그를 “관찰 가능하게” 만들기 (재현 가능한 디버깅)

캐시 문제는 결국 관찰 가능성의 문제입니다. 다음 3가지를 워크플로에 넣으면 원인 파악 시간이 급격히 줄어듭니다.

  1. 키를 출력
  2. 캐시 대상 경로의 존재/용량 출력
  3. 설치/빌드 단계 시간을 측정

예시:

- name: Print cache key inputs
  run: |
    echo "OS=${{ runner.os }}"
    echo "LOCK_HASH=${{ hashFiles('package-lock.json') }}"
    echo "REF=${{ github.ref_name }}"

- name: Time install
  run: |
    set -eux
    start=$(date +%s)
    npm ci --prefer-offline --no-audit
    end=$(date +%s)
    echo "install_seconds=$((end-start))"

이렇게 해두면 “캐시 hit인데 설치가 느린지”, “키가 왜 바뀌는지”가 바로 눈에 들어옵니다.


실제로 3배 빨라진 변경점 요약

제가 적용해서 효과가 컸던 순서대로 정리하면 아래와 같습니다.

  1. 키에서 커밋 SHA 제거, lockfile 기반으로 안정화
  2. restore-keys 추가로 부분 hit 확보
  3. node_modules 캐시 비중 축소, 패키지 매니저 캐시 중심으로 전환
  4. 빌드 캐시(.next/cache, .turbo 등)와 의존성 캐시 분리
  5. 경로/용량/시간 로그를 넣어 “hit인데도 느린” 상황을 분리 진단

이 조합이 맞아떨어지면, 캐시 hit율이 올라갈 뿐 아니라 캐시 자체의 압축/해제 비용이 줄어 전체 파이프라인이 눈에 띄게 빨라집니다.


디버깅 사고방식: 캐시도 ‘장애 대응’처럼 접근하기

캐시 문제는 네트워크/권한/정책/키 설계 등 여러 요소가 얽혀서, 막연히 설정만 바꾸면 더 꼬이기 쉽습니다. 저는 캐시 디버깅을 할 때도 장애 대응과 동일하게:

  • 증상을 분류하고(restore vs 효과)
  • 로그로 가설을 검증하고
  • 변경을 최소 단위로 적용

하는 방식으로 접근합니다. 이런 접근은 다른 운영 이슈에서도 그대로 통합니다. 예를 들어 레이트리밋이 걸릴 때도 “헤더를 관찰하고 재시도 정책을 설계”해야 하듯이, 캐시도 “키/경로/이벤트를 관찰하고 정책을 설계”해야 합니다.

관련해서 관찰과 재시도 설계 관점이 비슷한 글로는 아래도 함께 참고할 만합니다.


체크리스트: 캐시 miss/무효를 10분 안에 줄이는 질문들

  1. 키에 github.sha, timestamp, run_id 같은 변수가 들어가 있나?
  2. hashFiles 패턴이 과매칭되지 않나? (모노레포 특히)
  3. 캐시 path는 실제로 설치 결과물이 생기는 경로가 맞나?
  4. restore는 되는데 설치 시간이 그대로인가? (아카이브 비용/효과 없음)
  5. PR from fork에서 저장을 기대하고 있나?
  6. node_modules처럼 파일 수 폭탄을 캐시하고 있나?
  7. 빌드 캐시와 의존성 캐시를 분리했나?
  8. 키/경로/시간을 로그로 출력해 재현 가능하게 만들었나?

결론

GitHub Actions 캐시는 “설정하면 빨라지는 마법”이 아니라, 키/경로/이벤트/아카이브 비용을 함께 최적화해야 하는 시스템입니다. 캐시가 안 먹을 때는 감으로 고치기보다, 로그를 통해 restore/hit/효과를 분리하고, lockfile 기반 키와 패키지 매니저 캐시 중심 전략으로 재구성하면 안정적으로 속도를 끌어올릴 수 있습니다.

다음 단계로는, 여러분의 워크플로 파일을 기준으로(언어/패키지 매니저/모노레포 여부) 캐시 키와 경로를 더 구체적으로 튜닝해 볼 수 있습니다.