Spring Boot Docker 이미지, 800MB를 200MB로 줄이는 순서

Spring Boot 애플리케이션을 도커로 감싸면 이미지가 대개 700~900MB가 됩니다. 빌드도 느리고, 배포할 때마다 그만큼을 밀어 넣어야 합니다. 그런데 실제로 필요한 것은 그중 일부뿐입니다. 여기서는 이미지가 왜 커지는지 짚고, 멀티스테이지 빌드 → JRE 축소 → 레이어 분리 순서로 200MB 대까지 줄이는 과정을 정리합니다.

왜 커지는가

가장 흔한 Dockerfile은 이렇게 생겼습니다.

FROM eclipse-temurin:21-jdk
COPY build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

여기에 두 가지 낭비가 있습니다.

구성 크기 실행에 필요한가
JDK 베이스 이미지 약 450MB 아니오 — 컴파일러·javadoc·디버깅 도구 포함
OS 레이어 (Ubuntu) 약 80MB 일부만
애플리케이션 jar 50~80MB 예

핵심은 실행에는 JDK가 필요 없다는 점입니다. 컴파일은 빌드 시점에 끝났고, 런타임에는 JRE만 있으면 됩니다. 여기서부터 줄여 나갑니다.

1단계 — 멀티스테이지 빌드

빌드용 이미지와 실행용 이미지를 분리합니다. 최종 이미지에는 빌드 결과물만 남습니다.

# ── 빌드 스테이지 ──
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build

# 의존성만 먼저 받아 캐시가 깨지지 않게 한다
COPY gradle gradle
COPY gradlew build.gradle settings.gradle ./
RUN ./gradlew dependencies --no-daemon || true

COPY src src
RUN ./gradlew bootJar --no-daemon -x test

# ── 실행 스테이지 ──
FROM eclipse-temurin:21-jre        # ★ JDK 가 아니라 JRE
WORKDIR /app
COPY --from=builder /build/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

소스보다 의존성 파일을 먼저 복사하는 것이 요령입니다. 도커는 레이어 단위로 캐시하므로, 소스만 바뀌었을 때 의존성 내려받기를 건너뜁니다. 순서를 반대로 하면 코드 한 줄만 고쳐도 전부 다시 받습니다.

여기까지만 해도 약 450MB까지 내려옵니다. JDK가 빠진 효과입니다.

2단계 — 베이스 이미지를 줄인다

JRE 이미지도 배포판에 따라 크기가 꽤 다릅니다.

베이스 대략 크기 특징
temurin:21-jre 약 270MB Ubuntu 기반. 무난한 기본값
temurin:21-jre-alpine 약 170MB musl libc — 호환성 확인 필요
distroless/java21 약 190MB 셸이 없어 공격 표면이 작다
jlink 커스텀 런타임 약 90MB~ 필요한 모듈만. 손이 가장 많이 간다

Alpine은 크기가 매력적이지만 주의가 필요합니다. glibc가 아닌 musl libc를 쓰기 때문에 네이티브 라이브러리에 의존하는 코드에서 문제가 생길 수 있고, 일부 워크로드에서 성능 차이가 보고됩니다. 부하 테스트 없이 운영에 바로 넣지 마십시오.

보안까지 고려한다면 distroless가 균형이 좋습니다.

FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY --from=builder /build/build/libs/*.jar app.jar
ENTRYPOINT ["app.jar"]     # distroless 는 java 가 엔트리포인트로 지정돼 있다

셸이 없어 docker exec로 들어가 볼 수 없다는 점은 장단이 함께 있습니다. 침해 시 공격자가 할 수 있는 일이 줄지만, 디버깅도 그만큼 불편해집니다. 로그와 지표로 진단하는 체계가 갖춰져 있어야 선택할 만합니다.

3단계 — 레이어를 분리한다 (크기보다 중요한 것)

여기서 관점을 하나 바꿀 필요가 있습니다. 배포에서 진짜 비용은 이미지 전체 크기가 아니라 “매번 새로 전송되는 양”입니다.

fat jar를 통째로 COPY하면 코드 한 줄만 고쳐도 jar 전체가 새 레이어가 됩니다. 60MB를 매번 밀어 넣는 셈입니다. 그런데 그 60MB 중 대부분은 바뀌지 않는 라이브러리입니다.

Spring Boot는 jar를 용도별로 쪼개는 기능을 제공합니다.

java -Djarmode=layertools -jar app.jar list

dependencies              # 외부 라이브러리 — 거의 안 바뀐다
spring-boot-loader        # 로더 — 거의 안 바뀐다
snapshot-dependencies     # 스냅샷 의존성
application               # ★ 내 코드 — 매번 바뀐다

이걸 레이어로 나눠 복사합니다.

FROM eclipse-temurin:21-jre AS extractor
WORKDIR /tmp
COPY --from=builder /build/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:21-jre
WORKDIR /app
# 변경 빈도가 낮은 것부터 — 캐시가 오래 유지된다
COPY --from=extractor /tmp/dependencies/ ./
COPY --from=extractor /tmp/spring-boot-loader/ ./
COPY --from=extractor /tmp/snapshot-dependencies/ ./
COPY --from=extractor /tmp/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

이제 코드만 고친 배포에서는 application 레이어 몇 MB만 전송됩니다. 이미지 전체 크기는 비슷해도 배포 속도는 눈에 띄게 달라집니다. 전송량이 줄면 무중단 배포에서 새 인스턴스가 뜨는 시간도 짧아집니다. 전환 과정은 무중단 배포 Nginx vs HAProxy에 정리했습니다.

컨테이너에서 반드시 챙길 설정

크기를 줄이는 것과 별개로, 컨테이너에서 JVM을 돌릴 때 놓치면 안 되는 것이 있습니다.

ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:+ExitOnOutOfMemoryError", "org.springframework.boot.loader.launch.JarLauncher"]
옵션 이유
MaxRAMPercentage=75 힙을 컨테이너 한도의 비율로 잡는다. 나머지 25%는 메타스페이스·스레드 스택·네이티브 버퍼 몫
ExitOnOutOfMemoryError OOM 시 즉시 종료. 반쯤 죽은 채로 트래픽을 받는 상태를 막는다

두 번째가 특히 중요합니다. OOM이 나도 JVM은 종종 살아남아 요청을 계속 받는데, 대부분 실패시킵니다. 차라리 죽고 오케스트레이터가 새로 띄우는 편이 낫습니다. 힙을 얼마로 잡을지 판단하는 방법은 OOM으로 죽은 서비스, 힙 덤프에서 범인 찾기를 참고하십시오.

.dockerignore도 빠뜨리지 마십시오. 없으면 .git과 build 디렉터리까지 빌드 컨텍스트로 전송됩니다.

# .dockerignore
.git
.gradle
build
*.md

정리하면

단계 대략 크기 얻는 것
JDK 베이스 + fat jar 약 800MB —
+ 멀티스테이지 · JRE 약 450MB 컴파일 도구 제거
+ distroless 약 250MB OS 축소, 공격 표면 감소
+ 레이어 분리 약 250MB 재배포 전송량이 수 MB로

마지막 줄이 실무에서 가장 체감이 큽니다. 크기는 그대로인데 배포할 때마다 옮기는 양이 60MB에서 몇 MB로 줄기 때문입니다.

자주 묻는 질문

Q1. Dockerfile 없이 Gradle로 이미지를 만들 수 있다던데요?

./gradlew bootBuildImage를 쓰면 Cloud Native Buildpacks가 이미지를 만들어 줍니다. Dockerfile을 관리하지 않아도 되고 레이어 분리도 기본으로 적용됩니다. 편리하지만 베이스 이미지나 세부 구성을 직접 통제하기 어렵고, 빌드 시간이 더 걸립니다. 표준 구성으로 충분하면 buildpacks, 세밀한 제어가 필요하면 Dockerfile로 나누면 됩니다.

Q2. Alpine을 써도 괜찮을까요?

순수 자바 애플리케이션이라면 대개 문제없이 돕니다. 다만 이미지 처리 라이브러리처럼 네이티브 코드에 의존하는 부분이 있으면 musl libc에서 예기치 않게 실패할 수 있습니다. 크기 차이가 100MB 안팎인데 그것 때문에 원인 모를 장애를 겪을 이유는 없습니다. 부하 테스트로 검증한 뒤에만 넣으십시오.

Q3. 레이어를 나눴는데도 매번 전부 다시 받습니다.

레지스트리 캐시가 없는 환경이거나, COPY 순서가 잘못됐을 가능성이 큽니다. 변경 빈도가 낮은 것부터 복사해야 합니다. 또 하나 흔한 원인은 앞 레이어에 RUN apt-get update 같은 매번 결과가 달라지는 명령이 있는 경우입니다. 그 아래 레이어가 전부 무효화됩니다.

Q4. 이미지 취약점은 어떻게 확인하나요?

Trivy 같은 스캐너를 CI에 붙이면 됩니다. 이미지를 만든 직후 스캔해 심각도 높은 취약점이 있으면 배포를 막는 구성이 일반적입니다. 베이스 이미지를 작게 가져가는 것 자체가 취약점 수를 줄이는 가장 쉬운 방법이기도 합니다. 파이프라인에 넣는 방법은 GitHub Actions 글에서 다룬 job 구성과 같습니다.

마무리

이미지 최적화는 순서만 지키면 어렵지 않습니다. 멀티스테이지로 JDK를 걷어내고, 베이스를 줄이고, 레이어를 분리합니다. 앞의 둘은 크기를 줄이고, 마지막 하나는 배포 속도를 줄입니다.

다만 목적을 혼동하지 마십시오. 수백 MB를 깎는 것 자체가 목표가 아니라 배포가 빨라지고 취약점이 줄어드는 것이 목표입니다. 그 기준에서 보면 가장 효과가 큰 작업은 레이어 분리이고, 가장 위험한 선택은 검증 없는 Alpine 전환입니다.

함께 보면 좋은 글