- Published on
GitHub Actions 캐시가 안 먹을 때 속도 3배 올린 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
GitHub Actions를 쓰다 보면 “분명 cache를 걸었는데 매번 설치를 다시 한다”는 상황을 한 번쯤 겪습니다. 캐시가 안 먹으면 CI 시간은 선형으로 늘고, PR 피드백 루프는 길어지며, 팀 전체 생산성까지 같이 떨어집니다.
이 글은 캐시 hit율이 0%에 가깝던 파이프라인을 디버깅해서 체감 3배(예: 1215분 → 45분)까지 줄였던 방법을, 재현 가능한 체크리스트와 함께 정리한 실전 기록입니다. 단순히 actions/cache@v4 예제 붙여넣기가 아니라, “왜 miss가 나는지”를 로그로 확인하고 키/경로/전략을 바꾸는 과정에 초점을 둡니다.
캐시가 ‘안 먹는’ 증상부터 명확히 정의하기
캐시 문제는 대개 아래 3가지 중 하나입니다.
- 저장(restore) 자체가 안 됨: 항상
Cache not found for input keys만 뜸 - 복원은 되는데 효과가 없음: restore 로그는 있는데 설치 시간이 그대로
- 간헐적으로만 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는 다운로드 아티팩트 중심이라 상대적으로 효율적
그래서 저는 다음처럼 전략을 바꿨습니다.
node_modules캐시를 제거하거나 최소화- 패키지 매니저 캐시만 저장
- 설치 명령은 “오프라인 우선” 옵션을 사용
예: 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가지를 워크플로에 넣으면 원인 파악 시간이 급격히 줄어듭니다.
- 키를 출력
- 캐시 대상 경로의 존재/용량 출력
- 설치/빌드 단계 시간을 측정
예시:
- 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배 빨라진 변경점 요약
제가 적용해서 효과가 컸던 순서대로 정리하면 아래와 같습니다.
- 키에서 커밋 SHA 제거, lockfile 기반으로 안정화
restore-keys추가로 부분 hit 확보node_modules캐시 비중 축소, 패키지 매니저 캐시 중심으로 전환- 빌드 캐시(.next/cache, .turbo 등)와 의존성 캐시 분리
- 경로/용량/시간 로그를 넣어 “hit인데도 느린” 상황을 분리 진단
이 조합이 맞아떨어지면, 캐시 hit율이 올라갈 뿐 아니라 캐시 자체의 압축/해제 비용이 줄어 전체 파이프라인이 눈에 띄게 빨라집니다.
디버깅 사고방식: 캐시도 ‘장애 대응’처럼 접근하기
캐시 문제는 네트워크/권한/정책/키 설계 등 여러 요소가 얽혀서, 막연히 설정만 바꾸면 더 꼬이기 쉽습니다. 저는 캐시 디버깅을 할 때도 장애 대응과 동일하게:
- 증상을 분류하고(restore vs 효과)
- 로그로 가설을 검증하고
- 변경을 최소 단위로 적용
하는 방식으로 접근합니다. 이런 접근은 다른 운영 이슈에서도 그대로 통합니다. 예를 들어 레이트리밋이 걸릴 때도 “헤더를 관찰하고 재시도 정책을 설계”해야 하듯이, 캐시도 “키/경로/이벤트를 관찰하고 정책을 설계”해야 합니다.
관련해서 관찰과 재시도 설계 관점이 비슷한 글로는 아래도 함께 참고할 만합니다.
체크리스트: 캐시 miss/무효를 10분 안에 줄이는 질문들
- 키에
github.sha, timestamp, run_id 같은 변수가 들어가 있나? hashFiles패턴이 과매칭되지 않나? (모노레포 특히)- 캐시 path는 실제로 설치 결과물이 생기는 경로가 맞나?
- restore는 되는데 설치 시간이 그대로인가? (아카이브 비용/효과 없음)
- PR from fork에서 저장을 기대하고 있나?
node_modules처럼 파일 수 폭탄을 캐시하고 있나?- 빌드 캐시와 의존성 캐시를 분리했나?
- 키/경로/시간을 로그로 출력해 재현 가능하게 만들었나?
결론
GitHub Actions 캐시는 “설정하면 빨라지는 마법”이 아니라, 키/경로/이벤트/아카이브 비용을 함께 최적화해야 하는 시스템입니다. 캐시가 안 먹을 때는 감으로 고치기보다, 로그를 통해 restore/hit/효과를 분리하고, lockfile 기반 키와 패키지 매니저 캐시 중심 전략으로 재구성하면 안정적으로 속도를 끌어올릴 수 있습니다.
다음 단계로는, 여러분의 워크플로 파일을 기준으로(언어/패키지 매니저/모노레포 여부) 캐시 키와 경로를 더 구체적으로 튜닝해 볼 수 있습니다.