목차
- 왜 캐시부터 손대야 하는가
- actions/cache가 실제로 하는 일
- setup-* 액션의 cache 옵션을 먼저 써라
- Docker layer 캐시 — 시간을 가장 많이 깎는 구간
- 캐시 키 설계 — 여기서 대부분이 실패한다
- 운영하면서 만나는 한계들
- 의존성과 Docker 캐시, 둘이 충돌하지 않게
- 바로 적용할 체크리스트
GitHub Actions 캐시 최적화는 actions/cache 액션과 setup-* 액션의 cache 옵션을 묶어서, 의존성·빌드 산출물·Docker 레이어를 워크플로우 간에 재사용하는 작업이다. CI/CD에서 가장 비싸고 가장 자주 반복되는 구간이 의존성 설치와 컨테이너 빌드인데, 이 두 가지만 제대로 캐시해도 빌드 시간이 절반 가까이 떨어진다는 게 일반적인 경향이다.
반면, 문제는 옵션과 키 설계가 생각보다 까다롭다는 거다. 키를 잘못 잡으면 캐시 hit가 절대 안 나고, 너무 헐겁게 잡으면 오래된 레이어가 계속 살아남아서 이상한 버그가 재현된다. 이 글은 회사에 막 합류한 주니어가 "이거 어떻게 설정해요?" 하고 물었을 때 책상 옆에서 설명해주는 흐름으로 정리한다.
왜 캐시부터 손대야 하는가
또한, 새 워크플로우를 받았을 때 가장 먼저 봐야 하는 건 매트릭스 확장이 아니라 캐시 설정이다. 이유는 단순하다. GitHub Actions의 호스티드 러너는 빌드 시간을 분 단위로 과금하고, Linux 기준 ubuntu-latest 2-core 러너가 가장 싼 축에 속한다. 그래도 매 PR마다 12분 걸리던 빌드를 4분으로 줄이면 그 자체로 인프라 비용 1/3이다.
여기에 숨은 비용이 하나 더 있다. 개발자 대기 시간이다. 빌드가 12분이면 PR 머지까지 컨텍스트 스위칭이 두세 번씩 들어간다. 4분으로 줄면 그 자리에서 결과를 보고 바로 다음 작업을 잡는다. 캐시는 인프라 비용보다 이 부분에서 더 크게 회수된다고 보는 편이다.
특히, 캐시를 안 쓰면 매 빌드가 어떤 일을 반복하는지 떠올려보자. npm install로 동일한 패키지 트리를 다시 받는다. Docker 이미지를 base부터 다시 빌드한다. pytest 실행 전에 venv를 처음부터 만든다. 모두 입력이 바뀌지 않았을 때는 결과가 비트 단위로 동일한 작업이다. 캐시는 이 "결정론적 작업"의 결과를 재사용하는 것뿐이다.
actions/cache가 실제로 하는 일
예를 들어, 대부분 사람이 actions/cache@v4를 그냥 복붙해서 쓰는데, 내부 동작을 한 번 보고 가는 게 좋다. 이걸 알아야 키 설계할 때 헷갈리지 않는다.
실제로, 핵심은 세 가지다. path는 캐시할 디렉터리, key는 캐시를 식별하는 문자열, restore-keys는 정확한 key가 없을 때 prefix 매칭으로 가장 비슷한 캐시를 가져오는 fallback이다.
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
그래서, 워크플로우가 시작되면 캐시 액션은 key로 정확한 매칭을 시도한다. 있으면 cache hit다. 없으면 restore-keys의 prefix로 가장 최근 캐시를 가져온다. 이건 partial hit고, 워크플로우는 정상 진행되지만 작업 마지막에 새 key로 캐시를 새로 저장한다. 이 흐름을 이해 못 하면 "캐시 분명히 설정했는데 매번 새로 받는 것 같다"는 증상에서 못 빠져나온다.
한 가지 더. 캐시는 브랜치별로 격리된다. 정확히는 현재 브랜치 → base 브랜치 → default 브랜치 순으로 조회한다. PR에서 만든 캐시는 main에서 못 읽지만, main에서 만든 캐시는 PR에서 읽을 수 있다는 뜻이다. main에 한 번 빌드를 굴려서 캐시를 "심어두는" 워크플로우를 따로 두는 패턴이 여기서 나온다.
setup-* 액션의 cache 옵션을 먼저 써라
신입한테 가장 먼저 보여줘야 하는 건 actions/cache가 아니라 setup-node, setup-python, setup-java의 내장 cache 옵션이다. 이게 훨씬 단순하고, hashFiles 키 같은 걸 직접 안 짜도 된다.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: 'pnpm-lock.yaml'
특히, 이렇게만 써도 ~/.local/share/pnpm/store가 자동으로 캐시된다. lock 파일 해시로 키가 잡히고, restore-keys 폴백도 알아서 처리한다. Python도 똑같다.
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: 'requirements*.txt'
pip 외에 pipenv, poetry도 지원한다. 단, poetry는 pip install poetry가 먼저 끝나야 setup-python이 캐시 path를 찾을 수 있어서 step 순서를 신경 써야 한다. 이거 모르면 "왜 poetry 캐시가 안 잡히지" 하면서 한참 헤맨다.
내장 옵션이 부족한 경우에만 actions/cache를 직접 쓴다. 예를 들면 빌드 산출물(.next/cache, dist, target), 컴파일러 캐시(sccache, ccache), 모노레포의 turborepo 캐시 같은 것들이다.
Docker layer 캐시 — 시간을 가장 많이 깎는 구간
여기가 진짜 본론이다. 의존성 캐시는 보통 1~2분 단위로 줄지만, Docker 레이어 캐시는 5~10분이 한 번에 사라진다. 멀티스테이지 빌드에 npm install·apt install이 들어가 있다면 효과는 더 극적이다.
핵심 도구는 docker/build-push-action과 docker/setup-buildx-action 조합이다. BuildKit이 활성화돼야 레이어 캐시를 외부 백엔드로 export할 수 있기 때문에 setup-buildx-action은 거의 필수다.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: myorg/myapp:${{ github.sha }}
cache-from: type=gha,scope=app-${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=app-${{ github.ref_name }}
cache-from과 cache-to가 핵심이다. type=gha는 GitHub Actions Cache API를 백엔드로 쓴다는 의미고, mode=max는 중간 레이어까지 전부 저장한다는 옵션이다. mode=min이 기본인데 이건 최종 레이어만 저장해서 사실상 캐시 의미가 거의 없다. 반드시 mode=max로 두자.
gha 캐시와 registry 캐시 중 뭘 고를까
캐시 백엔드는 크게 세 가지다.
| 백엔드 | 장점 | 단점 |
|---|---|---|
type=gha |
설정 단순, GitHub 안에서 자동 정리 | 10GB 한도, 브랜치 격리 |
type=registry |
한도 없음, 팀·환경 간 공유 가능 | 레지스트리 비용·인증 설정 필요 |
type=inline |
별도 저장소 불필요 | 최종 이미지에 메타데이터 묻어감, 효과 제한적 |
이처럼, 소규모 프로젝트는 gha로 시작하면 된다. 이미지가 1GB를 넘기고 멀티 환경(dev/stage/prod)을 굴리기 시작하면 그때 type=registry,ref=myorg/myapp:buildcache 형태로 옮기는 게 자연스럽다. inline 캐시는 사실 잘 안 쓰게 되는 것 같다.
Dockerfile 자체를 캐시 친화적으로
한편, 캐시 백엔드를 붙여놨는데도 효과가 없다면 Dockerfile 작성이 잘못된 경우가 많다. 원칙은 두 가지다.
자주 바뀌는 건 뒤로 보낸다. 의존성 설치를 먼저, 소스 코드 복사는 뒤로. 이게 안 돼 있으면 코드 한 줄 바뀔 때마다 npm install을 다시 돌린다.
FROM node:20-alpine AS base
WORKDIR /app
# 의존성 먼저 — lock 파일만 바뀌어도 여기서 캐시 깨짐
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# 소스는 마지막에 — 코드 바뀌어도 위 레이어는 재사용
COPY . .
RUN pnpm build
BuildKit 캐시 마운트를 쓴다. RUN --mount=type=cache,target=/root/.npm 같은 패턴인데, 패키지 매니저 자체의 캐시(.npm, .pnpm-store, .cargo/registry 등)를 빌드 중간에 마운트로 재사용한다. 레이어 캐시가 깨져도 패키지 다운로드는 재사용되므로 한 단계 더 안전망이 생긴다.
캐시 키 설계 — 여기서 대부분이 실패한다
캐시가 hit 안 나는 이유 90%는 키 설계 문제다. 패턴 몇 개만 외워두면 거의 다 커버된다.
실제로, 기본형: runner.os + 도구 + lock 파일 해시. 이게 표준이다.
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
복수 lock 파일이 있는 모노레포: hashFiles에 glob을 넣고 prefix를 잘 잡는다.
key: ${{ runner.os }}-node-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-
restore-keys가 없으면 lock 파일이 단 한 줄 바뀌어도 캐시를 처음부터 다시 만든다. restore-keys는 거의 항상 같이 써야 한다. 부분 매칭으로라도 가져와서, 추가된 패키지만 받는 게 압도적으로 빠르다.
그러나, 툴 버전이 영향을 주는 경우: Python 3.12와 3.13의 wheel은 호환 안 되므로 키에 버전을 박아야 한다.
key: ${{ runner.os }}-py${{ matrix.python }}-${{ hashFiles('requirements.txt') }}
예를 들어, 여기서 자주 하는 실수가 hashFiles('package.json')처럼 lock 파일이 아닌 manifest를 해싱하는 거다. package.json의 ^1.2.0은 실제 설치되는 버전과 다를 수 있어서, 같은 manifest인데 캐시가 stale인 상황이 생긴다. 무조건 lock 파일을 해싱하라. 없으면 만들어라.
운영하면서 만나는 한계들
캐시는 만능이 아니다. 1년쯤 굴려보면 부딪히는 한계가 정해져 있다.
10GB 저장소 한도가 가장 크다. monorepo에서 도커 이미지 캐시 + 언어별 의존성 캐시 + 빌드 산출물 캐시를 다 쌓다 보면 금세 채워진다. 한도를 넘으면 LRU로 오래된 캐시부터 자동 삭제되는데, 정작 main의 base 캐시가 밀려나면서 매번 cold build가 되는 사고가 발생한다. scope를 명시적으로 나눠서 PR 캐시가 main 캐시를 밀어내지 못하게 막는 게 좋다.
7일 비접근 시 자동 삭제도 있다. 한동안 안 쓰인 브랜치의 캐시는 알아서 정리된다. 보통은 도움이 되지만, 분기에 한 번 도는 워크플로우가 있다면 매번 cold start가 된다. 이런 건 schedule로 main에서 캐시를 주기적으로 데우는 워크플로우를 따로 만들어 둔다.
예를 들어, 캐시 무효화 수단이 빈약하다. 키를 잘못 잡아서 깨진 캐시가 살아남으면 손으로 지워야 한다. gh cache delete CLI나 GitHub UI의 Actions Caches 페이지에서 지우는 방법이 있는데, CI가 깨질 때 후보 1번이 캐시라는 걸 기억해두면 디버깅 시간이 많이 줄어든다.
실제로, 비결정적 빌드와 안 맞는다. 빌드 결과가 시간·환경에 따라 달라지는 코드(예: 빌드 시 현재 git commit hash를 박는 ldflags)는 캐시 키에 그 값을 안 넣으면 잘못된 결과가 굳는다. 한 번은 이걸로 production 이미지에 이틀 전 commit hash가 박혀서 한참 시행착오를 겪은 적이 있다는 사례를 본 적 있다. 결정론적이지 않은 빌드는 캐시 대상에서 제외하는 게 안전하다.
의존성과 Docker 캐시, 둘이 충돌하지 않게
실무에서 한 가지 더 보고 가야 할 게 있다. 같은 워크플로우에서 setup-node의 npm 캐시를 켜놓고 동시에 Docker 빌드도 캐시하는 경우, 두 캐시가 서로 다른 layer에서 동작한다는 점이다.
setup-node 캐시는 러너 호스트의 ~/.npm을 캐시한다. Docker 빌드는 컨테이너 내부의 node_modules나 RUN --mount 캐시를 캐시한다. 둘은 공유되지 않는다. 둘 다 켜놓고 "왜 npm install이 두 번 도는 것 같지" 하면서 헤맬 수 있는데, 의도된 동작이다. 호스트에서 도는 테스트/린트용 install과 이미지에 들어갈 install은 별개 작업이라서 그렇다.
즉, 선택지는 두 가지다. 둘 다 캐시하거나, 도커 빌드 안에서 모든 걸 처리하고 호스트 install을 빼거나. 후자 쪽이 점점 표준이 되어가는 분위기다.
바로 적용할 체크리스트
신입한테 PR 한 번에 적용시키고 싶을 때 쓰는 순서다.
setup-*액션의 내장cache옵션부터 켠다. 5분 작업으로 빌드 시간 30~50% 감소가 보통이다.- Docker 빌드에
setup-buildx-action+cache-from/cache-to: type=gha,mode=max,scope=...를 붙인다. scope에 브랜치 이름을 넣어 main 캐시를 보호한다. - Dockerfile에서 의존성 설치를 소스 복사보다 앞으로 옮긴다. 캐시 백엔드만큼 중요한 변경이다.
- lock 파일을 키 해시 대상으로 명시한다.
package.json이 아니라pnpm-lock.yaml. restore-keys를 항상 같이 적는다. partial hit이 cold start보다 압도적으로 빠르다.- Actions Caches 페이지를 한 번 열어본다. 어떤 캐시가 얼마나 쌓이는지 감을 잡고 시작하는 것과 아닌 것은 차이가 크다.
여기까지가 일반적인 호스티드 러너에서 끌어낼 수 있는 거의 최대치라고 본다. 더 깎으려면 self-hosted runner에 S3 백엔드로 BuildKit 캐시를 빼거나, nix 같은 결정론적 빌드 시스템을 들이는 영역으로 넘어간다. 다음에는 self-hosted runner + S3 cache 백엔드 구성을 실험해볼 생각이다.
따라서, 공식 문서: actions/cache Docker Build Push Action v6 Release Notes
관련 글
- Trivy로 컨테이너 이미지 취약점 스캔을 CI/CD에 붙인 3개월 회고 – 프론트엔드에서 백엔드로 넘어오고 나서 처음 맡은 컨테이너 보안 스캔 자동화. Trivy를 GitHub Actions에 붙이면서 겪은 시행착…
- GitHub Actions CI/CD 구축기 — 테스트부터 배포 자동화까지 – 수동 배포 3개월 차에 터진 사고를 계기로 GitHub Actions CI/CD를 구축한 과정이다. YAML 문법 에러, 시크릿 설정 실수…
- GitHub Actions ARC 쿠버네티스 러너 설치, 3개월 운영 회고와 EC2 비용 비교 – EKS 위에 GitHub Actions ARC를 올려 ephemeral 러너로 운영한 3개월의 기록이다. 비용은 EC2 self-hoste…