← all posts
DEV 2026.05.02 · 12 min read Intermediate

Docker 이미지 빌드, 순서가 전부다

레이어 캐시 원리부터 멀티 스테이지 빌드, BuildKit 병렬 실행, 보안 강화, Spring Boot 최적화, 레지스트리 태그 전략까지 — 느린 빌드의 원인과 해결을 추적한다.


코드 한 줄만 바꿨는데 빌드가 5분이다. 어제는 30초였는데. 무엇이 달라진 걸까? Docker 빌드 시간을 좌우하는 결정은 대부분 Dockerfile의 첫 몇 줄에 숨어 있다. 그리고 그 결정은 단순히 속도 문제가 아니라 — 보안, 비용, 배포 안정성 전체에 걸쳐 있다.

레이어 캐시 — 순서가 전부다

Docker 이미지는 읽기 전용 레이어의 스택이다. 각 Dockerfile 명령어가 하나의 레이어를 만들고, 레이어는 이전 레이어 ID + 현재 명령어 + 파일 내용의 SHA256 해시로 식별된다. 한 레이어라도 변경되면 이후 모든 레이어가 재생성된다.

# ❌ 캐시 파괴형: 소스 코드 한 줄 바꾸면 의존성부터 재다운로드
FROM openjdk:17-slim
COPY . .
RUN ./gradlew clean build -x test

# ✅ 캐시 친화형: 변경 빈도 낮은 것을 먼저
FROM openjdk:17-slim
COPY build.gradle settings.gradle ./
RUN ./gradlew dependencies          # 의존성만 캐시
COPY src ./src
RUN ./gradlew clean build -x test   # 컴파일만 재실행

COPY . . 한 줄이 캐시 무효화의 주범이다. 소스를 수정하면 의존성 다운로드(3분)까지 매번 반복된다. 파일을 변경 빈도 순서로 나누면 — 의존성 정의 → 설정 → 소스 코드 — 소스 변경 시 컴파일(20초)만 재실행된다.

캐시 설계 원칙

변경 빈도가 낮은 것을 위로, 높은 것을 아래로. build.gradle은 거의 안 바뀌고, src/는 매일 바뀐다. 순서 하나가 빌드 시간을 5분에서 20초로 만든다.

멀티 스테이지 빌드 — 800MB를 80MB로

빌드 도구(Gradle, npm, Maven)는 컨테이너 실행 시 필요없다. 하지만 단일 스테이지 Dockerfile은 이 도구들을 최종 이미지에 그대로 담는다.

# Stage 1: 빌드 환경
FROM openjdk:17-jdk AS builder
WORKDIR /app
COPY build.gradle settings.gradle ./
RUN ./gradlew dependencies
COPY src ./src
RUN ./gradlew clean build -x test

# Stage 2: 실행 환경 — 필요한 것만
FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=builder /app/build/libs/app.jar .
ENTRYPOINT ["java", "-jar", "app.jar"]

COPY --from=builder는 이전 스테이지의 파일시스템에서 결과물만 가져온다. Gradle 캐시(150MB), JDK(300MB), 빌드 임시파일 전부 버려진다. Spring Boot 기준으로 800MB → 300MB, Node.js는 765MB → 180MB까지 줄어든다.

이미지 크기는 배포 비용과 직결된다. AWS ECR 기준 800MB 이미지 100개는 월 8,같은수의80MB이미지는월8, 같은 수의 80MB 이미지는 월 0.80이다. 배포 속도도 10배 차이가 난다.

BuildKit — 병렬 실행과 원격 캐시

기존 Docker 빌더는 스테이지를 순차 실행한다. BuildKit은 **의존성 그래프(DAG)**를 분석해 독립적인 스테이지를 병렬로 돌린다.

기존: stage1(30s) → stage2(20s) → stage3(10s) = 60s
BuildKit: max(30s, 20s, 10s) + final(2s) = 32s

