2026-02-04
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.kts에 org.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.java*Service__BeanDefinitions.java*Repository__BeanDefinitions.java*RepositoryImpl__AotRepository.java@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()}")}
기타 제약사항java -jarjava -Dspring.aot.enabled=true -jarorg.springframework.boot.aot 플러그인2025-09-09
@Aspect
@Component
@Slf4j
public class PerformanceAspect {
@Around("@annotation(Monitored)")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
return result;
} finally {
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
log.info("Method: {}, Elapsed Time: {}ms",
joinPoint.getSignature().getName(), elapsedTime);
}
}
}
서비스 메서드java@Service
public class UserService {
@Monitored
@Transactional
public void createUser(UserCreateRequest request) {
List<User> users = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
users.add(new User("user" + i, "user" + i + "@example.com"));
}
userRepository.saveAll(users);
}
}
예상 vs 실제 측정 결과@Order 어노테이션이나 명시적인 우선순위 설정이 없다면, 프록시 생성 순서에 따라 실행 순서가 결정된다.@Order 미설정 시 실제 동작:@Transactional의 기본 Order: Ordered.LOWEST_PRECEDENCE (Integer.MAX_VALUE)Ordered.LOWEST_PRECEDENCE (동일한 값)@Transactional의 기본 Order 값은 Ordered.LOWEST_PRECEDENCE(최대값)로 설정되어 있다. 따라서 커스텀 AOP가 트랜잭션보다 먼저 시작하고 나중에 끝나도록 하려면 더 낮은 값을 설정해야 한다.java@Aspect
@Component
@Order(100) // 확실하게 order 를 설정
@Slf4j
public class PerformanceAspect {
@Around("@annotation(Monitored)")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
return result;
} finally {
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
log.info("Method: {}, Elapsed Time: {}ms",
joinPoint.getSignature().getName(), elapsedTime);
}
}
}
실행 순서 변경 후:@Transactional의 기본값: Ordered.LOWEST_PRECEDENCE (Integer.MAX_VALUE)@Aspect
@Component
@Order(100)
@Slf4j
public class PerformanceAspect {
// ... 성능 측정 로직
}
@Order(100) 설정으로 트랜잭션 커밋 시간까지 포함된 정확한 성능 측정이 가능해졌다.이제 HTTP response 시간과 유사한 측정값을 얻을 수 있어, 대용량 배치 처리 시 실제 소요 시간을 정확히 파악할 수 있게 되었다.결론AOP를 활용한 성능 측정 시 프록시 순서를 반드시 고려해야 한다.특히 대용량 데이터 처리나 배치 작업의 경우, 트랜잭션 커밋 시간이 전체 실행 시간에서 상당한 비중을 차지할 수 있다. 이 시간을 놓치면 실제 성능과 크게 다른 잘못된 측정값을 얻게 된다.html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}2025-08-15
int나 long 타입으로 밀리초나 초 단위로 설정했지만, 이는 가독성과 유지보수성 측면에서 여러 문제점을 가지고 있었다.Spring Boot에서 제공하는 Duration 타입을 활용하면 이러한 문제를 깔끔하게 해결할 수 있다.기존 방식의 문제점단위가 불명확한 설정# 이 값이 초인지 밀리초인지 명확하지 않음
my-app:
request:
timeout: 5000
// 코드에서도 단위를 추측해야 함
@Value("${my-app.request.timeout}")
private long timeout; // 5000이 5초인지 5000초인지?
단위 변환의 번거로움// 설정값이 초 단위일 때 밀리초로 변환
long timeoutInMillis = timeoutInSeconds * 1000;
// 다양한 단위로 변환할 때마다 계산 필요
long timeoutInMinutes = timeoutInSeconds / 60;
Duration을 사용한 해결책명확한 단위 표기my-app:
request:
timeout: 10s # 10초
# timeout: 5000ms # 5000밀리초
# timeout: 2m # 2분
# timeout: 1h # 1시간
Duration은 다음과 같은 단위를 지원한다:ns - 나노초us - 마이크로초ms - 밀리초s - 초m - 분h - 시간d - 일package com.example.demo.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("my-app.request")
public record TimeoutConfiguration(
Duration timeout
) {
public TimeoutConfiguration {
if(timeout == null) {
timeout = Duration.ofSeconds(10);
}
if (timeout.isNegative()) {
throw new IllegalArgumentException("Timeout must be positive");
}
}
}
Service에서 활용하기package com.example.demo.service;
import com.example.demo.config.TimeoutConfiguration;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class MyService {
private final TimeoutConfiguration timeoutConfiguration;
public void doSomething() {
Duration timeout = timeoutConfiguration.timeout();
log.info("Doing something with timeout. {}ms, {}s, {}h",
timeout.toMillis(),
timeout.getSeconds(),
timeout.toHours());
}
}
다양한 단위로 변환Duration 객체는 편리한 변환 메서드들을 제공한다:Duration timeout = Duration.ofSeconds(30);
long millis = timeout.toMillis(); // 30000
long seconds = timeout.getSeconds(); // 30
long minutes = timeout.toMinutes(); // 0
long hours = timeout.toHours(); // 0
html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}