GitHub Actions 빌드 캐싱으로 CI 시간 70% 줄이기
GitHub Actions를 도입하고 얼마 지나지 않아 많은 팀이 같은 벽에 부딪힙니다. PR을 올릴 때마다 20~30분씩 기다리는 CI 실행 시간. 코드 몇 줄 고쳤을 뿐인데 의존성 수천 개를 처음부터 다운로드하고, 도커 이미지를 밑바닥부터 빌드합니다. 이 시간을 GitHub Actions 빌드 캐싱으로 얼마나 줄일 수 있을까요? 잘 설계된 캐시 전략은 CI 실행 시간을 50~75% 이상 단축하는 것이 일반적입니다.
핵심은 “이미 한 번 한 일을 또 하지 않는다”는 원칙입니다. 의존성은 락 파일이 바뀌지 않는 한 매번 새로 설치할 이유가 없습니다. 도커 이미지의 기반 레이어는 Dockerfile이 변경되지 않는 한 재빌드할 필요가 없습니다. GitHub Actions는 이를 위한 공식 캐시 액션과 플랫폼 네이티브 캐시 백엔드를 제공합니다. 이 글에서는 actions/cache, 언어별 setup 액션의 내장 캐시, 도커 레이어 캐시(GHA 백엔드)를 실제 YAML 예시와 함께 단계별로 설명합니다.
GitHub Actions 캐시 메커니즘 이해하기
GitHub Actions의 캐시는 키-값 쌍으로 관리됩니다. 워크플로우 실행 시 지정한 키로 캐시를 검색하고, 히트하면 해당 디렉터리/파일을 복원합니다. 미스 시 지정한 restore-keys로 폴백(fallback) 검색을 시도합니다. 작업 완료 후에는 현재 상태를 지정된 키로 저장합니다.
캐시는 브랜치 간에 격리됩니다. 기본 브랜치(main/master)의 캐시는 모든 브랜치에서 읽을 수 있지만, feature 브랜치 캐시는 해당 브랜치에서만 사용됩니다. 이 격리 정책 덕분에 브랜치별 의존성 오염 걱정 없이 안전하게 캐시를 활용할 수 있습니다.
| 캐시 방식 | 적합한 대상 | 설정 복잡도 | 효과 |
|---|---|---|---|
| actions/cache 직접 사용 | 커스텀 경로, 범용 | 낮음 | 높음 |
| setup-node (cache 옵션) | Node.js npm/yarn/pnpm | 매우 낮음 | 높음 |
| setup-python (cache 옵션) | Python pip/pipenv/poetry | 매우 낮음 | 높음 |
| Docker GHA 캐시 | 컨테이너 이미지 빌드 | 중간 | 매우 높음 |
| Gradle/Maven 캐시 | Java/Kotlin 빌드 | 낮음 | 높음 |
actions/cache로 범용 의존성 캐싱하기
actions/cache는 가장 유연한 캐싱 방법입니다. 어떤 디렉터리나 파일이든 캐시할 수 있습니다. 캐시 키 설계가 성능과 신뢰성을 결정하므로 신중하게 구성해야 합니다.
# Node.js - npm 캐시 (actions/cache 직접 사용)
name: CI with npm cache
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm registry
uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
캐시 키 구성 요소를 분해하면: runner.os는 OS 간 바이너리 호환성 문제를 방지하고, hashFiles는 락 파일 내용 기반으로 정확한 무효화 타이밍을 제공합니다. restore-keys의 접두어 패턴은 완전 일치 캐시가 없을 때 가장 최근의 부분 일치 캐시를 사용해 콜드 스타트를 피합니다.
# Python - pip 캐시 (actions/cache 직접 사용)
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install Python dependencies
run: pip install -r requirements.txt
setup-node/setup-python 내장 캐시 활용하기
언어 setup 액션의 cache 파라미터를 사용하면 actions/cache를 별도로 구성할 필요 없이 한 줄로 캐싱을 활성화할 수 있습니다. 내부적으로 actions/cache와 동일한 메커니즘을 사용하지만, 패키지 매니저 경로와 캐시 키를 자동으로 처리해줍니다.
# setup-node의 내장 캐시 (가장 간단한 방법)
name: CI - Node.js with built-in cache
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js with npm cache
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # npm | yarn | pnpm 중 선택
# cache-dependency-path: 'subdir/package-lock.json' # 모노레포 시 지정
- run: npm ci
- run: npm run build
- run: npm test
# setup-python의 내장 캐시
name: CI - Python with built-in cache
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python with pip cache
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip' # pip | pipenv | poetry 중 선택
- run: pip install -r requirements.txt
- run: pytest
setup 액션 내장 캐시의 장점은 단순성이고 단점은 캐시 경로나 키를 커스터마이즈하기 어렵다는 점입니다. 모노레포나 특수한 프로젝트 구조에서는 actions/cache를 직접 사용하는 편이 더 유연합니다.
Docker 레이어 캐시: GHA 백엔드로 이미지 빌드 시간 단축
도커 이미지 빌드는 CI에서 시간을 많이 잡아먹는 대표적인 작업입니다. BuildKit의 캐시 마운트와 GitHub Actions 캐시 백엔드(type=gha)를 결합하면 변경된 레이어만 재빌드해서 빌드 시간을 크게 줄일 수 있습니다.
# Docker 이미지 빌드 with GHA 캐시 백엔드
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
docker-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha # GHA 캐시에서 레이어 읽기
cache-to: type=gha,mode=max # 모든 레이어를 GHA 캐시에 저장
mode=max는 중간 레이어를 포함해 모든 레이어를 캐시에 저장합니다. 첫 번째 실행에서는 캐시가 없어 전체 빌드가 진행되지만, 이후 실행에서는 변경된 레이어 이후만 재빌드합니다. Dockerfile을 최적화해서 변경이 적은 레이어(기반 이미지, 의존성 설치)를 앞에, 자주 변경되는 레이어(앱 소스)를 뒤에 배치하면 캐시 효율이 극대화됩니다.
# 레이어 캐시 효율을 위한 Dockerfile 구조 예시
FROM node:20-alpine AS base
WORKDIR /app
# 의존성 레이어 (변경 빈도 낮음) - 먼저 복사
COPY package*.json ./
RUN npm ci --only=production
# 소스 레이어 (변경 빈도 높음) - 나중에 복사
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=base /app/dist ./dist
COPY --from=base /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
캐시 전략 실전 적용: 종합 워크플로우 예시
실제 프로젝트에서는 여러 캐시 전략을 조합해서 사용합니다. 아래는 Node.js 풀스택 앱의 빌드/테스트/배포 파이프라인에서 의존성 캐시와 도커 캐시를 함께 적용한 예시입니다.
# 종합 CI/CD 워크플로우 with 멀티 캐시 전략
name: Full CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1 # 얕은 클론으로 체크아웃 시간 단축
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # 내장 npm 캐시
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
build-image:
needs: lint-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=sha-
type=ref,event=branch
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- 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: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
캐시 트러블슈팅과 주의사항
캐시 히트율 확인하기
워크플로우 실행 로그에서 “Cache hit” 또는 “Cache miss”가 표시됩니다. 캐시 미스가 반복되면 캐시 키가 너무 자주 변경되는 것은 아닌지 확인하세요. hashFiles 패턴이 너무 넓거나, 자주 변경되는 파일을 포함하고 있을 수 있습니다.
캐시 용량 한도
GitHub Actions 캐시는 저장소당 10GB 한도가 있습니다(GitHub.com 기준, 2024년 시점). 한도 초과 시 가장 오래 사용되지 않은 캐시부터 자동으로 삭제됩니다. 불필요하게 큰 디렉터리를 캐싱하지 않도록 주의하세요.
캐시 강제 무효화
보안 패치 등의 이유로 캐시를 즉시 무효화해야 할 때는 캐시 키의 시드 값을 변경합니다. 예를 들어 v1-npm-${{ hashFiles(...) }}에서 v2-npm-${{ hashFiles(...) }}로 변경하면 모든 기존 캐시가 무시됩니다.
자주 묻는 질문
actions/cache v3와 v4의 차이는 무엇인가요?
v4는 Node.js 20 기반으로 업데이트되어 성능이 개선되었습니다. 또한 캐시 저장 실패 시 워크플로우를 중단하는 대신 경고만 표시하는 방식으로 동작해 안정성이 향상되었습니다. 새 프로젝트에서는 v4를 사용하고, 기존 프로젝트도 v4로 마이그레이션하는 것을 권장합니다.
self-hosted runner에서도 GHA 캐시 백엔드가 동작하나요?
네, self-hosted runner에서도 GitHub Actions 캐시 서비스에 접근할 수 있습니다. 단, runner가 GitHub.com 또는 GHES의 캐시 API에 네트워크 접근이 가능해야 합니다. 내부망 환경에서는 방화벽 규칙을 확인하세요. Docker 빌드에서 type=gha 캐시 백엔드를 사용하려면 Buildx가 설치되어 있어야 합니다.
모노레포에서 패키지별로 캐시를 분리할 수 있나요?
네, hashFiles에 특정 패키지의 락 파일 경로를 지정하거나, 캐시 키에 패키지 이름을 포함시켜 분리할 수 있습니다. 예를 들어 key: ${{ runner.os }}-packages-api-${{ hashFiles('packages/api/package-lock.json') }}처럼 구성하면 api 패키지 의존성만 별도로 캐싱됩니다. setup-node의 cache-dependency-path 파라미터로 특정 락 파일을 지정하는 방법도 있습니다.