더 중요한 것은 원격 캐시다. GitHub Actions 러너는 실행마다 초기화된다. 캐시를 저장하지 않으면 매번 의존성을 다시 다운로드한다.

- uses: docker/build-push-action@v4
  with:
    tags: myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max   # 빌드 결과를 GHA 캐시에 저장

cache-to가 없으면 캐시를 로드만 하고 저장하지 않는다 — 다음 빌드도 처음부터다. 이 두 줄이 있으면 첫 빌드 125초, 이후 재빌드 8초다. 월 50회 배포 기준으로 104분 → 8.6분(92% 단축)이다.

보안 — 루트 탈출과 공격 면적

컨테이너 root는 호스트 root다

User Namespace 매핑이 없으면 컨테이너의 UID 0은 호스트의 UID 0과 같다. 컨테이너 탈출 취약점이 있을 때 호스트 root를 내어주는 셈이다.

보안 강화는 세 방향이다.

Non-root 사용자: USER appuser 한 줄로 컨테이너 탈출 시 피해를 일반 사용자 권한으로 제한한다.

최소 이미지: ubuntu:22.04(45개 취약점)에서 distroless(0개 취약점)로 바꾸면 공격 면적이 사라진다. 쉘도 없으니 공격자가 도구를 설치할 방법도 없다. Alpine은 그 중간 — 쉘은 있지만 취약점이 2개 수준이다.

자동 취약점 스캔: Trivy를 CI에 붙이면 HIGH 이상 취약점 발견 시 배포를 막는다.

- uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:latest
    exit-code: '1'
    severity: 'HIGH,CRITICAL'

Spring Boot와 레지스트리 전략

Spring Boot는 Layered JAR로 한 단계 더 최적화할 수 있다. JAR를 dependencies / spring-boot-loader / snapshot-dependencies / application 네 계층으로 분리하면, 소스 코드 변경 시 application 레이어(2MB)만 재빌드된다.

# Layered JAR 추출
RUN java -Djarmode=layertools -jar app.jar extract --destination extracted

더 간단한 방법은 Cloud Native Buildpacks다.

./gradlew bootBuildImage --imageName=myapp:latest

한 줄이 Layered JAR 생성, JVM 메모리 자동 설정(-XX:MaxRAMPercentage=75.0), non-root 사용자 설정까지 모두 처리한다. Spring Boot 팀의 공식 권장이다.

레지스트리 관리에서는 latest 태그가 가장 흔한 함정이다. 같은 태그가 시간에 따라 다른 이미지를 가리키면 환경 간 재현이 불가능해진다. Git SHA 태그(myapp:main-abc1234)를 기본으로, 릴리스에는 Semantic Version을 추가한다. 자동 정리 정책(최근 5개 유지, 7일 지난 untagged 삭제)으로 저장소 비용을 99% 줄일 수 있다.

정리

  • 레이어 캐시는 변경 빈도 순서로 Dockerfile을 설계하면 자동으로 최적화된다. COPY . .가 첫 줄이면 항상 처음부터 빌드된다.
  • 멀티 스테이지 빌드는 빌드 환경과 실행 환경을 분리해 이미지 크기를 60-80% 줄인다. COPY --from=builder가 핵심이다.
  • BuildKit의 cache-from / cache-to type=gha 두 줄이 CI 빌드 시간을 10배 단축한다. cache-to가 없으면 저장이 안 된다.
  • Non-root 사용자 + 최소 이미지(Alpine/Distroless) + Trivy 스캔 — 세 가지가 컨테이너 보안의 기본이다.
  • latest 태그는 프로덕션에서 쓰지 않는다. Git SHA가 재현성을 보장하고, Semantic Version이 사용성을 보장한다.

다음 글에서는 이렇게 만든 이미지를 Kubernetes에 배포할 때 Rolling Update가 내부적으로 어떻게 동작하는지, 그리고 무중단 배포를 위해 무엇을 설정해야 하는지 추적한다.