Avatar
@deuk9

deuk9 의 공유 공간

Recent Posts

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

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.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.412s21.994s2.208s31.913s2.253s평균1.949s2.291sRepository 21개로 늘리자 평균 0.34초, 약 15% 차이가 발생했다. Repository 1개일 때의 0.065초 대비 5배 이상 차이가 커졌다.JAR 크기 영향항목크기AOT 생성 소스 (197개)1.1MBAOT 생성 클래스 (63개)504KBJAR 전체54MBAOT가 추가하는 용량은 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 모드빈 등록런타임 리플렉션빌드 시 생성된 BeanDefinitionRepository런타임 동적 Proxy빌드 시 생성된 구현체GraalVM 필요 여부-불필요실행 방식java -jarjava -Dspring.aot.enabled=true -jar추가 설정없음org.springframework.boot.aot 플러그인GraalVM Native Image의 호환성 제약 없이, 플러그인 하나와 실행 플래그 하나로 시작 속도를 개선할 수 있다. Repository가 많은 프로젝트일수록 효과가 크다.html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}

    AOP 로깅에서 실수하기 쉬운 실행 순서 문제

    2025-09-09

    최근에 로직 수행에 걸린 시간에 관련해서 로그를 보고있었는데, 생각과 다르게 로그가 찍히고 있는걸 발견했다. 이상함을 느껴 조사해본 결과, @Transactional과의 실행 순서 문제로 인해 실제 데이터베이스 커밋 시간이 측정에서 제외되고 있었다는 것을 발견했다.이 글에서는 해당 문제의 원인과 해결 방법을 공유한다.문제 상황기존 AOP 성능 측정 코드java@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 실제 측정 결과
  • HTTP response 시간은 5초 정도 소요되었다. 특별한 로직이 없기 때문에 DB 작업도 5초와 비슷하게 나와야 한다.
  • 그런데 로그상에서는 0.5초로 기록되어 있다.
  • 원인 분석Spring AOP 프록시 실행 순서@Order 어노테이션이나 명시적인 우선순위 설정이 없다면, 프록시 생성 순서에 따라 실행 순서가 결정된다.@Order 미설정 시 실제 동작:
  • @Transactional의 기본 Order: Ordered.LOWEST_PRECEDENCE (Integer.MAX_VALUE)
  • 커스텀 AOP의 기본 Order: Ordered.LOWEST_PRECEDENCE (동일한 값)
  • 같은 Order 값일 때는 프록시 생성 순서에 따라 실행 순서가 결정됨
  • 문제가 된 실행 순서:
  • @Transactional (트랜잭션 시작)
  • @Around AOP (성능 측정 시작)
  • 실제 메서드 실행
  • @Around AOP (성능 측정 종료) ← 여기서 측정 완료
  • @Transactional (트랜잭션 커밋) ← 이 시간이 누락됨
  • 해결 방법@Order 어노테이션 활용@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); } } } 실행 순서 변경 후:
  • @Around AOP (성능 측정 시작) ← Order 100
  • @Transactional (트랜잭션 시작) ← Order 최대값
  • 실제 메서드 실행
  • @Transactional (트랜잭션 커밋) ← 먼저 끝남
  • @Around AOP (성능 측정 종료) ← 나중에 끝남
  • Order 값 이해하기:
  • Order 값이 낮을수록 높은 우선순위 (먼저 시작하고 나중에 끝남)
  • Order 값이 높을수록 낮은 우선순위 (나중에 시작하고 먼저 끝남)
  • @Transactional의 기본값: Ordered.LOWEST_PRECEDENCE (Integer.MAX_VALUE)
  • 검증 결과수정 후 측정 결과java@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}

    Spring Boot Configuration에서 Duration 사용하기

    2025-08-15

    개발을 하다보면 타임아웃, 지연 시간, 캐시 만료 시간 등 다양한 시간 관련 설정을 다뤄야 할 때가 많다.기존에는 intlong 타입으로 밀리초나 초 단위로 설정했지만, 이는 가독성과 유지보수성 측면에서 여러 문제점을 가지고 있었다.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 - 일
  • Duration 사용의 장점가독성 향상: 설정값만 보고도 의미를 명확히 파악 가능유연성: 다양한 시간 단위를 자유롭게 사용타입 안전성: 컴파일 타임에 타입 체크편리한 변환: 다양한 단위로 쉽게 변환 가능Configuration Properties 구현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}