Spring Boot Docker 최적화 가이드: 520MB 이미지를 145MB로 줄이기까지

이번 포스팅에서는 Spring Boot Docker 이미지를 최적화하는 방법에 대해서 정리하고자 한다. 기본 설정으로 Spring Boot 애플리케이션을 Docker에 올리면 500MB가 넘는 이미지가 만들어지고, 코드 한 줄만 바꿔도 전체 레이어를 다시 빌드해야 한다. 처음에는 별 문제 없어 보이지만, Kubernetes에서 Pod가 수십 개씩 뜨기 시작하면 이미지 풀 시간과 디스크 비용이 눈에 띄게 불어난다. 기본적인 Spring Boot Docker 배포 방법을 이미 알고 있다는 전제하에, 레이어드 JAR 분리부터 Buildpacks, AOT Cache, 베이스 이미지 선택, JVM 메모리 튜닝까지 하나씩 적용해 보면서 Spring Boot Docker 이미지를 운영 수준으로 다듬어 본다.

Spring Boot Docker 이미지, 왜 최적화해야 하는가

Spring Boot 프로젝트를 mvn package로 빌드하면 모든 의존성이 하나로 묶인 fat JAR이 생성된다. 이 JAR을 그대로 Docker 이미지에 넣으면 문제가 생각보다 빨리 드러난다.

가장 먼저 체감하는 것은 이미지 크기다. JDK 기반 이미지에 fat JAR을 통째로 올리면 500MB를 가볍게 넘긴다. Kubernetes 클러스터에서 520MB짜리 Spring Boot Docker 이미지를 운영했던 한 사례에서는 이미지 풀에만 30초가 걸렸고, JVM 초기화까지 합치면 Pod 하나 뜨는 데 45초가 소요되었다.

그다음으로 불편한 것은 Docker 레이어 캐시를 전혀 쓰지 못한다는 점이다. fat JAR은 단일 레이어에 코드와 의존성이 전부 들어가 있어서, 소스 코드 한 줄만 고쳐도 수백 MB 분량의 레이어를 처음부터 다시 빌드해야 한다.

보안도 빠뜨릴 수 없다. 기본 Docker 컨테이너는 root(UID 0)로 실행되는데, 2025년 기준 컨테이너 보안 사고의 28%가 root 권한 컨테이너의 권한 상승에서 비롯되었다.

이 글에서 다루는 최적화를 적용하면 이미지 크기를 520MB에서 145MB 수준으로 줄이고, 코드만 변경했을 때 재빌드 시간을 수 초대로 낮출 수 있다.

fat JAR을 쪼개면 빌드가 빨라진다

Spring Boot는 JAR 파일을 4개 레이어로 쪼개는 기능을 내장하고 있다. 변경이 잦은 코드와 거의 바뀌지 않는 의존성을 분리해서, Docker가 바뀌지 않은 레이어를 캐시에서 재사용하도록 만드는 방식이다.

레이어 구조 이해하기

Spring Boot Docker 이미지의 레이어 구조는 다음과 같다.

레이어내용변경 빈도
dependencies릴리스된 외부 라이브러리거의 변경 없음
spring-boot-loaderSpring Boot 클래스 로더거의 변경 없음
snapshot-dependenciesSNAPSHOT 버전 라이브러리가끔 변경
application애플리케이션 코드와 리소스매 빌드마다 변경

이 구조 덕분에 애플리케이션 코드만 수정하면 Docker는 하위 3개 레이어를 캐시에서 그대로 가져오고, 최상위 application 레이어만 다시 만든다. 의존성이 수백 MB인 프로젝트에서 이 차이는 상당하다.

jarmode로 레이어 추출하기

Spring Boot 4.x에서는 jarmode=tools 옵션으로 JAR의 레이어를 확인하고 추출할 수 있다.

# 레이어 목록 확인
java -Djarmode=tools -jar target/my-app.jar list-layers

# 레이어별로 디렉토리 추출
java -Djarmode=tools -jar target/my-app.jar extract --layers --destination extracted
ShellScript

