GraalVM 없이 Spring AOT로 JVM 시작 속도 최적화하기

By deuk9

Spring AOT = GraalVM Native Image?

Spring AOT라고 하면 GraalVM Native Image(exe 만들기)를 떠올리기 쉽다. 하지만 Native Image를 만들지 않고, 일반 JVM 위에서 AOT의 장점만 가져올 수 있다.

이 글에서는 Spring Boot 4.0.2 + Gradle 환경에서 Spring Data AOT Repositories를 적용하고, 실제 벤치마크를 통해 성능 차이를 확인한다.

핵심 원리

기존 방식 (Runtime)

애플리케이션 시작  
  -> 컴포넌트 스캔 (리플렉션)  
  -> Repository 인터페이스 발견  
  -> 동적 Proxy 생성  
  -> 빈 등록  

AOT 방식 (Build-time)

빌드 시점  
  -> 실제 구현체 클래스 생성 (XXXRepositoryImpl__AotRepository.java)  -> BeanDefinition 클래스 생성 (XXX__BeanDefinitions.java)  
애플리케이션 시작  
  -> 미리 만들어진 클래스를 바로 사용  
  -> 리플렉션, Proxy 생성 과정 생략  

빌드할 때 실제 구현체와 빈 정의 클래스를 미리 생성해두고, 런타임에는 이것을 바로 사용한다. 리플렉션과 동적 Proxy 생성이 사라지므로 구동 속도가 빨라지고 메모리를 적게 먹는다.

적용 방법 (Gradle Kotlin DSL)

1. 플러그인 추가

build.gradle.ktsorg.springframework.boot.aot 플러그인을 추가한다.

plugins {  
    java    
    id("org.springframework.boot") version "4.0.2"    
    id("io.spring.dependency-management") version "1.1.7"    
    id("org.springframework.boot.aot") version "4.0.2"  // 추가  
}

이 플러그인이 processAot 태스크를 등록한다. org.graalvm.buildtools.native 플러그인을 쓸 수도 있지만, Native Image 관련 설정이 함께 들어온다. JVM에서만 쓸 거라면 org.springframework.boot.aot만 적용하는 것이 깔끔하다.

2. 빌드

./gradlew clean bootJar

빌드 로그에서 processAot 태스크가 실행되는 것을 확인할 수 있다.

3. 실행

java -Dspring.aot.enabled=true -jar build/libs/my-app.jar

-Dspring.aot.enabled=true 플래그가 있어야 Spring Boot가 AOT 생성 코드를 사용한다. 없으면 AOT 클래스가 JAR에 포함되어 있어도 기존 방식으로 동작한다.

4. 적용 확인

AOT가 활성화되면 시작 로그에서 여러 차이를 확인할 수 있다.

시작 메시지

# 일반 모드  
Starting SpringAotApplication using Java 17...  
  
# AOT 모드  
Starting AOT-processed SpringAotApplication using Java 17...  

AOT-processed 문구가 뜨면 적용된 것이다.

Repository 스캔 로그

# 일반 모드 - 런타임에 Repository를 스캔하고 Proxy를 생성한다  
Bootstrapping Spring Data JPA repositories in DEFAULT mode.  
Finished Spring Data repository scanning in 19 ms. Found 21 JPA repository interfaces.  
  
# AOT 모드 - 이 로그가 아예 없다. 빌드 시 이미 구현체가 만들어져 있기 때문이다.  

Context 초기화 시간

# 일반 모드  
Root WebApplicationContext: initialization completed in 489 ms  
  
# AOT 모드  
Root WebApplicationContext: initialization completed in 266 ms  

AOT가 생성하는 파일들

빌드 후 build/generated/aotSources 디렉토리를 보면 도메인마다 아래 파일들이 생성된다.

파일역할
*Controller__BeanDefinitions.javaController 빈 정의를 미리 생성
*Service__BeanDefinitions.javaService 빈 정의를 미리 생성
*Repository__BeanDefinitions.javaRepository 빈 정의를 미리 생성
*RepositoryImpl__AotRepository.javaRepository 실제 구현체를 미리 생성

