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 배포 원칙:
- 동일한 JAR을 모든 환경에 배포 — 환경별 차이는 환경 변수로만
- 이미지 태그는 Git SHA 사용 —
myapp:abc1234(재현 가능) - 헬스 체크 기반 롤아웃 — 새 버전이 healthy할 때까지 이전 버전 유지
- 자동 롤백 — 헬스 체크 실패 시 이전 버전으로 복구
요약 — 이 글의 결론
- 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 사이의 선택 폭이 넓어졌다.
생각해 볼 문제
- GraalVM Native Image에서 Hibernate 지연 로딩(proxy)이 작동하는가? 어떤 제약이 있는가?
- Docker 멀티 스테이지 빌드에서 빌더 스테이지와 런타임 스테이지를 분리하는 이유는?
- Spring Boot 4.0 AOT 처리가 빌드 시점에 생성하는 "reachability metadata"는 구체적으로 무엇인가?
- Kubernetes에서
terminationGracePeriodSeconds=30과 Spring Boot의timeout-per-shutdown-phase=30s가 다른 경우 어떻게 되는가? - GraalVM Native Image 빌드 시
--initialize-at-build-time과--initialize-at-run-time의 차이와 영향은? - Spring Boot 4.0의 모듈화된 JAR 구조가 Docker 이미지 빌드에 미치는 영향은?
참고
- Spring Boot 4.0 - Container Images - 접근 2026-07-10
- Spring Boot 4.0 - GraalVM Native Image - 접근 2026-07-10
- Spring Boot 4.0 - Graceful Shutdown - 접근 2026-07-10
- GraalVM 25 Native Image - 접근 2026-07-10
- Spring Boot 4.0 Release - 접근 2026-07-10
- Spring Boot 4.0 - Buildpacks - 접근 2026-07-10
- Cloud Native Buildpacks - 접근 2026-07-10
- Spring Boot 4.0과 이전 버전(Spring Boot 3.x)의 가장 큰 차이점은 무엇인가? 마이그레이션 시 주의할 점은?
- 이 글에서 다룬 개념을 실제 프로젝트에 적용할 때 가장 먼저 고려해야 할 것은?
Spring Boot DevTools — 개발 생산성
spring-boot-devtools는 개발 중 자동 재시작(live reload), 캐시 비활성화, 원격 디버깅을 제공한다. 프로덕션에는 포함되지 않는다 (dev scope 권장). 자동 재시작은 클래스패스 변경을 감지하여 애플리케이션을 빠르게 재시작한다 — 전체 재시작보다 5-10배 빠르다.
- Spring Boot 4.0 Reference - 접근 2026-07-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 |