위 명령을 실행하면 extracted 디렉토리 아래에 dependencies, spring-boot-loader, snapshot-dependencies, application 네 개의 하위 디렉토리가 생성된다. 각 디렉토리에는 해당 레이어에 포함될 파일들이 분리되어 있다.

멀티스테이지 Dockerfile 작성

레이어를 추출했으면 멀티스테이지 빌드로 최적화된 Spring Boot Docker 이미지를 만들 수 있다.

# 1단계: 빌드 스테이지 - 레이어 추출
FROM bellsoft/liberica-openjre-debian:21-cds AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
# jarmode로 레이어를 분리 추출한다
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

# 2단계: 런타임 스테이지 - 레이어 순서대로 복사
FROM bellsoft/liberica-openjre-debian:21-cds
WORKDIR /application
# 변경 빈도가 낮은 레이어부터 복사하여 캐시 효율을 높인다
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]
Dockerfile

이 Dockerfile은 두 단계로 구성된다. 첫 번째 스테이지에서 JAR 파일의 레이어를 추출하고, 두 번째 스테이지에서는 변경 빈도가 낮은 레이어부터 순서대로 복사한다. 이렇게 하면 의존성에 변화가 없는 한 dependenciesspring-boot-loader 레이어는 Docker 캐시에서 재사용되므로, 실제로 새로 빌드되는 것은 application 레이어뿐이다.

빌드 명령은 다음과 같다.

# Maven으로 JAR 빌드 후 Docker 이미지 생성
mvn clean package -DskipTests
docker build --build-arg JAR_FILE=target/my-app-0.0.1-SNAPSHOT.jar -t my-app:latest .
ShellScript