Repository뿐 아니라 Controller, Service 등 모든 Spring 빈에 대해 BeanDefinition 클래스가 생성된다.

벤치마크

테스트 환경

  • Spring Boot 4.0.2
  • Java 25.0.2
  • H2 인메모리 DB
  • 21개 도메인 (Entity + Repository + Service + Controller)

Repository 1개 (소규모)

모드시작 시간Context 초기화
AOT1.712s266ms
일반1.777s489ms

전체 시간 차이는 0.065초지만, Context 초기화 시간은 266ms vs 489ms로 AOT가 약 45% 빠르다.

Repository 21개 (중규모)

회차AOT 모드일반 모드
11.939s2.412s
21.994s2.208s
31.913s2.253s
평균1.949s2.291s

Repository 21개로 늘리자 평균 0.34초, 약 15% 차이가 발생했다. Repository 1개일 때의 0.065초 대비 5배 이상 차이가 커졌다.

JAR 크기 영향

항목크기
AOT 생성 소스 (197개)1.1MB
AOT 생성 클래스 (63개)504KB
JAR 전체54MB

AOT가 추가하는 용량은 JAR 대비 약 1% 수준이다. 파일 수가 많아 보이지만 각 클래스가 빈 등록 코드 몇 줄 수준이라 실질적 영향은 없다.

주의사항

@Profile, @Conditional은 빌드 시점에 결정된다

AOT에서 가장 주의할 점이다. @Profile로 분기하는 빈이 있을 때, 빌드 시점의 프로파일에 해당하는 빈만 BeanDefinition이 생성된다.

예를 들어 아래 두 서비스가 있다고 하자.

@Service  
@Profile("prod")  
public class ProdGreetingService implements GreetingService {  
    public String greet() { return "PROD 환경입니다"; }  
}  
  
@Service  
@Profile("dev")  
public class DevGreetingService implements GreetingService {  
    public String greet() { return "DEV 환경입니다"; }  
}

prod 프로파일로 빌드하면 AOT는 ProdGreetingService__BeanDefinitions.java만 생성한다. DevGreetingService는 빈 등록 코드 자체가 존재하지 않는다.

# prod로 빌드  
./gradlew clean bootJar -Dspring.profiles.active=prod

이 JAR를 dev 프로파일로 실행해도 결과는 PROD 환경입니다다.

# dev로 실행해도 prod 빈이 사용된다  
$ java -Dspring.aot.enabled=true -Dspring.profiles.active=dev -jar app.jar  
$ curl http://localhost:8080/greeting  
PROD 환경입니다

AOT 모드에서 프로파일을 바꾸려면 해당 프로파일로 다시 빌드해야 한다. Gradle에서 프로파일을 processAot 태스크에 전달하려면 아래 설정이 필요하다.

tasks.named<org.springframework.boot.gradle.tasks.aot.ProcessAot>("processAot") {  
    val profile = providers.systemProperty("spring.profiles.active").orElse("default")    jvmArgs("-Dspring.profiles.active=${profile.get()}")}

기타 제약사항

  • Lambda로 정의된 빈은 아직 AOT를 지원하지 않는다
  • AOT 모드로 빌드한 후 빈 구성을 변경하면 반드시 다시 빌드해야 한다

정리

항목일반 모드AOT 모드
빈 등록런타임 리플렉션빌드 시 생성된 BeanDefinition
Repository런타임 동적 Proxy빌드 시 생성된 구현체
GraalVM 필요 여부-불필요
실행 방식java -jarjava -Dspring.aot.enabled=true -jar
추가 설정없음org.springframework.boot.aot 플러그인

GraalVM Native Image의 호환성 제약 없이, 플러그인 하나와 실행 플래그 하나로 시작 속도를 개선할 수 있다. Repository가 많은 프로젝트일수록 효과가 크다.