왜 Docker 빌드 캐시를 따로 설계해야 하나

컨테이너 이미지를 CI에서 매번 새로 빌드하면 가장 먼저 느려지는 곳은 애플리케이션 코드가 아니라 의존성 설치와 베이스 레이어입니다. 로컬 개발 PC에서는 Docker가 이전 빌드 레이어를 자연스럽게 재사용하지만, GitHub Actions 같은 호스팅 러너는 작업이 시작될 때 깨끗한 환경에서 출발합니다. 그래서 Dockerfile을 잘 작성했더라도 외부 캐시를 명시적으로 내보내고 다시 가져오지 않으면 캐시 효과가 거의 남지 않습니다.

BuildKit과 buildx는 레이어 캐시를 레지스트리, 로컬 디렉터리, GitHub Actions 캐시 같은 외부 저장소로 보낼 수 있습니다. 핵심은 단순히 캐시 옵션을 켜는 것이 아니라 Dockerfile 레이어 순서, 의존성 파일 복사 범위, 브랜치별 캐시 키, 보안 스캔 위치를 함께 맞추는 것입니다. 이 문서는 Node.js 애플리케이션을 예로 들지만, Java, Python, Go 서비스에도 같은 원칙을 적용할 수 있습니다.

Dockerfile부터 캐시 친화적으로 정리하기

가장 흔한 실수는 전체 소스 코드를 먼저 복사한 뒤 패키지를 설치하는 방식입니다. 이 구조에서는 README 수정이나 화면 문구 변경만 있어도 의존성 설치 레이어가 무효화됩니다. 먼저 lock 파일과 매니페스트만 복사해 의존성을 설치하고, 그 다음 실제 소스 코드를 복사해야 합니다.

# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./package.json
USER node
CMD ["node", "dist/server.js"]

위 구조에서 의존성 설치 레이어는 package-lock.json이 바뀔 때만 다시 실행됩니다. 소스 코드 변경은 build 단계 이후에만 영향을 주므로 캐시 적중률이 높아집니다. Java 프로젝트라면 Gradle wrapper, build.gradle, settings.gradle, gradle.lockfile을 먼저 복사하고 의존성 다운로드 단계를 분리하는 식으로 같은 패턴을 만들 수 있습니다.

GitHub Actions에서 Buildx 캐시 연결하기

Buildx 캐시는 가져오기와 내보내기를 모두 지정해야 합니다. 가져오기만 있으면 다음 실행을 위한 캐시가 갱신되지 않고, 내보내기만 있으면 이번 실행에서 기존 캐시를 쓰지 못합니다. GitHub Actions 캐시 백엔드는 워크플로 안에서 빠르게 붙일 수 있지만, 캐시 용량과 보존 정책, API 사용량 제한의 영향을 받을 수 있으므로 무거운 다중 플랫폼 빌드에서는 레지스트리 캐시도 검토해야 합니다.

name: docker-build

on:
  push:
    branches: [main]
  pull_request:

jobs:
  image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v6

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        if: github.event_name == 'push'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: ${{ github.event_name == 'push' }}
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha,scope=app-image
          cache-to: type=gha,scope=app-image,mode=max

scope는 같은 저장소 안에서 이미지가 여러 개일 때 충돌을 막는 이름표입니다. API 서버, worker, admin처럼 Dockerfile이 다르면 scope도 분리하는 것이 좋습니다. mode=max는 더 많은 중간 레이어를 저장해 캐시 재사용 가능성을 높이지만 저장량도 늘어납니다. 작은 프로젝트는 mode=max가 편하고, 대형 모노레포는 주요 이미지부터 적용한 뒤 캐시 사용량을 보며 조정하는 방식이 안전합니다.

레지스트리 캐시를 함께 쓰는 경우

GitHub Actions 캐시는 CI 워크플로 안에서 편하지만, 조직 단위의 이미지 빌드가 많거나 캐시 만료로 빌드 시간이 들쭉날쭉해지면 레지스트리 캐시가 더 예측 가능할 수 있습니다. 컨테이너 레지스트리에 캐시 전용 태그를 두면 다른 러너나 브랜치에서도 동일한 캐시를 가져올 수 있습니다.

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/example/shop-api:${{ github.sha }}
    cache-from: |
      type=gha,scope=shop-api
      type=registry,ref=ghcr.io/example/shop-api:buildcache
    cache-to: |
      type=gha,scope=shop-api,mode=max
      type=registry,ref=ghcr.io/example/shop-api:buildcache,mode=max

이 방식은 캐시 내보내기가 실패하면 빌드 전체가 느려질 수 있으므로 권한과 레지스트리 접근 정책을 먼저 확인해야 합니다. 또한 캐시 태그는 실행 이미지 태그와 분리해야 합니다. buildcache 태그를 실제 배포 대상으로 쓰면 검증되지 않은 중간 레이어 운영 방식이 배포 정책과 섞일 수 있습니다.

의존성 캐시와 이미지 캐시를 구분하기

GitHub Actions의 setup-node cache 옵션은 npm, yarn, pnpm 패키지 다운로드 캐시를 다룹니다. Docker build 내부에서 실행되는 npm ci와는 영역이 다릅니다. 테스트를 호스트 러너에서 먼저 돌리고, 그 다음 Docker 이미지를 빌드한다면 두 캐시가 모두 필요할 수 있습니다. 반대로 모든 테스트가 Docker build 안에서만 수행된다면 setup-node 캐시는 큰 효과가 없을 수 있습니다.

- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: npm

- run: npm ci
- run: npm test

- uses: docker/build-push-action@v6
  with:
    context: .
    cache-from: type=gha,scope=shop-api
    cache-to: type=gha,scope=shop-api,mode=max

운영에서는 캐시를 많이 넣는 것보다 빌드 흐름을 명확히 나누는 것이 중요합니다. 호스트 테스트는 패키지 매니저 캐시, 이미지 빌드는 BuildKit 캐시, 배포 이미지는 레지스트리 태그와 서명 정책으로 분리하면 장애 원인 추적이 쉬워집니다.

실전 점검 포인트

  • Dockerfile에서 의존성 설치 전에는 lock 파일과 매니페스트만 복사합니다.
  • cache-from과 cache-to를 항상 한 쌍으로 설정하고, 이미지별 scope를 분리합니다.
  • 캐시 저장량이 커지는 프로젝트는 mode=max 적용 대상을 핵심 이미지부터 제한합니다.
  • pull_request에서는 push를 끄고 캐시 접근 권한이 제한될 수 있음을 전제로 빌드 시간을 관찰합니다.
  • 레지스트리 캐시 태그는 배포 태그와 분리하고, 삭제 정책을 문서화합니다.
  • 캐시 적중률만 보지 말고 전체 CI 시간, 네트워크 다운로드량, 실패 후 재시도 시간을 함께 비교합니다.

정리하면 Docker Buildx 캐시는 버튼 하나로 끝나는 최적화가 아니라 빌드 레이어를 안정적으로 재사용하기 위한 운영 설계입니다. 먼저 Dockerfile 레이어 순서를 고치고, 그 다음 GitHub Actions 캐시를 붙이고, 마지막으로 빌드 규모가 커졌을 때 레지스트리 캐시를 더하는 순서가 좋습니다. 이 세 단계를 지키면 작은 코드 변경 때문에 매번 의존성을 다시 받고 이미지를 새로 쌓는 낭비를 크게 줄일 수 있습니다.