--build-arg로 JAR 파일 경로를 지정한다. Gradle을 사용한다면 JAR_FILE=build/libs/*.jar로 변경하면 된다.

Dockerfile이 귀찮다면 Buildpacks

Dockerfile을 직접 관리하기 번거롭다면 Cloud Native Buildpacks가 좋은 대안이다. Spring Boot는 Maven과 Gradle 플러그인에 Buildpacks 지원을 내장하고 있어서, 명령 하나로 최적화된 Spring Boot Docker 이미지를 만들 수 있다.

# Maven으로 OCI 이미지 빌드
mvn spring-boot:build-image

# Gradle로 OCI 이미지 빌드
gradle bootBuildImage
ShellScript

내부적으로 Paketo Spring Boot buildpack이 레이어드 JAR 분리와 베이스 이미지 선택을 알아서 처리한다. Dockerfile을 직접 작성하거나 관리하지 않아도 된다.

빌드 시 이미지 이름이나 빌더를 커스터마이징하려면 pom.xml에 설정을 추가한다.

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <!-- 이미지 이름을 지정한다 -->
            <name>registry.example.com/my-app:${project.version}</name>
            <!-- 환경 변수로 JVM 옵션을 설정할 수 있다 -->
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
            </env>
        </image>
    </configuration>
</plugin>
XML

위 설정에서 BP_JVM_VERSION 환경 변수로 사용할 JVM 버전을 지정한다. Buildpacks는 항상 최신 패치 버전의 JDK를 사용하므로 보안 업데이트를 별도로 관리할 필요가 줄어든다.

한 가지 주의할 점이 있다. Buildpacks는 재현 가능한 빌드를 위해 파일의 “마지막 수정 시간” 메타데이터를 조작한다. 정적 리소스 캐싱에 파일 수정 시간을 사용하는 경우 application.properties에서 이를 비활성화해야 한다.

# Buildpacks 사용 시 정적 리소스 캐싱에서 수정 시간을 사용하지 않도록 설정
spring.web.resources.cache.use-last-modified=false

이 설정을 빠뜨리면 Buildpacks로 빌드한 이미지에서 정적 리소스 캐시가 깨질 수 있다. 배포 후에야 알게 되면 원인 파악이 꽤 번거롭다.

Docker 데몬 없이 빌드하고 싶다면 Jib

Buildpacks가 Dockerfile을 없앴다면, Google Jib은 여기서 한 발 더 나아간다. Jib은 Docker 데몬 자체가 필요 없다. Gradle이나 Maven 플러그인만 추가하면 빌드 과정에서 곧바로 최적화된 OCI 이미지를 만들어 레지스트리에 푸시한다. CI/CD 파이프라인에서 Docker-in-Docker 설정으로 골치 아팠던 경험이 있다면, Jib이 그 고민을 깔끔하게 해결해 준다.

Jib이 자동으로 나누는 4개 레이어

Jib은 애플리케이션을 분석해서 의존성, 리소스, 클래스 파일을 자동으로 분리한다. fat JAR을 통째로 넣는 게 아니라 변경 빈도에 따라 레이어를 구성하기 때문에, 코드만 수정하면 최상위 레이어 하나만 다시 빌드된다.

레이어내용변경 빈도
Layer 1JDK/JRE 런타임거의 없음
Layer 2의존성 JAR (libs/)가끔
Layer 3리소스 파일 (resources/)종종
Layer 4애플리케이션 클래스 (classes/)매번

이 구조 덕분에 일상적인 코드 수정에서는 수십 MB짜리 의존성 레이어를 건드리지 않고 수백 KB짜리 클래스 레이어만 교체한다. 빌드 시간과 레지스트리 전송량이 눈에 띄게 줄어든다.

Gradle에서 Jib 설정하기

plugins {
    id 'com.google.cloud.tools.jib' version '3.4.4'
}

jib {
    from {
        image = 'eclipse-temurin:21-jre-alpine'
    }
    to {
        image = 'registry.example.com/my-app'
        tags = ['latest', project.version]
    }
    container {
        jvmFlags = ['-XX:+UseG1GC', '-XX:MaxRAMPercentage=75.0']
        ports = ['8080']
        user = '1000:1000'
        creationTime = 'USE_CURRENT_TIMESTAMP'
    }
}
Groovy

from.image에 JRE 기반 Alpine 이미지를 지정하면 결과물 크기가 바로 줄어든다. container.user를 1000:1000으로 설정하면 non-root로 실행되고, creationTime을 USE_CURRENT_TIMESTAMP로 잡아야 이미지 생성 시각이 제대로 기록된다. 기본값인 epoch(1970-01-01)이 찍히면 모니터링 도구에서 혼란이 생기기 쉽다.

Maven에서 Jib 설정하기

<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>3.4.4</version>
    <configuration>
        <from>
            <image>eclipse-temurin:21-jre-alpine</image>
        </from>
        <to>
            <image>registry.example.com/my-app</image>
        </to>
        <container>
            <jvmFlags>
                <flag>-XX:+UseG1GC</flag>
                <flag>-XX:MaxRAMPercentage=75.0</flag>
            </jvmFlags>
            <ports><port>8080</port></ports>
            <user>1000:1000</user>
        </container>
    </configuration>
</plugin>
XML

Maven에서도 설정 구조는 동일하다. from.image로 베이스 이미지를 지정하고, container 블록에서 JVM 플래그와 포트, 실행 사용자를 설정한다. Gradle과 달리 XML 기반이라 약간 장황하지만, 빌드 결과물은 완전히 동일한 OCI 이미지가 나온다.

세 가지 빌드 명령어

# 1. Docker 데몬 없이 레지스트리에 직접 푸시
./gradlew jib

# 2. 로컬 Docker 데몬에 빌드
./gradlew jibDockerBuild

# 3. tar 파일로 내보내기
./gradlew jibBuildTar
Bash

./gradlew jib이 핵심이다. 이 명령은 Docker가 설치되지 않은 환경에서도 동작한다. GitHub Actions에서 Docker 설정 없이 바로 빌드-푸시가 가능하므로 CI 파이프라인이 단순해진다. 로컬에서 테스트할 때는 jibDockerBuild로 Docker 이미지를 직접 확인하면 된다.

Spring Boot Docker 빌드 전략 비교: Layered JAR vs Buildpacks vs Jib

항목Layered JAR + DockerfileBuildpacksJib
Dockerfile 필요O (직접 작성)XX
Docker 데몬 필요OOX
레이어 분리수동 설정자동자동 (4레이어)
빌드 속도보통느림가장 빠름
CI/CD 친화성보통보통최고
커스터마이징완전 자유제한적플러그인 설정
CDS/AOT 적용가능가능추가 설정 필요

Jib은 빌드 속도와 CI 편의성에서 확실한 우위를 가진다. 다만 CDS나 AOT Cache 같은 JVM 레벨 최적화를 적용하려면 extraDirectories 설정으로 별도 파일을 이미지에 주입하거나 커스텀 entrypoint를 만들어야 해서 복잡도가 올라간다. 시작 시간 최적화까지 필요한 프로젝트라면 Layered JAR + Dockerfile 조합이 더 유연하고, 빌드 파이프라인 단순화가 우선이면 Jib이 최선의 선택이다.

시작 시간이 느리다면 AOT Cache와 CDS

이미지 크기를 줄였다면 다음 관심사는 시작 시간이다. Spring Boot 콜드 스타트 최적화에서 다룬 JVM 수준 기법 외에, Docker 빌드 단계에서 적용할 수 있는 방법이 있다. AOT(Ahead-of-Time) Cache나 CDS(Class Data Sharing)를 적용하면 JVM이 매번 반복하는 클래스 로딩 작업을 빌드 시점에 미리 처리해 둘 수 있다.

Java 24+ AOT Cache 적용

Java 24 이상에서는 AOT Cache를 활용한 Dockerfile을 작성할 수 있다.

FROM bellsoft/liberica-openjre-debian:24-cds AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM bellsoft/liberica-openjre-debian:24-cds
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
# 트레이닝 실행으로 AOT 캐시를 생성한다
RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar
# 생성된 AOT 캐시를 사용하여 시작 시간을 단축한다
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "application.jar"]
Dockerfile

-XX:AOTCacheOutput=app.aot 옵션으로 이미지 빌드 시점에 트레이닝 실행을 수행한다. -Dspring.context.exit=onRefresh는 ApplicationContext 초기화까지만 실행하고 종료하도록 하여 트레이닝 시간을 최소화한다. 런타임에서는 -XX:AOTCache=app.aot로 미리 생성된 캐시를 로딩하므로 클래스 로딩 시간이 줄어든다.

Java 21 CDS 적용

Java 21 환경이라면 CDS(Class Data Sharing)로 동일한 효과를 얻을 수 있다.

FROM bellsoft/liberica-openjre-debian:21-cds AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM bellsoft/liberica-openjre-debian:21-cds
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
# CDS 아카이브를 생성하는 트레이닝 실행
RUN java -XX:ArchiveClassesAtExit=application.jsa -Dspring.context.exit=onRefresh -jar application.jar
# CDS 아카이브를 로딩하여 시작 시간을 단축한다
ENTRYPOINT ["java", "-XX:SharedArchiveFile=application.jsa", "-jar", "application.jar"]
Dockerfile

CDS는 공유 아카이브(application.jsa)에 클래스 메타데이터를 저장하여, 다음 실행 시 클래스 로딩과 검증 과정을 건너뛸 수 있게 한다. Spring Boot 3.3 이후 버전에서 공식적으로 CDS를 지원하며, 평균 20~30%의 시작 시간 단축 효과가 있다.

베이스 이미지만 바꿔도 크기가 반으로 줄어든다

같은 JAR 파일이라도 어떤 베이스 이미지 위에 올리느냐에 따라 Spring Boot Docker 이미지 크기가 3~4배까지 차이 난다. JDK 대신 JRE를 쓰고, 경량 리눅스 배포판으로 바꾸는 것만으로도 절반 이하로 떨어진다.

# 1. 표준 JDK 기반 (약 500MB+)
FROM eclipse-temurin:21-jdk

# 2. JRE 기반 (약 250MB)
FROM eclipse-temurin:21-jre

# 3. Alpine + JRE 기반 (약 150MB)
FROM eclipse-temurin:21-jre-alpine

# 4. Liberica JRE + CDS 지원 (약 180MB, CDS/AOT 최적화 가능)
FROM bellsoft/liberica-openjre-debian:21-cds
Dockerfile

운영 환경에서는 JDK가 아닌 JRE 이미지를 써야 한다. javac나 디버깅 도구처럼 런타임에 쓸 일이 없는 것들이 빠지면서 이미지가 가벼워지고, 공격에 이용될 수 있는 도구도 함께 사라진다.

더 극단적인 최적화가 필요하면 jlink로 커스텀 JRE를 만들 수 있다.

# jlink로 필요한 모듈만 포함한 커스텀 JRE를 만든다
FROM eclipse-temurin:21-jdk AS jre-builder
RUN jlink \
    --add-modules java.base,java.logging,java.sql,java.naming,java.management,java.instrument,java.desktop,java.security.jgss \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=zip-6 \
    --output /custom-jre

FROM debian:bookworm-slim
COPY --from=jre-builder /custom-jre /opt/java
ENV PATH="/opt/java/bin:${PATH}"
WORKDIR /application
# 이후 레이어드 JAR 복사 과정은 동일
Dockerfile

jlink는 애플리케이션이 실제로 사용하는 JVM 모듈만 골라서 커스텀 JRE를 만든다. --strip-debug--compress=zip-6(JDK 21+ 문법, JDK 17에서는 --compress=2) 옵션으로 디버그 심볼을 제거하고 파일을 압축하면 약 143MB 수준까지 줄일 수 있다. 위 모듈 목록은 최소 구성 예시이며, 실제 Spring Boot 웹 애플리케이션에서는 java.xml, jdk.unsupported(Netty 등 내부에서 sun.misc.Unsafe 사용), jdk.crypto.ec(TLS EC 암호화) 등이 추가로 필요할 수 있다. 반드시 jdeps 명령으로 의존 모듈을 먼저 분석한 뒤 목록을 확정해야 한다.

# jdeps로 애플리케이션의 실제 모듈 의존성을 분석한다
jdeps --ignore-missing-deps --print-module-deps target/my-app.jar
ShellScript

이 명령의 출력을 --add-modules에 그대로 넣으면 누락 없이 커스텀 JRE를 만들 수 있다.

root로 돌리면 안 되는 이유와 non-root 설정

앞서 “왜 최적화해야 하는가” 섹션에서 root 실행 문제를 언급했다. 실제로 Spring Boot Docker 컨테이너를 운영에 올릴 때 non-root 설정을 빠뜨리는 경우가 의외로 많다. 애플리케이션에 취약점이 하나라도 있으면 root 권한이 공격자에게 그대로 넘어갈 수 있다.

FROM bellsoft/liberica-openjre-debian:21-cds AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM bellsoft/liberica-openjre-debian:21-cds
# 전용 사용자와 그룹을 생성한다
RUN groupadd -r appgroup && useradd -r -g appgroup -d /application -s /sbin/nologin appuser

WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./

# 파일 소유권을 appuser로 변경한다
RUN chown -R appuser:appgroup /application

# non-root 사용자로 전환한다
USER appuser

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "application.jar"]
Dockerfile

useradd -r 옵션은 시스템 사용자를 생성하며, -s /sbin/nologin으로 셸 접근을 차단한다. USER appuser 지시어 이후의 모든 명령은 이 사용자 권한으로 실행된다. 이렇게 하면 컨테이너 내부에서 공격자가 root 권한을 획득하는 경로가 차단된다.

Spring Boot Docker 컨테이너 OOM Kill을 막는 JVM 메모리 튜닝

Spring Boot Docker 컨테이너에서 JVM 메모리 설정을 빠뜨리면 꽤 당황스러운 상황을 맞을 수 있다. 컨테이너에 512MB 제한을 걸어 놨는데, JVM이 호스트 전체 메모리(예를 들면 64GB)를 보고 힙을 잡으려 하면서 OOM Kill이 터진다. 로그만 봐서는 왜 죽었는지 감이 안 오는 경우가 많다.

ENTRYPOINT ["java", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:InitialRAMPercentage=50.0", \
    "-XX:+UseG1GC", \
    "-jar", "application.jar"]
Dockerfile

-XX:MaxRAMPercentage=75.0은 컨테이너에 할당된 메모리의 75%를 JVM 힙의 최대값으로 설정한다. 나머지 25%는 메타스페이스, 스레드 스택, 네이티브 메모리 등 힙 외 영역에 사용된다. -XX:InitialRAMPercentage=50.0은 초기 힙 크기를 50%로 잡아 시작 시 불필요한 메모리 확장을 줄인다.

메모리가 극도로 제한된 환경(256MB 이하)에서는 G1GC 대신 SerialGC를 사용하여 GC 오버헤드를 줄이는 방법도 있다.

# docker-compose.yml 예시
services:
  my-app:
    image: my-app:latest
    deploy:
      resources:
        limits:
          memory: 512M
    environment:
      # JAVA_TOOL_OPTIONS는 JVM이 자동으로 읽는 환경 변수이다
      # JAVA_OPTS는 java 명령이 직접 읽지 않으므로 주의
      JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
YAML

위 docker-compose 설정에서 컨테이너 메모리 제한을 512MB로 설정하고, JAVA_TOOL_OPTIONS 환경 변수로 JVM 옵션을 전달한다. JAVA_OPTS가 아닌 JAVA_TOOL_OPTIONS를 사용하는 이유는, JAVA_OPTS는 Tomcat 같은 애플리케이션 서버의 관례일 뿐 JVM 자체가 읽는 변수가 아니기 때문이다. java -jar로 직접 실행하는 경우 JAVA_OPTS는 무시된다. Kubernetes Helm으로 배포를 관리하는 경우라면 Pod의 resources.limits.memory와 함께 동일하게 설정한다.

컨테이너 메모리 제한과 JVM 힙 설정은 반드시 짝으로 지정해야 한다. 이 둘이 어긋나면 OOM Kill이 간헐적으로 발생하거나, 반대로 할당해 놓은 메모리가 놀면서 클러스터 자원이 낭비된다.

다 합치면 이렇게 된다: Spring Boot Docker 운영용 Dockerfile

지금까지 다룬 기법을 모두 적용한 운영 수준의 Dockerfile을 정리한다.

# ============================================
# Spring Boot Docker 최적화 - 운영용 Dockerfile
# ============================================

# 1단계: 레이어 추출
FROM bellsoft/liberica-openjre-debian:21-cds AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

# 2단계: 운영 이미지 구성
FROM bellsoft/liberica-openjre-debian:21-cds

# non-root 사용자 생성
RUN groupadd -r appgroup && useradd -r -g appgroup -d /application -s /sbin/nologin appuser

WORKDIR /application

# 레이어 캐시 최적화 순서로 복사
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./

# 파일 소유권을 먼저 변경하고 non-root로 전환한다
RUN chown -R appuser:appgroup /application
USER appuser

# CDS 아카이브를 non-root 사용자로 생성한다
# 런타임과 동일한 사용자 컨텍스트에서 생성해야 경로 불일치를 방지한다
RUN java -XX:ArchiveClassesAtExit=application.jsa \
    -Dspring.context.exit=onRefresh \
    -jar application.jar

EXPOSE 8080

# JVM 메모리 튜닝 + CDS 적용
ENTRYPOINT ["java", \
    "-XX:SharedArchiveFile=application.jsa", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:InitialRAMPercentage=50.0", \
    "-XX:+UseG1GC", \
    "-jar", "application.jar"]
Dockerfile

이 Dockerfile 하나로 레이어드 JAR 분리, CDS 시작 시간 최적화, non-root 보안 설정, JVM 메모리 튜닝이 모두 적용된다. 이미지 크기는 약 200MB 내외가 되며, 재빌드 시 애플리케이션 코드 변경만 반영되므로 빌드 시간도 수 초 수준으로 줄어든다.

마치며

지금까지 Spring Boot Docker 이미지를 최적화하는 방법에 대해서 정리해 보았다.

개인적으로는 Buildpacks와 멀티스테이지 빌드를 프로젝트마다 골라 쓰는 편이다. 사내 신규 서비스를 빠르게 찍어내야 할 때는 Buildpacks가 편하고, CI/CD에서 이미지 레이어를 세밀하게 제어해야 하는 운영 서비스라면 Dockerfile을 직접 관리하는 쪽이 오히려 속이 편했다. 다만 어떤 방식을 택하든 레이어 분리와 JVM 메모리 튜닝만큼은 빠뜨리지 않는 것이 좋다. 이 두 가지가 빠지면 나머지 최적화의 효과가 반감된다.

참고 자료: