500MB JAR에서 80MB 바이너리로 — GraalVM native image와 컨테이너 배포

Spring Boot 애플리케이션을 서버리스(AWS Lambda)에 배포하려 한다. JVM 웜업에 3초, 첫 요청에 5초가 걸린다. 콜드 스타트가 치명적인 서버리스 환경에서 이것은 사용할 수 없는 수준이다. GraalVM native image로 빌드하면 — 시작 시간이 50ms로 줄고, 메모리 사용량이 1/5로 감소한다.

이 글은 Docker 컨테이너 빌드, GraalVM AOT 컴파일, layered JAR, graceful shutdown, 그리고 Spring Boot 4.0의 AOT 강화를 풀어간다. (Spring Boot Reference - Container Images)

컨테이너 빌드 — Dockerfile 패턴

기본 Dockerfile (JVM 모드)

# Spring Boot 4 / Java 25 — 기본 Dockerfile
FROM eclipse-temurin:25-jre AS builder
WORKDIR /app
COPY gradlew .
COPY gradle/ gradle/
COPY build.gradle settings.gradle .
COPY src/ src/
RUN ./gradlew bootJar --no-daemon

FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Layered JAR 최적화 Dockerfile

# Spring Boot 4 / Java 25 — Layered JAR (캐시 최적화)
FROM eclipse-temurin:25-jre AS builder
WORKDIR /app
COPY build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

FROM eclipse-temurin:25-jre
WORKDIR /app
# 의존성 계층 (변경 빈도 낮음 → 캐시 적중)
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
# 애플리케이션 계층 (변경 빈도 높음)
COPY --from=builder /app/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Layered JAR는 의존성과 애플리케이션 코드를 분리하여 Docker 빌드 캐시 효율을 극대화한다. 코드를 수정해도 의존성 계층이 캐시에 남아 빌드가 빠르다. (Spring Boot - Container Images)

Buildpacks (Spring Boot 권장)

# Spring Boot 4 — Buildpacks로 자동 Docker 이미지 생성
./gradlew bootBuildImage --imageName=myapp:latest
# Dockerfile 없이 Cloud Native Buildpacks가 자동으로 최적화된 이미지 생성

Buildpacks는 Dockerfile 작성 없이 자동으로 이미지를 만든다 — layered 구조, 보안 패치, 기본 이미지 관리를 자동화. Spring Boot 팀이 권장하는 배포 방식이다. (Spring Boot - Buildpacks)

GraalVM Native Image — 서버리스/마이크로서비스의 게임 체인저

# Spring Boot 4 / GraalVM 25 — native image 빌드
./gradlew nativeCompile
# build/native/nativeCompile/myapp 실행 파일 생성
# GraalVM native image Dockerfile
FROM ghcr.io/graalvm/native-image-community:25 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew nativeCompile

FROM debian:bookworm-slim
COPY --from=builder /app/build/native/nativeCompile/myapp /app/myapp
EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
# 이미지 크기: ~80MB (vs JVM 500MB+)
# 시작 시간: ~50ms (vs JVM 3s)
항목 JVM 모드 GraalVM Native Image
시작 시간 2~5초 ~50ms
메모리 사용 200~500MB 40~80MB
이미지 크기 300~500MB ~80MB
피크 성능 높음 (JIT 런타임 최적화) 보통 (AOT 정적 최적화)
리플렉션 전체 지원 제한적 (설정 필요)
빌드 시간 빠름 느림 (분석 + 컴파일)
적합 장기 실행 서버 서버리스, CLI, 마이크로서비스

GraalVM native image는 실행 파일(바이너리)을 생성한다 — JVM 없이 실행된다. Spring Boot 3.0+부터 AOT(Ahead-Of-Time) 처리를 통해 native image를 공식 지원한다. Spring Boot 4.0은 GraalVM 25와 새 reachability metadata 포맷으로 더 강화됐다. (Spring Boot - GraalVM)

왜 JVM 없이 실행될 수 있는가? GraalVM의 native-image 도구는 Java 바이트코드를 분석하여 네이티브 기계어로 컴파일한다. 이때 "이 프로그램이 런타임에 어떤 클래스를 로드하고, 어떤 메서드를 리플렉션으로 호출하고, 어떤 프록시를 생성하는지"를 빌드 시점에 정적 분석(closed-world analysis)한다.

비유: JVM은 실시간 통역사다 — 코드를 읽으면서 그때그때 기계어로 통역한다. GraalVM native image는 미리 번역된 책이다 — 통역사 없이도 읽을 수 있지만, 내용이 고정되어 있어서 현장에서 즉석 번역(JIT)의 유연성은 없다. 통역사가 없으니 시작이 빠르고 메모리가 적게 든다.

Spring Boot의 AOT 처리: Spring Boot 4.0은 빌드 시 AOT 엔진을 실행하여 — (1) 모든 bean 정의를 분석하고 (2) 리플렉션으로 접근할 클래스/메서드를 식별하여 (3) reflect-config.json, resource-config.json 등의 reachability metadata 파일을 자동 생성한다. GraalVM이 이 파일을 읽어 "어떤 클래스를 바이너리에 포함할지" 결정한다. 사용자가 수동으로 리플렉션 설정을 작성할 필요가 없다 — Spring AOT가 대신한다.

제약: 정적 분석은 "런타임에 동적으로 로드되는 클래스"를 예측하지 못할 수 있다. Class.forName(variableName)처럼 변수로 클래스를 로드하는 코드는 GraalVM이 미리 알 수 없다. 이런 코드는 빌드 시 에러가 나거나 런타임에 실패한다. Spring의 핵심 컴포넌트(DI, AOP, JPA proxy 등)는 AOT 처리로 대부분 해결되지만, 타사 라이브러리의 동적 로딩은 별도 검증이 필요하다.

Native Image의 제약

// Spring Boot 4 / Java 25 — Native Image에서 작동하지 않는 패턴
// 1. 동적 프록시 (런타임 클래스 생성) → AOT 처리 시 미리 생성
// 2. 리플렉션 → reachability metadata에 명시 필요
// 3. 동적 class loading → Native Image에서 지원 안 됨
// 4. JNI / 외부 라이브러리 → 별도 설정 필요

// Spring Boot 4.0의 AOT 엔진이 대부분을 자동 처리한다:
// - Spring bean의 리플렉션 메타데이터 자동 생성
// - 동적 프록시 사전 생성
// - 리소스 접근 경로 명시

Spring Boot 4.0의 AOT 엔진이 빌드 시점에 bean 정의를 분석하여 native image에 필요한 메타데이터(Hint)를 자동 생성한다. 대부분의 Spring 애플리케이션은 추가 설정 없이 native image로 빌드할 수 있다. (Spring Boot - AOT)

Graceful Shutdown — 안전한 종료

# Spring Boot 4 / Java 25 — application.yml
server:
  shutdown: graceful   # SIGTERM 시 진행 중인 요청 완료 후 종료

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s   # 최대 30초 대기

graceful shutdown은 컨테이너 종료(SIGTERM) 시 진행 중인 요청을 완료한 후 종료한다. Kubernetes Rolling Update에서 이전 버전 파드가 새 요청을 거부하면서 기존 요청은 완료한다. (Spring Boot - Graceful Shutdown)

Kubernetes 배포 매니페스트

# K8s Deployment (Spring Boot 4)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: myapp
          image: myapp:latest
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: prod
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: password
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          # 헬스 체크
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
      # 종료 유예 기간 (graceful shutdown 대기)
      terminationGracePeriodSeconds: 30

Spring Boot 4.0 — AOT Method Profiling과 배포

Spring Boot 4.0은 Java 25의 JEP-515 (AOT Method Profiling)와 결합하여 더 빠른 시작을 제공한다:

배포 방식 시작 시간 웜업 이미지 크기 용도
JVM (전통) 3초 필요 400MB 장기 실행 서버
JVM + AOT Profiling (JEP-515) 1초 최소 400MB Java 25 서버
GraalVM Native Image 50ms 불필요 80MB 서버리스, CLI

JEP-515의 AOT Method Profiling은 JVM 시작 시 프로파일 데이터를 캐시에서 로드하여 JIT 컴파일을 즉시 활성화한다. Native Image가 아직 적합하지 않은 장기 실행 서버에서 시작 시간을 줄인다. (Java SE 장 25 참조)

실습 — 배포 패턴 비교

// Spring Boot 4 / Java 25 — DeploymentPatternDemo.java
public class DeploymentPatternDemo {
    public static void main(String[] args) {
        System.out.println("=== 배포 패턴 비교 ===");
        System.out.println();

        String[][] patterns = {
            {"JVM (전통)", "3초", "400MB", "장기 실행 서버"},
            {"JVM + AOT (JEP-515)", "1초", "400MB", "Java 25 서버"},
            {"GraalVM Native", "50ms", "80MB", "서버리스/CLI"}
        };

        System.out.printf("%-25s %-12s %-12s %s%n", "패턴", "시작", "이미지", "적합");
        System.out.println("-".repeat(70));
        for (String[] p : patterns) {
            System.out.printf("%-25s %-12s %-12s %s%n", p[0], p[1], p[2], p[3]);
        }

        System.out.println();
        System.out.println("선택 기준:");
        System.out.println("  - 장기 실행 + 피크 성능 → JVM");
        System.out.println("  - 빠른 시작 + 저메모리 → GraalVM Native Image");
        System.out.println("  - 서버리스 (콜드 스타트) → GraalVM Native Image");
    }
}
java DeploymentPatternDemo.java
=== 배포 패턴 비교 ===

