Spring Boot 4 AOT Repositories로 앱 시작 시간 줄이기 — 쿼리 파싱을 빌드 타임에
Spring Data JPA 4.0의 AOT 리포지토리가 쿼리 메서드를 빌드 타임에 미리 만들어 시작 시 리플렉션 파싱을 없애는 원리를, 공식 문서와 예제를 직접 돌려보며 정리했습니다.

Spring Boot 4.0 릴리스 노트를 넘겨보다 "AOT Repositories" 한 줄에서 멈췄습니다. 처음엔 GraalVM 네이티브 이미지 얘기인 줄 알고 지나치려 했는데, 일반 JVM에서도 시작 시간을 줄여 준다는 문장을 보고 다시 읽었습니다.
솔직히 말하면 이 기능은 아직 제 운영 서비스에 올려본 적이 없습니다. 4.0으로 갓 들어온 신기능이라, 검증되지 않은 수치를 지어내는 대신 공식 문서(Spring Data JPA 4.0 레퍼런스, spring.io 블로그)를 읽고 예제 프로젝트를 직접 빌드해 생성물을 뜯어본 과정을 그대로 남깁니다. 그래서 이 글은 "이만큼 빨라졌다"는 자랑이 아니라 "무엇이 언제 어떻게 생성되는가"에 대한 정리에 가깝습니다.
앱이 뜰 때 리포지토리는 무슨 일을 하나
findByEmailAndStatus 같은 메서드 이름 하나만 적어두면 쿼리가 알아서 만들어지는 게 Spring Data의 매력입니다. 그런데 그 편리함에는 대가가 있습니다. 애플리케이션이 뜰 때(정확히는 리포지토리 프록시를 초기화할 때) Spring Data가 메서드 이름을 파싱하고, 리플렉션으로 프로퍼티를 확인하고, JPQL을 조립합니다.
리포지토리가 몇 개 안 되면 티도 안 납니다. 하지만 도메인이 커져 리포지토리가 수십, 수백 개가 되면 이 파싱·리플렉션 작업이 시작 시간에 차곡차곡 쌓입니다. 게다가 이 계산은 매번 앱이 뜰 때마다 똑같이 반복됩니다. 이미 소스 코드에 다 적혀 있어서 빌드 시점에 결과가 정해지는데도 말이죠.
AOT Repositories는 바로 이 지점을 노립니다. 빌드 타임에 미리 알 수 있는 쿼리는 빌드 타임에 만들어 두자는 것입니다.
AOT가 하는 일 — 파싱을 빌드 타임으로 옮긴다
Spring Data AOT 엔진은 빌드 단계에서 리포지토리 인터페이스를 훑어(introspect) 두 가지를 만들어 냅니다.
- 쿼리 메서드를 실제로 구현한 자바 소스 파일
- 각 메서드의 시그니처와 최종 쿼리 문자열을 담은 JSON 메타데이터
이렇게 만들어진 구현체가 런타임에 프록시 대신 꽂히면, 앱이 뜰 때 이름을 다시 파싱할 필요가 없습니다. 문서 표현을 빌리면, 파생·@Query·named 쿼리처럼 "빌드 타임에 이미 아는" 쿼리 메서드를 미리 구현해 두어, 시작할 때마다 리플렉션으로 분석하던 작업을 없애는 것입니다.
켜는 법 — 사실 따로 켤 스위치가 없다
여기서 제가 처음에 오해했던 부분입니다. 여러 블로그가 "spring.aot.repositories.enabled=true로 켜라"고 적어놨는데, 공식 레퍼런스를 보면 반대에 가깝습니다. AOT 처리 자체가 켜지면(네이티브 이미지이거나 spring.aot.enabled=true이면) AOT 리포지토리는 기본으로 함께 켜집니다. 저 프로퍼티는 켜는 용도가 아니라 끄는 용도입니다.
Gradle이면 processAot 태스크가, Maven이면 spring-boot:process-aot 골이 빌드 타임 생성을 담당합니다.
# 빌드 타임에 AOT 소스를 생성
./gradlew processAot
# 생성된 리포지토리 구현이 여기에 떨어집니다
ls build/generated/aotSources/dev/srue/coffee/
# 예: CoffeeRepositoryImpl__AotRepository.javaMaven이라면 생성 경로만 다릅니다.
./mvnw spring-boot:process-aot
ls target/spring-aot/main/sources/dev/srue/coffee/일반 JVM으로 실행할 때는 런타임에 spring.aot.enabled=true를 줘야 생성된 구현이 실제로 쓰입니다. 네이티브 이미지는 AOT가 필수라 자동으로 적용됩니다.
끄고 싶을 때 쓰는 프로퍼티는 이렇게 나뉩니다.
# 모든 Spring Data 모듈의 AOT 리포지토리 생성 끄기
spring.aot.repositories.enabled=false
# JPA 모듈만 끄기
spring.aot.jpa.repositories.enabled=false
# JSON 메타데이터 생성 여부 (기본 on, repositories.enabled에 종속)
spring.aot.repositories.metadata.enabled=true생성된 코드를 직접 뜯어봤다
가장 궁금했던 건 "대체 뭘 만들어 주길래"였습니다. 생성된 클래스 이름은 <리포지토리 이름>Impl__AotRepository 규칙을 따르고, 원본 인터페이스와 같은 패키지에 놓입니다. 예를 들어 CoffeeRepository의 파생 쿼리들은 CoffeeRepositoryImpl__AotRepository에 담깁니다.
참고로 2025년 5월 마일스톤(M3) 블로그에는 접미사가
Impl__Aot로 소개돼 있는데, GA(4.0)에서는Impl__AotRepository로 확정됐습니다. 옛 글을 보고 파일 이름을 찾으면 안 나올 수 있어 짚어둡니다.
메서드 하나가 어떻게 풀리는지, 레퍼런스 예제를 기준으로 보면 이런 모양입니다.
public List<OwnerSummary> findAllByLastName(String lastName) {
String queryString = "SELECT o.firstName AS firstName, o.lastName AS lastName "
+ "FROM Owner o WHERE o.lastName = :lastName";
Query query = this.entityManager.createQuery(queryString, Tuple.class);
query.setParameter("lastName", lastName);
return (List<OwnerSummary>) convertMany(query.getResultList(), false, OwnerSummary.class);
}메서드 이름을 파싱해 만들던 JPQL이 그냥 문자열 상수로 박혀 있고, EntityManager.createQuery(...)를 직접 호출합니다. 시작 시점에 이름을 해석할 일이 사라진 겁니다. JPA 모듈은 이 메타데이터를 뽑을 때 인메모리 Hibernate를 쓰거나, spring.aot.jpa.repositories.use-entitymanager=true로 실제 EntityManagerFactory를 쓰도록 바꿀 수 있습니다.
같이 생성되는 JSON은 이렇게 생겼습니다.
{
"name": "dev.srue.coffee.CoffeeRepository",
"module": "JPA",
"type": "IMPERATIVE",
"methods": [
{
"name": "findByOriginOrderByNameAsc",
"signature": "...findByOriginOrderByNameAsc(java.lang.String)",
"query": {
"query": "SELECT c FROM Coffee c WHERE c.origin = :origin ORDER BY c.name ASC"
}
}
]
}핵심 함정: 모든 메서드가 AOT 대상은 아니다
이게 이 기능에서 제일 오해하기 쉬운 부분이라고 느꼈습니다. AOT는 "빌드 타임에 쿼리가 확정되는" 메서드만 미리 만듭니다. 실행 시점에 조건이 정해지는 동적 쿼리류는 대상이 아니고, 자동으로 런타임 방식으로 폴백합니다. 앱은 정상 동작하되, 그 메서드에 한해서는 AOT 이득이 없다는 뜻입니다.
| 메서드 유형 | AOT 생성 |
|---|---|
파생 쿼리 (findByOrigin...) | O |
@Query / @NativeQuery | O |
Named Query, @Procedure | O |
@Modifying (반환 void/int) | O |
Page·Slice·Stream·Optional 반환 | O |
| 인터페이스/DTO 프로젝션 | O |
CrudRepository 기본 메서드(save 등) | X (기본 구현 사용) |
| Querydsl / Query by Example / Specification | X |
ScrollPosition 키셋 페이지네이션 | X |
| 동적(dynamic) 프로젝션 | X |
| 커스텀 구현 fragment | X |
| 리액티브 리포지토리 | X (imperative만 지원) |
정리하면, JpaSpecificationExecutor로 조건을 조립하거나 Query by Example, Querydsl에 크게 의존하는 리포지토리라면 AOT가 건드릴 게 별로 없습니다. 반대로 파생 쿼리와 @Query가 주력인 평범한 CRUD 리포지토리일수록 이득이 큽니다. 팀 코드가 어느 쪽에 가까운지부터 보는 게 순서였습니다.
한 가지 더. AOT는 쿼리를 미리 만들어 둘 뿐, 실행 시점의 SQL 동작 자체를 바꾸지 않습니다. 그래서 JPA N+1 문제를 잡던 때처럼 실행 시점에 연관을 따라가며 쿼리가 폭증하는 문제는 AOT로 사라지지 않습니다. 시작 파싱 비용과 런타임 쿼리 설계는 완전히 다른 층위의 문제라는 걸 다시 확인했습니다.
뜻밖의 수확: 빌드 타임 쿼리 검증
돌려보다 정작 마음에 든 건 시작 시간이 아니라 이 JSON 메타데이터였습니다. 각 쿼리 메서드의 최종 쿼리가 빌드 산출물에 텍스트로 남으니, 파생 메서드 이름의 오타나 존재하지 않는 프로퍼티 참조를 빌드/테스트 단계에서 잡아낼 여지가 생깁니다.
예를 들어 선언한 리포지토리 메서드 수와 JSON에 실제로 처리된 메서드 수를 비교하는 테스트를 두면, 런타임에야 터지던 PropertyReferenceException류를 앞당겨 발견할 수 있습니다.
@Test
void 선언한_쿼리_메서드가_모두_AOT로_처리됐는지_확인() {
// build/classes 또는 target/classes 옆의 CoffeeRepository.json 을 읽어
var processed = extractAotMethodSignatures("dev/srue/coffee/CoffeeRepository.json");
var declared = declaredQueryMethods(CoffeeRepository.class);
// 폴백(리스트 밖)으로 빠진 메서드가 의도한 것인지 점검
assertThat(declared).allMatch(processed::containsKey);
}물론 이건 프레임워크가 강제하는 검증은 아니라 직접 짜야 합니다. 그래도 "쿼리가 어디에 어떻게 확정됐는지"가 파일로 남는다는 것만으로 디버깅 관점이 하나 늘었습니다.
그래서 얼마나 빨라지나 — 기대와 한계
여기서부터는 정직하게 적겠습니다. 시작 시간 개선폭은 리포지토리·쿼리 메서드 수에 비례하고, 가장 큰 이득은 네이티브 이미지에서 리플렉션을 걷어낼 때 나옵니다. 리포지토리가 열 개 남짓인 작은 앱에서 극적인 차이를 기대하면 실망할 수 있습니다.
제 사이드 프로젝트에 실제로 적용해 before/after를 측정하기 전까지는, 남의 벤치마크 숫자를 제 것처럼 옮기지 않으려 합니다.
한계도 분명합니다. GA 기준으로 AOT 리포지토리가 지원되는 모듈은 JPA(Hibernate 경유)와 MongoDB가 중심이고, 위 표처럼 상당수 동적 메서드는 여전히 런타임 폴백입니다. AOT·비AOT 메서드가 한 리포지토리에 섞이면 "이 메서드는 미리 만들어졌나?"를 매번 신경 써야 하는 인지 비용도 생깁니다. 도입한다면 JSON 메타데이터로 실제 처리 여부를 확인하는 습관이 사실상 필수라고 느꼈습니다.
마무리
이번엔 운영 장애를 겪고 원인을 쫓은 글이 아니라, 신기능을 문서와 예제로 먼저 익힌 기록입니다. 그래서 배운 것도 성격이 다릅니다. 예전엔 새 기능을 보면 "성능이 얼마나 오르나"부터 궁금했는데, 이번엔 "무엇이 언제 확정되고, 무엇이 여전히 런타임에 남는가"를 먼저 그려보는 게 훨씬 쓸모 있었습니다. AOT는 마법이 아니라, 빌드 타임에 이미 알 수 있는 계산을 앞당기는 정직한 최적화였습니다.
당장 운영에 넣을 생각은 없습니다. 다만 다음에 Spring Boot 4로 버전을 올릴 때 시작 시간이 눈에 밟히면, 그때 파생 쿼리 비중을 확인하고 processAot 산출물을 뜯어보는 것부터 시작할 겁니다. 그리고 숫자는 빌려 오는 게 아니라 반드시 제 손으로 재본 뒤에 적겠습니다.