레이어 캐시 전략 — 빌드 시간을 절반으로 줄이는 Dockerfile 최적화
Docker 이미지를 빌드할 때마다 몇 분씩 걸린다면, 실제로 변경된 부분은 소스 코드 한 줄인데 전체를 다시 빌드하고 있는 건 아닐까요?
레이어 캐시의 기본 원리
Dockerfile의 각 명령어(FROM, RUN, COPY 등)는 하나의 레이어를 만듭니다. Docker는 빌드할 때 각 레이어의 입력이 이전 빌드와 동일하면 캐시된 결과를 재사용합니다.
명령어 1: FROM node:20-alpine → 캐시 히트 ✓
명령어 2: COPY package.json ./ → 캐시 히트 ✓ (파일 내용 동일)
명령어 3: RUN npm ci → 캐시 히트 ✓ (이전 레이어 동일)
명령어 4: COPY . . → 캐시 미스 ✗ (소스 코드 변경됨)
명령어 5: RUN npm run build → 캐시 미스 ✗ (이전 레이어 변경)
핵심 규칙: 캐시 미스가 발생하면, 그 이후의 모든 레이어도 캐시를 사용할 수 없습니다.
캐시가 무효화되는 조건
RUN 명령어
- 명령어 문자열이 변경되면 캐시 미스
- 이전 레이어가 변경되면 캐시 미스
- 명령어 실행 결과가 달라지더라도 문자열이 같으면 캐시 히트 (주의!)
# 이 명령어는 실행할 때마다 다른 패키지 버전을 설치할 수 있지만
# 문자열이 동일하면 캐시를 재사용합니다
RUN apt-get update && apt-get install -y curl
COPY/ADD 명령어
- 파일의 내용 체크섬을 비교합니다 (수정 시간이 아님)
- 파일 내용이 하나라도 바뀌면 캐시 미스
- 퍼미션 변경도 캐시 미스를 유발
명령어 순서 최적화
가장 기본적이면서도 효과가 큰 최적화는 변경 빈도가 낮은 명령어를 위에 배치하는 것입니다.
나쁜 예
FROM node:20-alpine
WORKDIR /app
COPY . . # 소스 코드 변경 → 여기서 캐시 미스
RUN npm ci # 매번 다시 설치 (캐시 못 씀)
RUN npm run build # 매번 다시 빌드
소스 코드 한 줄만 수정해도 npm ci부터 다시 실행됩니다.
좋은 예
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # 의존성 파일은 잘 안 바뀜
RUN npm ci # 캐시 재사용!
COPY . . # 소스 코드 (여기서만 캐시 미스)
RUN npm run build # 이것만 다시 실행
의존성 파일이 변경되지 않았다면 npm ci 레이어가 캐시에서 재사용됩니다. 소스 코드만 바뀌었을 때 빌드 시간이 분 단위에서 초 단위로 줄어들 수 있습니다.
언어별 캐시 최적화 패턴
# Java (Gradle)
COPY build.gradle.kts settings.gradle.kts gradlew ./
COPY gradle/ gradle/
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon
# Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Go
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app
BuildKit 캐시 마운트
Docker BuildKit은 --mount=type=cache라는 강력한 기능을 제공합니다. 패키지 매니저의 다운로드 캐시를 빌드 간에 유지할 수 있습니다.
활성화 방법
# 방법 1: 환경변수
export DOCKER_BUILDKIT=1
docker build .
# 방법 2: docker buildx (권장)
docker buildx build .
npm 캐시 마운트
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
/root/.npm 디렉토리가 빌드 간에 유지되므로, 패키지를 다시 다운로드하지 않아도 됩니다.
다양한 패키지 매니저에 적용
# apt
RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt \
apt-get update && apt-get install -y curl git
# pip
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Maven
RUN --mount=type=cache,target=/root/.m2/repository \
mvn package -DskipTests
# Gradle
RUN --mount=type=cache,target=/root/.gradle \
./gradlew build --no-daemon
# Go
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app
캐시 마운트 vs 레이어 캐시
| 특성 | 레이어 캐시 | 캐시 마운트 |
|---|---|---|
| 동작 | 명령어 입력이 같으면 전체 재사용 | 캐시 디렉토리만 유지 |
| 무효화 | 이전 레이어 변경 시 연쇄 무효화 | 무효화 없이 항상 사용 가능 |
| 이미지 포함 | 레이어에 포함됨 | 이미지에 포함되지 않음 |
| 활용 | 명령어 순서 최적화 | 패키지 매니저 캐시 |
CI 환경에서의 캐시 전략
CI 러너는 매번 깨끗한 환경에서 시작하므로 로컬 레이어 캐시가 없습니다. 별도의 캐시 전략이 필요합니다.
레지스트리 캐시
# 이전 빌드 이미지를 캐시 소스로 사용
docker buildx build \
--cache-from type=registry,ref=myregistry.com/myapp:cache \
--cache-to type=registry,ref=myregistry.com/myapp:cache,mode=max \
-t myapp:latest .
GitHub Actions 예시
# .github/workflows/build.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build with cache
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/myorg/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
type=gha는 GitHub Actions의 캐시 스토리지를 활용합니다.
인라인 캐시
# 이미지에 캐시 메타데이터를 포함시켜 push
docker buildx build \
--cache-to type=inline \
--push \
-t myregistry.com/myapp:latest .
# 다음 빌드에서 해당 이미지를 캐시로 사용
docker buildx build \
--cache-from myregistry.com/myapp:latest \
-t myregistry.com/myapp:latest .
로컬 캐시 (CI에서 디렉토리로 저장)
docker buildx build \
--cache-from type=local,src=/tmp/.buildx-cache \
--cache-to type=local,dest=/tmp/.buildx-cache-new,mode=max \
-t myapp:latest .
캐시 무효화를 피하는 패턴들
1. .dockerignore로 불필요한 파일 제외
# .dockerignore
.git
node_modules
dist
*.md
.env
.vscode
.git 디렉토리가 빌드 컨텍스트에 포함되면, 커밋할 때마다 COPY . .의 캐시가 무효화됩니다.
2. ARG 사용 시 위치 주의
FROM node:20-alpine
# ARG를 아래쪽에 배치 — 값이 바뀌어도 윗 레이어에 영향 없음
COPY package*.json ./
RUN npm ci
COPY . .
ARG BUILD_VERSION=dev
RUN npm run build -- --env version=${BUILD_VERSION}
ARG가 사용된 레이어부터 캐시가 무효화되므로, 빈번히 변경되는 ARG는 최대한 아래에 배치합니다.
3. 파일 복사 세분화
# 나쁜 예: 설정 파일 하나 바뀌면 전체 캐시 미스
COPY . .
# 좋은 예: 변경 빈도별로 나누어 복사
COPY tsconfig.json ./
COPY webpack.config.js ./
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
COPY public/ ./public/
RUN npm run build
4. 날짜/타임스탬프 포함 명령어 피하기
# 나쁜 예: 빌드할 때마다 날짜가 달라져 캐시 미스
RUN echo "Built on $(date)" > /build-info.txt
# 좋은 예: 빌드의 마지막에 배치
COPY . .
RUN npm run build
RUN echo "Built on $(date)" > /build-info.txt
캐시 분석 및 디버깅
빌드 로그 확인
# 캐시 사용 여부 확인
docker build . 2>&1 | grep -E "CACHED|RUN|COPY"
# BuildKit 상세 로그
docker buildx build --progress=plain .
캐시가 사용되면 CACHED라고 표시됩니다.
#5 [2/6] COPY package.json package-lock.json ./
#5 CACHED
#6 [3/6] RUN npm ci
#6 CACHED
#7 [4/6] COPY . .
#7 sha256:abc123... 2.5MB
#8 [5/6] RUN npm run build
#8 0.523 Building...
캐시 정리
# 빌드 캐시 확인
docker builder prune --dry-run
# 빌드 캐시 정리
docker builder prune
# 전체 정리 (주의: 사용하지 않는 이미지/컨테이너도 삭제)
docker system prune -a
정리
- Docker는 각 명령어의 입력이 동일하면 캐시를 재사용하고, 캐시 미스가 발생하면 이후 모든 레이어를 다시 빌드합니다.
- 변경 빈도가 낮은 명령어(의존성 설치)를 위에, 높은 명령어(소스 복사)를 아래에 배치하는 것이 기본 전략입니다.
- BuildKit의
--mount=type=cache는 패키지 매니저 캐시를 빌드 간에 유지하여 다운로드 시간을 절약합니다. - CI 환경에서는 레지스트리 캐시(
type=registry)나 GitHub Actions 캐시(type=gha)를 설정해야 캐시를 활용할 수 있습니다. .dockerignore, ARG 배치, 파일 복사 세분화로 불필요한 캐시 무효화를 방지합니다.