2026-03-16
Spring Boot를 GraalVM Native Image로 빌드해봤다 — 삽질과 결과
사이드 프로젝트로 만든 Spring Boot API 서버가 있다. 간단한 CRUD에 인증 정도 붙인 수준인데, AWS Lambda에 올렸더니 콜드스타트가 8초를 찍었다. 8초. 사용자가 API 호출하고 8초를 기다려야 첫 응답이 온다. 이건 서비스가 아니라 인내심 테스트다.
그래서 GraalVM Native Image를 시도해봤다. 결론부터 말하면, 콜드스타트는 극적으로 줄었지만 그 과정은 극적으로 고통스러웠다.
이 글에서 사용한 전체 코드는 GitHub에 올려뒀다.
왜 Native Image인가
일반적인 Spring Boot 앱은 JVM 위에서 돌아간다. JVM은 훌륭하지만, 시작할 때 클래스 로딩, JIT 컴파일, 빈 초기화 같은 작업을 거치면서 시간이 걸린다. 서버리스 환경에서는 이 시작 시간이 곧 사용자 경험이다.
GraalVM Native Image는 이 문제를 근본적으로 해결한다. 빌드 시점에 애플리케이션을 정적으로 분석해서, 필요한 코드만 골라 네이티브 바이너리로 컴파일한다. JVM 없이 바로 실행되니 시작 시간이 밀리초 단위로 줄어든다.
이론은 아름답다. 문제는 현실이다.
환경 세팅
사용한 스택은 이렇다.
Spring Boot 3.5.x
GraalVM 25 (JDK 25 LTS 기반)
Native Build Tools 0.10.6
JDK 25는 2025년 9월에 릴리즈된 최신 LTS다. GraalVM 25도 동시에 나왔는데, 주목할 만한 변화가 있다. Oracle이 GraalVM을 Java SE 제품에서 분리하면서, 대신 JDK 자체에 AOT 컴파일 기능을 내장하기 시작했다. JEP 514(Ahead-of-Time Command-Line Ergonomics)와 JEP 515(Ahead-of-Time Method Profiling)가 JDK 25에 포함됐다.
즉, GraalVM Native Image의 핵심 가치가 점차 JDK 표준으로 흡수되고 있다는 뜻이다. 장기적으로는 GraalVM 없이도 네이티브 빌드가 가능해질 수 있다. 하지만 지금 당장은 여전히 GraalVM이 가장 성숙한 선택지다.
build.gradle 설정:
plugins {
id 'org.graalvm.buildtools.native' version '0.10.6'
}
graalvmNative {
binaries {
main {
imageName = 'my-api'
buildArgs.add('--enable-preview')
}
}
}첫 빌드: 바로 터짐
첫 ./gradlew nativeCompile을 돌렸더니 빌드만 4분 37초. 그리고 실행하자마자 이런 에러가 날아왔다.
Caused by: com.oracle.svm.core.jdk.UnsupportedFeatureError:
Proxy class defined by interfaces [interface org.springframework.data.jpa.repository.JpaRepository]
not found. Generating proxy classes at image build time requires configuration.Native Image의 근본적인 한계는 리플렉션이다. JVM에서는 런타임에 클래스를 동적으로 로딩하고, 프록시를 만들고, 리플렉션으로 필드에 접근하는 게 자유롭다. 하지만 Native Image는 빌드 시점에 모든 것을 결정해야 한다. 런타임에 "이 클래스 있어?" 하고 물어보는 건 불가능하다.
Spring은 역사적으로 리플렉션의 왕국이다. DI, AOP, JPA 프록시, 트랜잭션 프록시... 전부 리플렉션이다. 이걸 Native Image와 궁합을 맞추는 게 핵심 과제다.
해결 과정: 하나씩 잡아가기
1. Reachability Metadata
GraalVM은 리플렉션, 프록시, 직렬화 등에 대한 메타데이터를 JSON 파일로 요구한다. Spring Boot 3.x부터는 대부분의 Spring 모듈에 대한 메타데이터가 기본 제공된다. 문제는 서드파티 라이브러리다.
tracing-agent를 사용해서 JVM 모드로 앱을 실행한 뒤, 런타임에 사용되는 리플렉션 호출을 자동으로 수집할 수 있다.
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar build/libs/my-api.jar이렇게 하면 reflect-config.json, proxy-config.json 등이 자동 생성된다. 만능은 아니지만, 수작업보다 백배 낫다.
2. JPA 프록시 문제
JPA 엔티티의 Lazy Loading은 런타임 프록시에 의존한다. Native Image에서는 이게 문제가 된다. 해결책은 두 가지였다.
Lazy → Eager로 바꾸기 (성능 트레이드오프 있음)
@RegisterReflectionForBinding으로 엔티티 클래스를 명시적으로 등록
나는 엔티티가 많지 않아서 Eager로 바꾸는 게 현실적이었다. 대규모 프로젝트라면 이 선택이 달라질 수 있다. 엔티티 코드를 보면 관련 주석을 남겨뒀다.
3. Jackson 직렬화
Jackson이 JSON 직렬화할 때도 리플렉션을 쓴다. DTO 클래스들을 reflect-config.json에 등록하거나, Record 타입을 사용하면 컴파일 타임에 처리되어 더 깔끔하다.
// Record 타입은 Native Image와 궁합이 좋다
public record UserResponse(Long id, String name, String email) {}JDK 25에서는 Record가 완전히 성숙한 기능이라, 새 프로젝트에서는 DTO를 Record로 만드는 게 Native Image 호환성 면에서도 이점이 크다.
GraalVM 25의 새로운 점
GraalVM 25에서 몇 가지 주목할 변화가 있다.
WP-SCCP(Whole-Program Sparse Conditional Constant Propagation)이 기본 활성화됐다. 정적 분석의 정밀도가 올라가서 바이너리 크기가 줄어든다.
GraalNN(뉴럴 네트워크 기반 최적화)이 O2, O3 레벨에서 1~3% 성능 향상과 1% 이상 바이너리 크기 감소를 제공한다.
Compact Object Headers가 JDK 25에서 정식 포함되어 메모리 사용량이 더 줄어든다.
체감상 이전 GraalVM 버전에 비해 빌드 안정성이 많이 좋아진 느낌이다. Spring Boot 3.5 + GraalVM 25 조합에서는 기본 Spring 모듈에 대해서는 별도 메타데이터 설정 없이도 대부분 동작했다.
결과 비교
삽질 끝에 빌드가 성공하고, 실제 Lambda에 배포해서 비교해봤다.
항목JVM (Corretto 25)Native Image (GraalVM 25)콜드스타트~8.2초~0.3초Warm 응답~15ms~12ms메모리 사용~256MB~62MB빌드 시간~18초~4분 37초바이너리 크기~45MB (JAR)~78MB
콜드스타트 8.2초 → 0.3초. 27배 개선이다. 메모리도 4분의 1 수준으로 줄었다. GraalVM 25의 Compact Object Headers 덕분에 이전 버전보다 메모리가 더 절약됐다. 대신 빌드 시간은 15배 늘었고, 바이너리 크기는 오히려 커졌다.
벤치마크 상세 결과는 BENCHMARK.md에 정리해뒀다.
언제 쓰고, 언제 쓰지 말아야 하나
몇 주 써보고 내린 결론이다.
쓸 만한 경우:
서버리스 환경 (Lambda, Cloud Run)에서 콜드스타트가 치명적일 때
CLI 도구처럼 빠른 시작이 필요한 경우
마이크로서비스가 단순하고 리플렉션 의존이 적을 때
쓰지 말아야 할 경우:
리플렉션이 많은 레거시 프로젝트 (마이그레이션 비용이 감당 안 됨)
항상 떠 있는 서버 (ECS, Kubernetes — JVM의 JIT 최적화가 장기 성능에서 유리)
빌드 시간이 CI/CD 병목이 되는 환경
디버깅이 자주 필요한 개발 초기 단계
JDK 25 AOT와 GraalVM의 미래
흥미로운 점은 JDK 25에 AOT 관련 JEP이 포함되면서, GraalVM의 핵심 가치가 JDK 표준으로 흡수되고 있다는 것이다. Oracle도 GraalVM을 Java SE에서 분리하는 방향으로 움직이고 있다.
장기적으로는 java --aot 같은 형태로 GraalVM 없이도 네이티브 수준의 시작 성능을 얻을 수 있게 될 가능성이 높다. 하지만 지금 당장은 GraalVM 25가 가장 성숙하고 안정적인 선택지다.
결국 Native Image는 "모든 Spring Boot 앱에 적용하세요"가 아니라 "특정 상황에서 강력한 카드"다. 콜드스타트가 문제라면 시도해볼 가치가 충분하다. 다만, 기존 프로젝트에 적용하려면 리플렉션 지옥을 각오해야 한다.
개인적으로는 새 프로젝트를 시작할 때 JDK 25 LTS + Spring Boot 3.5 + GraalVM 25 조합으로, 처음부터 Native Image를 고려하고 설계하는 게 가장 현실적인 접근이라고 느꼈다. 이미 돌아가는 앱을 전환하는 건... 음, 주말 두 번은 날려야 한다.
이 글에서 사용한 예제 프로젝트 전체 소스는 spring-graalvm-demo에서 확인할 수 있다.
관련 글
벡터 유사도 기반Java 8에서 25까지 — 단계적 마이그레이션으로 보일러플레이트 900줄 제거한 이야기
Java 8 + Spring Boot 2.x 레거시를 Java 25 + Spring Boot 3.5로 단계적 마이그레이션하며 겪은 실전 경험을 공유합니다.
73% 일치Neon DB + Vercel 무료 플랜으로 사이드 프로젝트 운영하기
Vercel Hobby + Neon 무료 PostgreSQL로 블로그를 운영하면서 겪은 cold start, 크론 제한, 함수 타임아웃 등의 삽질 기록.
70% 일치스프링 내부 구조 정리
스프링의 핵심 내부 구조를 실제 코드와 테스트를 통해 정리합니다. IoC 컨테이너, Bean 생명주기, AOP, 의존성 주입 방식을 다룹니다.