목차
- 캐시 후보 셋 — 어떻게 추렸나
- 비교 기준 — 무엇으로 평가했나
- 항목별 비교 1 — 캐시 키 설계
- 항목별 비교 2 — Hit Rate 시나리오
- 항목별 비교 3 — 비용과 운영 부담
- 실제 벤치마크 — 9분에서 3분으로
- 운영하면서 본 함정
- 한 가지 못 해본 것
- 언제 쓰고 언제 안 쓰는가
GitHub Actions 캐시 설정은 빌드 시간을 가장 빨리 줄일 수 있는 레버 중 하나다. 정확히 말하면 actions/cache 액션, Docker BuildKit registry cache, 그리고 셀프호스티드 러너의 로컬 디스크 캐시. 세 방식 모두 "이전 빌드의 산출물을 재사용한다"는 목표는 같지만, 작동 위치도 다르고 무료 한도도 다르고 hit rate가 무너지는 지점도 다르다.
특히, 프론트엔드에서 백엔드로 전환한 지 2년이 지났다. 프론트에 있을 때는 Webpack persistent cache, Vite의 의존성 최적화처럼 "내 로컬에서만 보이는" 캐시를 주로 만졌다. 백엔드로 와서 처음 부딪힌 게 CI 캐시였다. node_modules뿐 아니라 pip wheel, Docker layer, Gradle wrapper, Go module까지 캐시 대상이 늘어난다. 같은 "캐시"라는 단어인데 다루는 결이 꽤 다르더라.
이 글은 PR 빌드 평균 9분을 3분대로 줄이려고 후보 셋을 비교한 기록이다. 결론은 마지막에 판단 기준 형태로 정리했다.
캐시 후보 셋 — 어떻게 추렸나
특히, CI 빌드 캐시를 검색하면 보통 다섯 가지 정도가 나온다. actions/cache, actions/setup-node/setup-python의 내장 캐시, Docker BuildKit registry cache, 셀프호스티드 러너 + 로컬 디스크, 외부 캐시 서비스(Cloudflare R2, S3 위에 직접 구축).
이 중에서 다섯 개를 다 비교하는 건 의미가 없다. setup-node의 캐시는 내부적으로 actions/cache를 그대로 쓰기 때문에 같은 분류로 묶인다. 외부 캐시 서비스는 별도 인프라 운영이 들어가니 다른 카테고리다. 그래서 후보를 셋으로 줄였다.
| 후보 | 작동 위치 | 무료 한도 | 운영 부담 |
|---|---|---|---|
| actions/cache | GitHub 호스팅 캐시 (저장소당 10GB) | 무료 (퍼블릭/프라이빗 동일) | 거의 없음 |
| Docker registry cache | 컨테이너 레지스트리 (GHCR/ECR) | GHCR은 퍼블릭 저장소 무료, 프라이빗은 스토리지 과금 | 레지스트리 정리 정책 필요 |
| 셀프호스티드 러너 디스크 | 러너 머신의 로컬 SSD | 러너 인프라 비용 | 러너 자체를 운영해야 함 |
즉, (출처: GitHub Docs "Caching dependencies to speed up workflows", 2026-04 기준)
실제로, 이 세 개를 같은 잣대로 비교하면 어떤 워크로드에 뭐가 맞는지 답이 나온다.
비교 기준 — 무엇으로 평가했나
캐시는 단순히 "빠르냐"로 결정되지 않는다. 빠른데 hit rate가 30%면 평균적으로 보면 손해다. 그래서 네 가지 축을 잡았다.
게다가, 첫째, 캐시 키 표현력. 의존성 파일 해시, 운영체제, 매트릭스 변수 등을 키에 어떻게 녹이느냐. fallback 키(restore-keys)를 몇 단계 둘 수 있느냐.
또한, 둘째, hit rate. 동일 PR 내 재실행, 베이스 브랜치 머지 후 첫 빌드, 의존성 추가 후 빌드 같은 시나리오에서 캐시가 살아나는 비율.
물론, 셋째, 저장 한도와 만료 정책. GitHub Actions 캐시는 저장소당 10GB 제한이 있고, 7일간 접근 안 되면 자동 삭제된다. registry cache는 레지스트리 정책에 따른다.
넷째, 비용. 직접 청구되는 비용도 있고, 빌드 분이 늘어나서 발생하는 GitHub Actions 사용 시간 비용도 있다. 둘 다 합쳐서 봐야 한다.
항목별 비교 1 — 캐시 키 설계
actions/cache의 키 설계가 의외로 까다롭다. 정확히는, 처음에 키를 잘못 짜면 매번 miss나 stale 캐시가 생긴다.
# 권장 패턴 — 의존성 파일 해시 + restore-keys fallback
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
그래서, 키의 핵심은 hashFiles('**/package-lock.json')다. lock 파일이 바뀌면 키가 바뀌고, 새 캐시가 저장된다. restore-keys는 정확한 키가 없을 때의 fallback이다. 위 예시에서 lock 파일이 바뀌어도 ${{ runner.os }}-node- 접두사로 시작하는 가장 최근 캐시를 가져와서 partial hit를 노린다. node_modules 전체 다운로드는 막을 수 있다.
그래서, 처음에는 이 fallback 개념을 몰라서 lock 파일 한 줄만 바뀌어도 매번 풀 다운로드가 일어났다. 키를 ${{ runner.os }}-node처럼 정적으로만 두면 거꾸로 stale 의존성을 계속 끌어다 쓰게 된다. 둘 다 함정이다.
Docker registry cache는 키 설계라는 개념 자체가 다르다. BuildKit이 레이어 단위로 해시를 계산해서 매칭하기 때문에, 사용자가 직접 키를 짜지 않는다.
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/myorg/app:latest
cache-from: type=registry,ref=ghcr.io/myorg/app:buildcache
cache-to: type=registry,ref=ghcr.io/myorg/app:buildcache,mode=max
mode=max가 중요하다. 기본값(min)은 최종 이미지 레이어만 캐시하고, max는 중간 레이어까지 전부 푸시한다. 다단계 빌드(multi-stage)에서 빌더 스테이지의 캐시를 살리려면 max가 필수다.
특히, 셀프호스티드 러너는 또 다르다. 러너의 작업 디렉토리가 빌드 사이에 살아있기 때문에, 캐시 액션 자체가 필요 없을 수도 있다. node_modules가 그냥 남아있다. 다만 이게 "장점"이자 "단점"이다 — 의도치 않게 stale 상태가 누적된다.
항목별 비교 2 — Hit Rate 시나리오
따라서, 세 시나리오로 hit rate를 측정했다. 동일 저장소(Node.js + Dockerfile, 의존성 약 600개)에서 각 방식을 한 주씩 돌렸다.
| 시나리오 | actions/cache | registry cache | 셀프호스티드 |
|---|---|---|---|
| 동일 PR 재실행 (no change) | 100% | 100% | 100% |
| lock 파일 1줄 변경 | partial (restore-keys hit) | 80% (베이스 이미지 레이어 hit) | 100% (의존성 이미 설치됨, 단 stale 가능) |
| 베이스 브랜치 머지 후 첫 빌드 | 70~85% | 90% | 100% |
| 새 워커/러너에서 첫 빌드 | 0% (캐시 없음) | 80% | 0% |
실제로, 수치는 우리 저장소 기준이고 절대값이 아니다. 다른 프로젝트에서는 다르게 나올 수 있다. 그래도 경향은 비슷해 보인다.
actions/cache는 lock 파일이 바뀐 순간 정확한 키 hit가 깨진다. restore-keys로 fallback을 받아도 새 의존성은 다시 받아야 하니 시간이 더 든다. registry cache는 레이어 단위라서 변경된 레이어 이전까지는 hit가 살아남는다 — 의존성 설치 레이어를 Dockerfile 앞쪽에 두는 게 그래서 중요하다.
# 캐시 친화적인 순서
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # 이 레이어가 lock 파일 안 바뀌면 그대로 hit
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . . # 소스만 바뀌면 위 레이어는 살아남음
RUN npm run build
결국, 소스 코드를 마지막에 COPY하는 게 핵심이다. 의외로 이 순서를 거꾸로 짠 Dockerfile을 자주 본다. 그러면 소스 한 줄만 고쳐도 의존성 레이어부터 깨진다.
항목별 비교 3 — 비용과 운영 부담
비용은 단순하지 않다. 캐시 자체 비용 + 빌드 시간 비용 두 축을 다 봐야 한다.
actions/cache는 저장소당 10GB 무료다. 이걸 넘으면 LRU로 오래된 캐시부터 지워진다. 비용이 직접 청구되지는 않지만, 한도 초과 후에는 hit rate가 떨어져서 빌드 시간이 늘어난다 — 간접 비용이다.
그런데, Docker registry cache는 저장소에 따라 다르다. GHCR의 퍼블릭 저장소는 무료지만, 프라이빗 저장소는 스토리지 GB·월 단위로 과금된다. 한 저장소에 multi-stage 빌드 캐시를 mode=max로 푸시하면 1~5GB 정도 잡힌다. 큰 모노레포는 더 든다.
예를 들어, 셀프호스티드 러너는 러너 인프라 자체가 비용이다. EC2 t3.large 한 대 24/7 돌리면 월 60~70달러 수준(2026년 5월 us-east-1 기준, 출처: AWS Pricing). 다만 빌드 분 단위 과금이 없으니 빌드를 많이 돌리는 팀일수록 단가가 떨어진다.
또한, ::: tip 비용 비교 감각
PR이 일주일에 100개 들어오는 팀 기준 — 평균 빌드 9분이면 GitHub-hosted 러너 비용만 월 200달러를 넘기 시작한다. 이걸 3분대로 줄이면 월 70달러 수준. actions/cache 도입의 단순 ROI가 가장 큰 구간이다.
:::
운영 부담은 셀프호스티드가 압도적으로 무겁다. 러너 자동 스케일링, 보안 패치, 디스크 정리, 좀비 컨테이너 청소까지 전부 직접 해야 한다. 작은 팀에서 이걸 떠안으면 캐시 절감보다 운영 시간이 더 비싸진다.
실제 벤치마크 — 9분에서 3분으로
목표는 PR 빌드 9분을 3분대로 줄이는 것이었다. 적용 전후 측정값이다.
| 단계 | 캐시 없음 | actions/cache만 | actions/cache + registry cache |
|---|---|---|---|
| Checkout | 8s | 8s | 8s |
| 의존성 설치 (npm ci) | 2m 10s | 18s | 18s |
| 린트/테스트 | 1m 20s | 1m 20s | 1m 20s |
| Docker 빌드 | 4m 30s | 4m 30s | 50s |
| 푸시 | 25s | 25s | 25s |
| 총합 | 8m 33s | 6m 41s | 3m 11s |
actions/cache만 적용해도 의존성 설치는 거의 사라진다(2분 10초 → 18초). 물론 Docker 빌드는 그대로다 — actions/cache는 호스트 파일시스템 캐시지, BuildKit 캐시는 따로 설정해야 한다.
그러나, registry cache까지 붙이니 Docker 빌드가 4분 30초 → 50초로 줄었다. 이게 가장 큰 절감 구간이다. 단, 첫 빌드(캐시 콜드)에서는 두 방식 모두 효과가 없다 — cache-to로 푸시하는 시간이 오히려 30초 정도 추가된다.
# 최종 워크플로우 발췌
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # 내부적으로 actions/cache 사용
- run: npm ci
- run: npm test
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/myorg/app:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/myorg/app:buildcache
cache-to: type=registry,ref=ghcr.io/myorg/app:buildcache,mode=max
운영하면서 본 함정
캐시를 깔아놓고 안심하고 있으면, 어느 날 갑자기 빌드가 다시 느려진다. 몇 가지 패턴이 반복된다.
첫째, 캐시 키 충돌. 서로 다른 워크플로우가 같은 키를 쓰면 마지막에 저장한 쪽으로 덮어진다. 매트릭스 빌드(Node 18, 20 동시)에서 OS만 키에 넣고 Node 버전을 안 넣으면 둘이 서로 캐시를 덮어쓴다. 빌드는 성공하는데 의존성이 묘하게 안 맞는 상황이 발생한다.
둘째, 캐시 만료. GitHub은 7일간 접근 없으면 자동 삭제다. 자주 쓰는 메인 브랜치 캐시는 살아있지만, 가끔 푸시되는 release 브랜치 캐시는 매번 0부터 시작한다. 이게 통계상 hit rate를 끌어내린다.
셋째, registry cache의 무한 증가. mode=max는 빠르지만 레이어가 누적된다. 정기적으로 buildcache 태그를 정리해야 한다. GHCR에서 6개월 안 쓴 이미지 자동 삭제 정책 같은 걸 걸어두는 게 안전하다.
즉, ::: warning 캐시 키 디버깅 팁
캐시 hit/miss 여부는 워크플로우 로그의 "Cache" 단계에서 확인할 수 있다. Cache restored from key: ... 메시지가 정확한 키 hit, Cache restored with key: ...(약간 다른 표현)가 restore-keys fallback hit다. 둘을 구분해서 보면 키 설계 문제를 찾기 쉽다.
:::
한편, 이 함정들은 한 번 겪고 나면 워크플로우 작성 습관이 바뀐다. 새 캐시를 추가할 때 키에 OS, 런타임 버전, 의존성 해시를 다 넣는 게 디폴트가 된다.
한 가지 못 해본 것
이처럼, 외부 스토리지(S3나 Cloudflare R2)에 캐시를 직접 올리는 방식은 아직 안 해봤다. actions/cache의 10GB 한도가 부담이 되는 모노레포라면 검토할 만한데, 우리 저장소는 거기까지 가지 않았다. 인프라 운영 비용 대비 절감액이 어떻게 나오는지는 직접 측정해봐야 알 것 같다.
언제 쓰고 언제 안 쓰는가
게다가, 상황별로 어떤 방식이 맞는지 정리한다.
물론, actions/cache (또는 setup- 내장 캐시)만 쓰면 되는 경우.* 빌드 산출물이 컨테이너 이미지가 아니거나, Docker 빌드가 전체 빌드 시간의 30% 미만일 때. node_modules, pip cache, Gradle wrapper 같은 의존성 디렉토리만 캐시해도 효과가 크다. 설정 5줄이면 끝난다. 작은 팀, 단일 저장소, 월 빌드 시간 5,000분 이하라면 이걸로 충분하다.
actions/cache + Docker registry cache 조합을 써야 하는 경우. Docker 이미지가 산출물이고, multi-stage 빌드를 쓰며, 빌드 시간의 절반 이상이 docker build인 경우. 위 벤치마크처럼 가장 큰 절감이 여기서 나온다. GHCR 퍼블릭 저장소면 추가 비용도 없다.
셀프호스티드 러너로 가야 하는 경우. 월 GitHub Actions 사용량이 50,000분을 넘기 시작하거나, 빌드에 GPU·특수 하드웨어가 필요하거나, 사내망 리소스 접근이 필요한 경우. 운영 부담이 크니 전담 인력이 있을 때만 권한다. 캐시 효과만 노리고 셀프호스티드로 가는 건 거의 손해다.
결국, 지금 당장 적용할 수 있는 액션 셋:
actions/setup-node또는setup-python의cache:옵션을 켠다 — 한 줄 추가로 가장 큰 절감- Dockerfile에서
COPY package*.json→RUN npm ci→COPY . .순서로 정렬해 레이어 친화적으로 만든다 - Docker 빌드가 1분을 넘기면
cache-from/cache-to를 GHCR registry cache로 붙인다
그래서, 여기까지 한 번 깔아두면 빌드 시간이 절반 이하로 떨어진다. 그 이상의 최적화는 빌드 분석을 해서 병목을 찾은 다음에 손대는 게 맞다.
관련 글
- GitHub Actions 비용 절감 — 과금 구조부터 self-hosted runner 손익 계산까지 – GitHub Actions 비용 절감을 위해 GitHub-hosted, self-hosted EC2, third-party 세 옵션을 두고…
- Redis 캐시 전략 비교 — LRU·LFU·allkeys로 maxmemory 다루기 – 프론트엔드에서 백엔드로 넘어온 지 2년. Redis OOM 에러를 만나고서야 maxmemory-policy를 제대로 본다. LRU와 LFU…
- Google $40B Anthropic 투자, 결국 클라우드 종속 비용이다 – Google의 $40B Anthropic 투자 보도가 나왔다. 현금이 아니라 컴퓨트 크레딧이 함께 묶이는 구조라, Vertex AI Cla…