패턴                      시작          이미지       적합
----------------------------------------------------------------------
JVM (전통)               3초          400MB       장기 실행 서버
JVM + AOT (JEP-515)      1초          400MB       Java 25 서버
GraalVM Native           50ms         80MB        서버리스/CLI

선택 기준:
  - 장기 실행 + 피크 성능 → JVM
  - 빠른 시작 + 저메모리 → GraalVM Native Image
  - 서버리스 (콜드 스타트) → GraalVM Native Image

확인할 것: 용도에 따라 JVM과 Native Image를 선택한다. 장기 실행 서버는 JVM의 JIT 최적화가 유리하고, 서버리스/CLI는 Native Image의 빠른 시작이 유리하다.

CI/CD 파이프라인 — Spring Boot 배포 자동화

# GitHub Actions 예 (개념)
# jobs:
#   build:
#     steps:
#       - ./gradlew bootJar           # JAR 빌드
#       - docker build -t myapp:${{ github.sha }} .  # Docker 이미지
#       - docker push myapp:${{ github.sha }}        # 레지스트리 푸시
#   deploy:
#     steps:
#       - kubectl set image deployment/myapp myapp=myapp:${{ github.sha }}  # K8s 배포
#       - kubectl rollout status deployment/myapp   # 롤아웃 대기

CI/CD 배포 원칙:

  1. 동일한 JAR을 모든 환경에 배포 — 환경별 차이는 환경 변수로만
  2. 이미지 태그는 Git SHA 사용myapp:abc1234 (재현 가능)
  3. 헬스 체크 기반 롤아웃 — 새 버전이 healthy할 때까지 이전 버전 유지
  4. 자동 롤백 — 헬스 체크 실패 시 이전 버전으로 복구

요약 — 이 글의 결론

  • Layered JAR + Docker로 컨테이너 이미지를 최적화한다. 의존성과 애플리케이션 계층을 분리하여 Docker 빌드 캐시 효율을 극대화.
  • Buildpacks로 Dockerfile 없이 이미지를 생성한다. Spring Boot 팀이 권장. 보안 패치, 기본 이미지 관리를 자동화.
  • GraalVM Native Image로 시작 시간 50ms, 메모리 80MB를 달성한다. 서버리스, CLI, 마이크로서비스에 적합. 단, 빌드 시간이 길고 리플렉션에 제약.
  • server.shutdown=graceful로 안전한 종료를 구현한다. SIGTERM 시 진행 중인 요청을 완료한 후 종료. K8s Rolling Update와 결합.
  • Spring Boot 4.0은 Java 25의 AOT Method Profiling(JEP-515)과 결합하여 JVM 모드의 시작 시간도 단축한다. Native Image와 JVM 사이의 선택 폭이 넓어졌다.

생각해 볼 문제

  1. GraalVM Native Image에서 Hibernate 지연 로딩(proxy)이 작동하는가? 어떤 제약이 있는가?
  2. Docker 멀티 스테이지 빌드에서 빌더 스테이지와 런타임 스테이지를 분리하는 이유는?
  3. Spring Boot 4.0 AOT 처리가 빌드 시점에 생성하는 "reachability metadata"는 구체적으로 무엇인가?
  4. Kubernetes에서 terminationGracePeriodSeconds=30과 Spring Boot의 timeout-per-shutdown-phase=30s가 다른 경우 어떻게 되는가?
  5. GraalVM Native Image 빌드 시 --initialize-at-build-time--initialize-at-run-time의 차이와 영향은?
  6. Spring Boot 4.0의 모듈화된 JAR 구조가 Docker 이미지 빌드에 미치는 영향은?

참고

  1. Spring Boot 4.0과 이전 버전(Spring Boot 3.x)의 가장 큰 차이점은 무엇인가? 마이그레이션 시 주의할 점은?
  2. 이 글에서 다룬 개념을 실제 프로젝트에 적용할 때 가장 먼저 고려해야 할 것은?

Spring Boot DevTools — 개발 생산성

spring-boot-devtools는 개발 중 자동 재시작(live reload), 캐시 비활성화, 원격 디버깅을 제공한다. 프로덕션에는 포함되지 않는다 (dev scope 권장). 자동 재시작은 클래스패스 변경을 감지하여 애플리케이션을 빠르게 재시작한다 — 전체 재시작보다 5-10배 빠르다.

'Develop Artifacts > Spring Boot' 카테고리의 다른 글

SpringBoot - 09. config profile  (0) 2026.07.12
SpringBoot - 08. actuator  (0) 2026.07.12
SpringBoot - 07. security  (0) 2026.07.12
SpringBoot - 06. testing  (0) 2026.07.12
SpringBoot - 05. spring-data-jpa  (0) 2026.07.12