이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 열여섯 번째 글입니다. 15편에서는 Spring의 작은 표현식 언어 SpEL이 문자열을 어떻게 파싱하고 계산하는지 봤습니다. 이번 글은 1편 지도의 Phase 3(기반 기술)을 마무리하는 단위 U14로, 두 가지를 다룹니다. 하나는 지금까지 열다섯 편에서 계속 스쳐 지나간 유틸리티 클래스들이고, 다른 하나는 Spring 6부터 들어온 AOT(Ahead-of-Time, 미리 계산해 두기)입니다.
답하려는 질문은 네 가지입니다.
- Spring은 왜
Assert,ClassUtils,ReflectionUtils같은 유틸리티를 직접 가지고 있고, 앞 글들의 어디에서 쓰였는가 LinkedCaseInsensitiveMap,MultiValueMap,ConcurrentReferenceHashMap은 평범한Map과 무엇이 다르고 어디가 함정인가@Order와Ordered가 섞여 있을 때 순서는 어떤 규칙으로 정해지는가- AOT는 무엇을 "미리" 계산하고, 그 결과물은 어떤 모양이며, 실행할 때는 어느 길로 갈지 무엇이 정하는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-core (org.springframework.util, org.springframework.core, org.springframework.aot), spring-beans의 beans.factory.aot, spring-context의 context.aot |
| 주로 읽는 클래스 | Assert, ClassUtils, ReflectionUtils, LinkedCaseInsensitiveMap, ConcurrentReferenceHashMap, OrderComparator, AotDetector, RuntimeHints, GenerationContext, ApplicationContextAotGenerator, BeanInstanceSupplier |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
원래 공부 노트는 유틸리티 하나하나를 줄 단위로 훑지만, 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 유틸리티는 대표 몇 개를 "왜 있나, 어디서 쓰이나, 함정은 무엇인가"로만 보고, AOT는 처리 흐름의 뼈대만 따라갑니다. 뺀 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.
먼저 알아 둘 말
유틸리티 클래스는 상태 없이 static 메서드만 모아 둔 클래스입니다. Spring의 유틸리티는 거의 전부 spring-core 모듈의 org.springframework.util 패키지에 있고, 2편에서 본 것처럼 다른 모든 모듈이 spring-core를 가져다 쓰므로 어디서든 같은 도구를 씁니다.
리플렉션(reflection)은 프로그램이 실행 중에 자기 클래스의 메서드·필드·어노테이션을 들여다보고 부르는 Java 기능입니다. @Autowired가 붙은 필드를 찾아 값을 넣는 일(5편)이 전부 리플렉션입니다.
CGLIB는 실행 중에 클래스의 서브클래스를 바이트코드로 만들어 내는 라이브러리입니다. 10편에서 본 CGLIB 프록시와 8편의 @Configuration 클래스가 이것으로 만들어지고, 그 클래스 이름에는 $$가 들어갑니다.
네이티브 이미지(native image)는 GraalVM이 Java 프로그램을 JVM 없이 바로 실행되는 실행 파일로 미리 컴파일한 것입니다. 빌드할 때 "어떤 클래스가 쓰이는지"를 전부 알아야 하므로, 실행 중에 이름으로 클래스를 찾는 리플렉션이나 실행 중에 클래스를 만들어 내는 프록시가 그대로는 되지 않습니다.
AOT(Ahead-of-Time)는 "실행할 때 하던 계산을 빌드할 때 미리 해 둔다"는 뜻입니다. Spring의 AOT는 컨테이너가 시작하면서 하던 스캔·파싱·결정을 빌드 시점에 한 번 하고, 그 결과를 Java 소스 코드와 힌트로 내놓습니다.
힌트(hint)는 "실행할 때 이 클래스에 리플렉션을 쓸 것이다", "이 파일을 읽을 것이다"처럼 네이티브 이미지 빌더에게 미리 알려 주는 정보입니다. Spring에서는 RuntimeHints라는 객체에 모읍니다.
1. 왜 Spring은 자기 유틸리티를 가지고 있나
Assert, ClassUtils, StringUtils, ObjectUtils, CollectionUtils의 클래스 주석에는 똑같은 문장이 있습니다. "Mainly for internal use within the framework"(Assert 50행, ClassUtils 62행, StringUtils 46행, ObjectUtils 38행, CollectionUtils 43행). ReflectionUtils는 더 세게 "Only intended for internal use"(38행)입니다. Assert의 주석은 더 넓은 도구가 필요하면 Apache Commons Lang이나 Guava를 쓰라고까지 적어 둡니다(51–55행).
즉 이 유틸리티들은 "세상에 없는 기능"이라서가 아니라, 세 가지 이유로 프레임워크 안에 있습니다.
- 바깥 의존 없이.
spring-core는 가장 아래층 모듈이라 다른 라이브러리에 기대지 않고 자기 안에서 해결해야 합니다. - 프레임워크 특유의 사정이 있어서. CGLIB가 만든 클래스 이름 풀기, 클래스로더 고르기, 리플렉션 결과 캐시처럼 일반 유틸리티 라이브러리에는 없는 일이 섞여 있습니다.
- 모든 모듈이 같은 도구를 쓰게. 인자 검사 메시지의 모양, 빈 문자열 판정 기준 같은 것이 22개 모듈에서 똑같아집니다.
이 글에서 보는 도구와, 앞 글들에서 그 도구가 쓰인 자리. 회색은 앞 글이고 노란색은 캐시입니다.
| 유틸리티 | 하는 일 | 앞 글에서 본 자리 |
|---|---|---|
Assert |
인자·상태 검사. 틀리면 예외 | 12편의 Assert.notNull(event, "Event must not be null") |
ClassUtils |
클래스 로딩, 존재 확인, CGLIB 이름 풀기 | 2편의 isPresent, 9편·11편의 getUserClass |
ReflectionUtils |
메서드·필드 순회, 접근 허용, 리플렉션 결과 캐시 | 5편의 makeAccessible, 11편의 doWithMethods |
LinkedCaseInsensitiveMap, MultiValueMap, ConcurrentReferenceHashMap |
특수한 맵 세 가지 | 13편의 어노테이션 캐시 |
OrderComparator, AnnotationAwareOrderComparator |
Ordered·@Order로 정렬 |
5편의 후보 정렬, 11편의 어드바이저 정렬 |
Assert — 인자는 IllegalArgumentException, 상태는 IllegalStateException
@Contract("null, _ -> fail")
public static void notNull(@Nullable Object object, String message) {
if (object == null) {
throw new IllegalArgumentException(message);
}
}
spring-core/src/main/java/org/springframework/util/Assert.java 179–184행
본문은 세 줄이지만 규칙이 하나 들어 있습니다. 메서드 인자가 잘못됐으면 IllegalArgumentException(notNull, isTrue, hasText), 객체의 상태가 잘못됐으면 IllegalStateException(state, 78행)입니다. 클래스 주석도 설정 오류가 아니라 "보통 프로그래머의 실수"를 잡는 용도라고 적습니다(37–40행). 그래서 Spring 소스를 읽다가 Assert.state(...)를 만나면 "이 시점에 이미 이래야 한다"는 전제를 적어 둔 곳이라고 읽으면 됩니다. @Contract("null, _ -> fail")는 null 분석 도구에게 "첫 인자가 null이면 이 메서드는 정상 반환하지 않는다"고 알려 주는 표시입니다.
ClassUtils — 클래스 이름을 다루는 자잘한 사정들
ClassUtils.forName(name, classLoader)(283–325행)는 Class.forName을 감싼 것인데, 감싸면서 네 가지를 더 합니다. "int" 같은 기본형 이름과 자주 쓰는 클래스는 미리 채워 둔 맵에서 꺼내고(288–294행, 맵은 126행·132행), "java.lang.String[]" 같은 배열 표기를 풀고(297–301행), 클래스로더가 null이면 기본 클래스로더를 고르고(303–306행), 못 찾았는데 이름이 a.b.Outer.Inner 꼴이면 마지막 점을 $로 바꿔 중첩 클래스로 한 번 더 시도합니다(310–323행). 2편에서 본 isPresent는 이 forName을 불러 보고 예외가 나면 false를 돌려주는 것뿐입니다(모듈 가독성 불일치 IllegalAccessError 하나만 IllegalStateException으로 바꿔 던집니다)(377–390행).
이 글에서 가장 눈여겨볼 메서드는 getUserClass입니다.
public static Class<?> getUserClass(Class<?> clazz) {
if (clazz.getName().contains(CGLIB_CLASS_SEPARATOR)) {
Class<?> superclass = clazz.getSuperclass();
if (superclass != null && superclass != Object.class) {
return superclass;
}
}
return clazz;
}
spring-core/src/main/java/org/springframework/util/ClassUtils.java 1008–1016행
CGLIB_CLASS_SEPARATOR는 "$$"입니다(92행). 10편에서 본 CGLIB 프록시와 8편의 @Configuration 클래스는 원래 클래스의 서브클래스이고 이름에 $$가 들어가므로, "이름에 $$가 있으면 부모 클래스가 사용자가 쓴 클래스"라는 규칙 하나로 원래 클래스를 되찾습니다. 어노테이션을 읽을 때 이 되찾기가 꼭 필요합니다. CGLIB 서브클래스에는 사용자가 붙인 어노테이션이 없기 때문입니다. 그래서 @Autowired를 찾는 후처리기도(AutowiredAnnotationBeanPostProcessor 553행), 포인트컷이 맞는 메서드를 찾는 AopUtils도(213행, 258행) 먼저 getUserClass를 부릅니다.
ReflectionUtils — 순회하고, 열고, 캐시한다
11편에서 어스펙트의 어드바이스 메서드를 모을 때 쓴 doWithMethods입니다. 예외 처리 부분만 // ...로 줄였습니다.
public static void doWithMethods(Class<?> clazz, MethodCallback mc, @Nullable MethodFilter mf) {
if (mf == USER_DECLARED_METHODS && clazz == Object.class) {
// nothing to introspect
return;
}
Method[] methods = getDeclaredMethods(clazz, false);
for (Method method : methods) {
if (mf != null && !mf.matches(method)) {
continue;
}
try {
mc.doWith(method);
}
// ...
}
// Keep backing up the inheritance hierarchy.
if (clazz.getSuperclass() != null && (mf != USER_DECLARED_METHODS || clazz.getSuperclass() != Object.class)) {
doWithMethods(clazz.getSuperclass(), mc, mf);
}
else if (clazz.isInterface()) {
for (Class<?> superIfc : clazz.getInterfaces()) {
doWithMethods(superIfc, mc, mf);
}
}
}
spring-core/src/main/java/org/springframework/util/ReflectionUtils.java 355–381행
Java의 Class.getDeclaredMethods()는 그 클래스에 선언된 메서드만 줍니다. doWithMethods는 부모 클래스로 계속 올라가면서(clazz.getSuperclass()) 같은 일을 반복하고, 인터페이스면 상위 인터페이스로 올라갑니다. 그래서 상속받은 메서드까지 한 번에 훑을 수 있습니다. 필터 USER_DECLARED_METHODS(55–56행)는 컴파일러가 만든 브리지·합성 메서드와 Object의 메서드를 거르는, Spring이 가장 자주 쓰는 필터입니다.
세 가지가 더 있습니다.
- 캐시. 안에서 부르는
getDeclaredMethods는 결과를declaredMethodsCache에 담아 둡니다(460–487행). 그 캐시는ConcurrentReferenceHashMap입니다(84행). 이 맵이 무엇인지는 2절에서 봅니다. 담을 때 인터페이스의 default 메서드도 함께 넣어 둡니다(465–478행). - 접근 열기. 5편에서 본
makeAccessible은 멤버가public이 아니거나 선언한 클래스가public이 아닐 때(필드는final일 때도)만setAccessible(true)를 부릅니다(Field799–805행,Method566–571행). 주석대로 "꼭 필요할 때만 불러 불필요한 충돌을 피하기" 위해서입니다. - 같은 이름이 두 번 나올 수 있습니다. 서브클래스와 부모 클래스에 같은 이름의 메서드가 있으면 둘 다 콜백에 들어옵니다. 주석에 그렇게 적혀 있고 필터로 거르라고 되어 있습니다(348–349행).
예제로 확인하기 ①
ex/u14/UtilitiesExample.java에서 이 절에 해당하는 부분입니다. @Configuration 클래스를 컨테이너에서 꺼내 그 실제 클래스 이름을 찍어 봅니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) {
Class<?> actual = context.getBean(AppConfig.class).getClass();
System.out.println("getClass() = " + actual.getName());
System.out.println("getUserClass() = " + ClassUtils.getUserClass(actual).getName());
}
== ClassUtils.getUserClass / isPresent
getClass() = ex.u14.UtilitiesExample$AppConfig$$SpringCGLIB$$0
getUserClass() = ex.u14.UtilitiesExample$AppConfig
isPresent("java.util.List") = true
isPresent("com.example.Nope") = false
== ReflectionUtils.doWithMethods
필터 없이 = 14개 (Object 의 메서드까지 올라간다)
USER_DECLARED_METHODS = [hello, secret]
== Assert.notNull
IllegalArgumentException: Event must not be null
컨테이너에서 꺼낸 AppConfig는 이름에 $$SpringCGLIB$$가 든 서브클래스이고, getUserClass가 원래 클래스를 돌려줍니다. 메서드가 둘뿐인 클래스를 필터 없이 훑으면 Object의 메서드까지 14개가 나오고, USER_DECLARED_METHODS를 주면 둘만 남습니다. 이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 위 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다).
2. 평범하지 않은 맵 세 가지
LinkedCaseInsensitiveMap — 열쇠의 대소문자를 무시하되, 표기는 기억한다
HTTP 헤더 이름이나 데이터베이스 컬럼 이름은 대소문자를 가리지 않습니다. Content-Type으로 넣고 content-type으로 꺼낼 수 있어야 하는데, 그렇다고 전부 소문자로 바꿔 버리면 돌려줄 때 원래 표기를 잃습니다. 그래서 이 맵은 안에 맵을 둘 둡니다.
private final LinkedHashMap<String, V> targetMap;
private final HashMap<String, String> caseInsensitiveKeys;
private final Locale locale;
spring-core/src/main/java/org/springframework/util/LinkedCaseInsensitiveMap.java 57–61행
조회는 맵 두 개를 차례로 거칩니다. 빨간 상자가 "헷갈리기 쉬운 곳"입니다.
caseInsensitiveKeys는 "소문자로 바꾼 열쇠 → 처음 넣었을 때의 표기"를, targetMap은 "처음 넣었을 때의 표기 → 값"을 담습니다. get은 열쇠를 소문자로 바꿔(convertKey, 337–339행) 원래 표기를 찾고, 그 표기로 값을 꺼냅니다(168–176행). put은 조금 더 합니다.
@Override
public @Nullable V put(String key, @Nullable V value) {
String oldKey = this.caseInsensitiveKeys.put(convertKey(key), key);
V oldKeyValue = null;
if (oldKey != null && !oldKey.equals(key)) {
oldKeyValue = this.targetMap.remove(oldKey);
}
V oldValue = this.targetMap.put(key, value);
return (oldKeyValue != null ? oldKeyValue : oldValue);
}
spring-core/src/main/java/org/springframework/util/LinkedCaseInsensitiveMap.java 189–198행
같은 열쇠를 다른 표기로 다시 넣으면 옛 표기의 항목을 지우고 새 표기로 넣습니다. 그래서 keySet()에 보이는 표기가 바뀝니다. JDBC의 ColumnMapRowMapper가 한 행을 맵으로 돌려줄 때 이 맵을 씁니다(ColumnMapRowMapper 70행). HttpHeaders도 안에 이 맵을 두는데, 로케일을 Locale.ROOT로 못 박아 둡니다(HttpHeaders 455행). 기본 생성자는 Locale.getDefault()를 쓰기 때문입니다(131행).
MultiValueMap — 열쇠 하나에 값 여러 개
HTTP 헤더나 요청 파라미터는 같은 이름이 여러 번 올 수 있습니다. MultiValueMap<K, V>는 Map<K, List<V>>를 상속하면서 getFirst와 add를 더한 인터페이스입니다(32–46행). add는 리스트가 없으면 만들어서 뒤에 붙입니다(MultiValueMapAdapter 64–68행). 평소에 쓰는 구현 LinkedMultiValueMap은 LinkedHashMap을 감싼 어댑터일 뿐이고(38행, 46–48행), 주석에 스레드 안전하지 않다고 적혀 있습니다(29–30행). 9편에서 @Profile의 속성을 모을 때 돌려받은 것이 이 타입입니다.
ConcurrentReferenceHashMap — 비워질 수 있는 캐시
13편에서 ResolvableType과 MergedAnnotations의 캐시가 전부 이 맵이라고 했습니다(ResolvableType 99–100행, AnnotationsScanner 55–59행, OrderUtils 45행). 1절의 declaredMethodsCache도 그렇습니다. 클래스 주석이 성격을 다 말해 줍니다(46–62행).
ConcurrentHashMap처럼 여러 스레드가 동시에 써도 되지만, 열쇠와 값을 soft 또는 weak 참조로 들고 있습니다. 기본은 soft입니다(78행, 종류는 600–607행). 메모리가 모자라면 가비지 컬렉터가 항목을 거둬 갑니다.- 그래서 "넣어 둔 것이 나중에도 있다는 보장이 없고, 모르는 스레드가 조용히 항목을 지우는 것처럼 보일 수 있다"고 주석이 경고합니다(55–58행).
null열쇠와null값을 허용합니다(52–53행).ConcurrentHashMap과 다른 점입니다.
Spring이 캐시를 이 맵으로 두는 이유는 분명합니다. 클래스마다 리플렉션 결과를 영원히 들고 있으면 클래스를 많이 올리는 환경에서 메모리가 새고, 특히 클래스로더가 내려갈 때 클래스가 회수되지 못합니다. 없으면 다시 계산하면 되는 값만 넣는다는 조건으로, 비워질 수 있는 캐시를 쓰는 것입니다.
헷갈리기 쉬운 곳
LinkedCaseInsensitiveMap은null열쇠를 받지 않습니다(주석 44행).get(Object)에 문자열이 아닌 것을 주면 그냥null입니다(169행).MultiValueMap의get은 리스트를 돌려줍니다. 값 하나를 기대하면getFirst입니다. 두 메서드를 섞어 쓰면ClassCastException이 아니라 조용히List가 들어옵니다.ConcurrentReferenceHashMap에 "꼭 남아 있어야 하는 것"을 넣으면 안 됩니다. 캐시 전용입니다.
예제로 확인하기 ②
같은 UtilitiesExample의 앞부분 출력입니다.
== LinkedCaseInsensitiveMap
get("content-type") = text/html
get("CONTENT-TYPE") = text/html
keySet() = [Content-Type, Accept]
put("CONTENT-TYPE") 뒤: size=2, keySet()=[Accept, CONTENT-TYPE], get("Content-Type")=application/json
== LinkedMultiValueMap
getFirst("tag") = spring
get("tag") = [spring, aot]
전체 = {tag=[spring, aot], page=[1]}
Content-Type으로 넣은 값을 어떤 대소문자로도 꺼낼 수 있고, keySet()은 처음 표기를 보여 줍니다. 그런데 CONTENT-TYPE으로 다시 넣자 크기는 2 그대로인데 표기가 CONTENT-TYPE으로 바뀌고 순서도 뒤로 갔습니다. 위의 put에서 옛 항목을 지우고 새로 넣기 때문입니다.
3. 순서는 어떻게 정해지나 — OrderComparator
5편에서 같은 타입의 빈 여럿을 List로 주입할 때, 6편에서 후처리기를 등록할 때, 11편에서 어드바이저를 늘어놓을 때 모두 "순서대로 정렬한다"고 했습니다. 그 정렬이 전부 OrderComparator 계열입니다. 비교의 핵심은 열네 줄짜리 메서드 하나입니다.
private int doCompare(@Nullable Object o1, @Nullable Object o2, @Nullable OrderSourceProvider sourceProvider) {
boolean p1 = (o1 instanceof PriorityOrdered);
boolean p2 = (o2 instanceof PriorityOrdered);
if (p1 && !p2) {
return -1;
}
else if (p2 && !p1) {
return 1;
}
int i1 = getOrder(o1, sourceProvider);
int i2 = getOrder(o2, sourceProvider);
return Integer.compare(i1, i2);
}
spring-core/src/main/java/org/springframework/core/OrderComparator.java 77–90행
파란 마름모가 세 번의 판정이고, 빨간 상자가 "순서를 말하지 않은 객체"의 자리입니다.
PriorityOrdered가 먼저입니다. 한쪽만PriorityOrdered면 값을 보지도 않고 그쪽이 앞입니다. 6편에서 후처리기를 세 묶음으로 나눠 등록한 이유가 이것입니다.- 값은
getOrder로 구합니다. 기본 구현은Ordered를 구현했으면getOrder(), 아니면Ordered.LOWEST_PRECEDENCE입니다(128–136행, 145–147행).LOWEST_PRECEDENCE는Integer.MAX_VALUE입니다(Ordered55행). 순서를 말하지 않은 객체는 맨 뒤로 갑니다. - 작은 값이 앞입니다.
Ordered의 주석이 "값이 클수록 우선순위가 낮다"고 못 박습니다(60–62행).
어노테이션을 보는 쪽은 서브클래스 AnnotationAwareOrderComparator입니다. findOrder를 재정의해서 먼저 부모(Ordered 인터페이스)에게 묻고, 없을 때만 어노테이션을 봅니다.
@Override
protected @Nullable Integer findOrder(Object obj) {
Integer order = super.findOrder(obj);
if (order != null) {
return order;
}
return findOrderFromAnnotation(obj);
}
어노테이션은 13편의 MergedAnnotations로 타입 계층을 훑어 읽고(71–79행), @Order를 먼저, 없으면 jakarta.annotation.Priority를 봅니다(OrderUtils 118–128행). 읽은 결과는 2절의 ConcurrentReferenceHashMap에 캐시됩니다(45행).
어디에 꽂혀 있는지도 적어 둡니다. 어노테이션 설정 컨테이너는 시작할 때 빈 팩토리의 dependencyComparator를 AnnotationAwareOrderComparator.INSTANCE로 바꿔 둡니다(AnnotationConfigUtils 148–149행). 자동 프록시 생성기는 어드바이저를 AnnotationAwareOrderComparator.sort로 정렬합니다(AbstractAdvisorAutoProxyCreator 162행). 그리고 이 글 뒤쪽의 AOT가 처리기를 모을 때도 같은 정렬을 씁니다(AotServices 71행).
예제로 확인하기 ③
순서 정보가 제각각인 객체 넷을 섞어 정렬했습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
public static class Plain { } // 순서 정보 없음
@Order(1) public static class AnnotatedOne { } // 어노테이션으로 1
public static class OrderedFive implements Ordered { // 인터페이스로 5
@Override public int getOrder() { return 5; }
}
public static class PriorityHundred implements PriorityOrdered { // 값은 100 이지만 PriorityOrdered
@Override public int getOrder() { return 100; }
}
== AnnotationAwareOrderComparator.sort
PriorityHundred order=100
AnnotatedOne order=1
OrderedFive order=5
Plain order=2147483647
값이 100인 PriorityHundred가 1인 AnnotatedOne보다 앞에 섰습니다. PriorityOrdered는 값과 상관없이 먼저이기 때문입니다. 아무것도 적지 않은 Plain은 2147483647, 곧 Integer.MAX_VALUE로 맨 뒤입니다.
헷갈리기 쉬운 곳
@Order(100)은PriorityOrdered가 아닙니다. 어노테이션은 값만 줍니다. 1번 판정을 통과하는 것은PriorityOrdered인터페이스를 구현한 객체뿐입니다.Ordered와@Order가 둘 다 있으면Ordered가 이깁니다. 위findOrder가 부모에게 먼저 묻기 때문입니다. 클래스 주석도Ordered인스턴스의 값이 어노테이션 값을 덮는다고 적습니다(34–35행).- 같은 값끼리의 순서는 보장되지 않습니다(
OrderComparator36–37행). 순서가 중요하면 값을 다르게 주어야 합니다.
4. 짧게 보고 지나갈 두 가지
**AntPathMatcher**는 com/**/*.jsp 같은 Ant 식 경로 패턴을 맞춰 보는 클래스입니다(주석 43–58행). 12편에서 classpath*:**/*.xml을 풀던 PathMatchingResourcePatternResolver가 이것을 씁니다(259행). 눈여겨볼 점은 패턴을 쪼갠 결과와 정규식을 캐시해 두되(96–98행), 캐시에 쌓인 서로 다른 패턴이 65,536개에 이르면 "되풀이되지 않는 패턴이 쏟아지는 상황"으로 보고 캐시를 스스로 끈다는 것입니다(79행, 401–421행). 캐시가 공격 통로가 되지 않게 한 장치입니다.
캐시를 스스로 끄는 것은 기본값(null)일 때뿐입니다. true·false를 직접 정하면 그대로 따릅니다(AntPathMatcher 159–167행, 401–421행).
**StopWatch**는 구간별 실행 시간을 재서 표로 찍어 주는 작은 클래스입니다. 주석이 두 가지를 분명히 합니다. 스레드 안전하지 않고(34–35행), 운영 코드가 아니라 개념 검증과 개발 중에 쓰라는 것입니다(37–38행).
여기까지가 (A) 유틸리티입니다. 지금부터는 (B) AOT입니다.
5. AOT — 무엇을 "미리" 하나
왜 필요한가
이 시리즈의 7편부터 9편까지는 컨테이너가 켜질 때 하는 일이었습니다. 클래스패스를 스캔하고, @Configuration을 파싱하고, @Conditional을 평가하고, 리플렉션으로 생성자와 @Autowired 필드를 찾고, 필요하면 CGLIB로 서브클래스를 만듭니다. 이 모든 일은 "실행 중에 클래스를 이름으로 찾고 들여다볼 수 있다"는 JVM의 능력에 기대고 있습니다.
GraalVM 네이티브 이미지에서는 그 전제가 깨집니다. 같은 커밋의 참고 문서가 그 사정을 이렇게 적습니다. 네이티브 이미지는 어떤 컴포넌트가 리플렉션을 쓰는지 미리 알아야 하고, 클래스패스의 리소스도 명시하지 않으면 이미지에 들어가지 않습니다(framework-docs/.../core/aot.adoc 576–579행). 그래서 Spring의 AOT는 "실행할 때 하던 결정과 탐색을 빌드 시점에 ApplicationContext를 들여다보며 미리 적용한다"고 자기를 소개합니다(11–12행). 미리 하려면 조건이 붙습니다. 클래스패스가 빌드 시점에 고정되어야 하고, @Profile과 @Conditional은 빌드 시점에 결정되며, 람다로 등록한 빈이나 registerSingleton으로 넣은 객체는 변환할 수 없습니다(14–24행). 그 대가로 Java 소스 코드, 바이트코드(주로 프록시), 그리고 RuntimeHints 세 가지가 만들어집니다(30–34행). 문서는 "지금은 네이티브 이미지에 초점이 있지만 JVM 쪽 쓰임도 넓힐 계획"이라고 덧붙이고(36–37행), JVM에서도 spring.aot.enabled 시스템 속성으로 켤 수 있다고 적습니다(234–236행).
위가 빌드 시점, 아래가 실행 시점입니다. 노란 상자 둘이 AOT의 결과물이고, 초록 상자가 "미리 계산한 것을 쓰는 길"입니다.
실행할 때 어느 길인지 정하는 스위치
실행 시점의 분기점은 아주 작은 클래스 하나입니다.
public static final String AOT_ENABLED = "spring.aot.enabled";
private static final boolean inNativeImage = NativeDetector.inNativeImage(Context.RUN, Context.BUILD);
// ...
public static boolean useGeneratedArtifacts() {
return (inNativeImage || SpringProperties.getFlag(AOT_ENABLED));
}
spring-core/src/main/java/org/springframework/aot/AotDetector.java 39–52행
네이티브 이미지 안이거나(NativeDetector는 GraalVM이 심어 두는 org.graalvm.nativeimage.imagecode 시스템 속성을 봅니다, 30–32행) spring.aot.enabled가 켜져 있으면 "미리 만들어 둔 것을 써라"입니다. SpringProperties.getFlag는 클래스패스의 spring.properties 파일을 먼저, 시스템 속성을 다음으로 봅니다(106–117행). 이 스위치를 누가 어디서 보는지는 7절에서 봅니다.
RuntimeHints — 네이티브 이미지 빌더에게 남기는 메모
RuntimeHints는 힌트 묶음 다섯 개를 들고 있는 그릇입니다(34–45행).
| 메서드 | 묶음 | 적어 두는 것 |
|---|---|---|
reflection() |
ReflectionHints |
어떤 타입의 어떤 멤버(생성자·메서드·필드)에 리플렉션을 쓸 것인가 |
resources() |
ResourceHints |
어떤 경로 패턴의 리소스를 읽을 것인가(registerPattern 113행) |
proxies() |
ProxyHints |
어떤 인터페이스 조합으로 JDK 프록시를 만들 것인가(registerJdkProxy 76행) |
serialization() |
SerializationHints |
Java 직렬화 대상. 7.0.6부터 deprecated, reflection()으로 합쳐지는 중(67–71행) |
jni() |
ReflectionHints |
JNI로 접근할 것 |
리플렉션 힌트는 registerType(타입, MemberCategory...)로 적습니다(137–141행). MemberCategory는 "이 타입에서 무엇까지 열어 둘 것인가"의 종류로, INVOKE_PUBLIC_CONSTRUCTORS, INVOKE_DECLARED_METHODS, ACCESS_DECLARED_FIELDS처럼 "동작(INVOKE/ACCESS) + 범위(PUBLIC/DECLARED) + 대상(CONSTRUCTORS/METHODS/FIELDS)" 꼴로 이름이 붙어 있습니다(34–180행, INTROSPECT_* 넷과 동작 이름이 없는 PUBLIC_FIELDS·DECLARED_FIELDS·PUBLIC_CLASSES·DECLARED_CLASSES는 7.0에서 deprecated). 메서드 하나만 콕 집어 적을 수도 있습니다(registerMethod, 242행).
힌트를 누가 적는가는 세 갈래입니다.
- Spring 자신이 자동으로. 6절의 처리 과정에서 빈 클래스, 생성자, 팩토리 메서드, 프록시 클래스에 대한 힌트가 저절로 쌓입니다. 아래 예제에서 그 대부분을 보게 됩니다.
RuntimeHintsRegistrar로 직접. 메서드 하나짜리 인터페이스입니다(36–44행). 빈이나@Bean메서드에@ImportRuntimeHints(MyHints.class)를 붙여 등록하거나(68–76행), 라이브러리라면META-INF/spring/aot.factories에 적어 둡니다. 실제로spring-core,spring-beans,spring-context자신도 그 파일에 자기 등록기를 적어 두었습니다(spring-core/.../aot.factories,spring-context/.../aot.factories).- 어노테이션으로 표시만.
@Reflective를 타입·생성자·필드·메서드에 붙이면(42–58행) 기본 처리기SimpleReflectiveProcessor가 그 요소에 맞는 힌트를 적습니다(35–51행).@RegisterReflection(classes = ..., memberCategories = ...)는 그 위에 얹힌, 6.2부터 있는 선언형 표기입니다(78–104행).
6. 미리 계산하는 흐름 — processAheadOfTime
입구는 ApplicationContextAotGenerator 하나이고 메서드 본문은 여덟 줄입니다.
public ClassName processAheadOfTime(GenericApplicationContext applicationContext,
GenerationContext generationContext) {
return withCglibClassHandler(new CglibClassHandler(generationContext), () -> {
applicationContext.refreshForAotProcessing(generationContext.getRuntimeHints());
ApplicationContextInitializationCodeGenerator codeGenerator =
new ApplicationContextInitializationCodeGenerator(applicationContext, generationContext);
DefaultListableBeanFactory beanFactory = applicationContext.getDefaultListableBeanFactory();
new BeanFactoryInitializationAotContributions(beanFactory).applyTo(generationContext, codeGenerator);
return codeGenerator.getClassName();
});
}
파란 상자가 절차, 초록 상자가 생성되는 Java 소스, 노란 상자가 힌트입니다.
인자 둘과 반환값부터 봅니다.
GenericApplicationContext는 아직refresh()하지 않은 컨텍스트여야 합니다(주석 46행 "the non-refreshed application context"). 설정 클래스만 등록된 상태입니다.- **
GenerationContext**는 생성물을 받는 그릇입니다. 인터페이스가 약속하는 것은 생성 클래스 관리(getGeneratedClasses), 생성 파일(getGeneratedFiles), 그리고 힌트(getRuntimeHints)입니다(43–81행). 7.1에서 리소스용getGeneratedResources가 더해졌습니다(52–58행). 기본 구현DefaultGenerationContext는 이름 규칙(NameGenerator)과 파일 저장소(GeneratedFiles)를 받아 만듭니다(58–60행). 파일 저장소는 메모리에 모으는InMemoryGeneratedFiles(39–48행)와 디스크의sources·resources·classes폴더에 쓰는FileSystemGeneratedFiles(78–84행) 두 가지가 있고, 파일 종류는SOURCE·RESOURCE·CLASS셋입니다(GeneratedFiles.Kind219–237행). - 반환값은 생성된
ApplicationContextInitializer의 클래스 이름입니다. 실행 시점에 이 클래스를 적용하면 컨텍스트가 "미리 계산된 상태"로 복원됩니다.
이름 규칙도 한 줄로 정리됩니다. 대상 클래스 이름 뒤에 __와 기능 이름을 붙입니다. 주석의 예로 com.example.Demo에 Initializer 기능이면 com.example.Demo__Initializer입니다(NameGenerator 93–94행, 구현은 111–117행). 예제 출력의 __ApplicationContextInitializer, __BeanDefinitions가 다 이 규칙입니다.
첫 단계: refreshForAotProcessing — 빈 정의까지만 만드는 refresh
public void refreshForAotProcessing(RuntimeHints runtimeHints) {
if (logger.isDebugEnabled()) {
logger.debug("Preparing bean factory for AOT processing");
}
prepareRefresh();
obtainFreshBeanFactory();
prepareBeanFactory(this.beanFactory);
postProcessBeanFactory(this.beanFactory);
invokeBeanFactoryPostProcessors(this.beanFactory);
this.beanFactory.freezeConfiguration();
PostProcessorRegistrationDelegate.invokeMergedBeanDefinitionPostProcessors(this.beanFactory);
preDetermineBeanTypes(runtimeHints);
}
7편의 refresh() 12단계와 나란히 놓으면 뜻이 보입니다. 앞 다섯 줄은 refresh()의 1~5단계와 똑같습니다(AbstractApplicationContext 590–604행). 그래서 8편의 설정 클래스 파싱과 컴포넌트 스캔, 9편의 조건 평가와 @Import가 여기서 전부 실행되어 빈 정의가 완성됩니다. 그다음이 다릅니다. 후처리기 등록, 이벤트 멀티캐스터, 싱글톤 미리 만들기(606–625행)는 하지 않고, 설정을 얼리고(freezeConfiguration), 6편의 MergedBeanDefinitionPostProcessor만 돌려 @Autowired·@PostConstruct 같은 메타데이터를 빈 정의에 붙인 뒤, preDetermineBeanTypes로 빈마다 최종 타입(프록시가 될 것이면 프록시 타입)을 미리 알아냅니다(431–466행). 이때 JDK 프록시면 프록시 힌트를, CGLIB 서브클래스면 그 클래스와 원래 클래스에 대한 리플렉션 힌트를 적습니다(ClassHintUtils 62–76행). 여기서 1절의 getUserClass가 다시 나옵니다(69행).
주석의 요약이 정확합니다. "이 모드에서는 BeanDefinitionRegistryPostProcessor와 MergedBeanDefinitionPostProcessor만 호출된다"(404–405행). 빈 객체는 하나도 만들지 않습니다. 설계도까지만 만들고 멈추는 refresh입니다.
둘째 단계: 처리기를 모아 기여를 받는다
private static List<BeanFactoryInitializationAotProcessor> getProcessors(AotServices.Loader loader) {
List<BeanFactoryInitializationAotProcessor> processors = new ArrayList<>(
loader.load(BeanFactoryInitializationAotProcessor.class).asList());
processors.add(new RuntimeHintsBeanFactoryInitializationAotProcessor());
return Collections.unmodifiableList(processors);
}
loader는 AotServices.factoriesAndBeans(beanFactory)(46–48행)로, META-INF/spring/aot.factories에 적힌 것과 빈으로 등록된 것을 모두 모아 3절의 AnnotationAwareOrderComparator로 정렬합니다(AotServices 52행, 71행). 처리기는 두 종류이고 이름이 비슷해서 표로 둡니다.
| 인터페이스 | 입력 | 돌려주는 것 | 대표 구현 |
|---|---|---|---|
BeanFactoryInitializationAotProcessor (47–62행) |
빈 팩토리 전체 | BeanFactoryInitializationAotContribution 또는 null |
BeanRegistrationsAotProcessor(빈 등록 코드), RuntimeHintsBeanFactoryInitializationAotProcessor(힌트 등록기 실행) |
BeanRegistrationAotProcessor (50–76행) |
빈 하나(RegisteredBean) |
BeanRegistrationAotContribution 또는 null |
AutowiredAnnotationBeanPostProcessor, ConfigurationClassPostProcessor(342행) |
"기여"(contribution)는 "생성 코드나 힌트를 GenerationContext에 보태는 객체"입니다. 두 인터페이스 모두 applyTo(generationContext, code) 메서드 하나로 그 일을 합니다(BeanRegistrationAotContribution 57행). 눈여겨볼 규칙이 하나 있습니다. AOT 처리기를 구현한 빈은 자기 자신이 생성 코드에서 빠집니다(BeanRegistrationAotProcessor 86–88행, 판정은 BeanDefinitionMethodGeneratorFactory 133–147행). 실행 중에 하던 일(@Autowired 필드 찾기 등)을 생성 코드가 대신하므로, 그 일을 하던 후처리기는 실행 시점에 필요 없기 때문입니다.
셋째 단계: 빈 하나가 코드가 되기까지
가장 중요한 처리기는 BeanRegistrationsAotProcessor입니다. 빈 정의 이름을 전부 돌면서 빈마다 BeanDefinitionMethodGenerator를 만들고(46–54행), 그 묶음을 기여 하나로 돌려줍니다. 기여의 applyTo가 하는 일은 셋입니다(71–84행).
X__BeanFactoryRegistrations클래스를 만들고(120–126행) 거기에registerBeanDefinitions와registerAliases메서드를 넣습니다.- 그 두 메서드를 "초기화 때 부를 것" 목록에 올립니다(
addInitializer). 이 목록이__ApplicationContextInitializer의initialize()본문이 됩니다(ApplicationContextInitializationCodeGenerator98–113행). 그 본문 앞머리에서dependencyComparator를AnnotationAwareOrderComparator.INSTANCE로 놓는 줄(105–106행)은 3절에서 본AnnotationConfigUtils가 실행 시점에 하던 일을 코드로 박아 둔 것입니다. - 빈 클래스마다 리플렉션 힌트를 적습니다(142–149행).
빈 하나의 코드는 BeanDefinitionMethodGenerator가 만듭니다. 빈 클래스가 속한 최상위 클래스 이름으로 X__BeanDefinitions 클래스를 얻고(중첩 클래스면 같은 구조의 중첩 클래스를 안에 만듭니다, 117–138행), 거기에 BeanDefinition을 돌려주는 static 메서드를 하나 더합니다(169–177행). 메서드 본문은 new RootBeanDefinition(클래스)로 시작해(DefaultBeanRegistrationCodeFragments 131–132행) 속성을 채우고, 마지막에 인스턴스 공급자를 꽂습니다. 그 공급자 코드를 만드는 것이 InstanceSupplierCodeGenerator입니다(141–151행).
- 생성자가 생성 코드에서 보이고(
public이거나, 같은 패키지에 있고private이 아니면) 인자가 없으면 그냥Foo::new입니다(183–191행). 리플렉션이 전혀 없습니다. @Bean메서드처럼 팩토리 메서드면BeanInstanceSupplier.forFactoryMethod(클래스, "메서드이름", 인자타입...)에.withGenerator(람다)를 붙입니다(272–291행). 람다 안에서 메서드를 직접 부릅니다.- 보이지 않으면(
private등) 람다 없이forConstructor/forFactoryMethod만 쓰고, 그 대신 리플렉션 호출 힌트를 적습니다(171–175행).
실행 시점에 이 BeanInstanceSupplier가 하는 일은 get(registeredBean) 하나입니다(190–207행). 람다가 있으면 인자가 필요한 경우 5편의 방식대로 인자를 해석해 넘기고(resolveArguments) 람다를 부릅니다. 람다가 없으면 리플렉션으로 생성자·메서드를 찾아 호출합니다. 즉 "무엇을 부를지 고르는 일"은 빌드 시점에 끝났고, "인자를 채우는 일"만 실행 시점에 남습니다. 4편의 세 단계 가운데 인스턴스화가 이 공급자로 바뀌는 것입니다.
예제로 확인하기 ④ — 직접 돌려 보기
ex/u14/AotGenerateExample.java입니다. 네이티브 이미지는 만들지 않고, 생성기를 런타임에 직접 불러 결과를 메모리에 받습니다. 중심은 이 세 단계입니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
// 1. refresh 하지 않은 컨텍스트 — 생성자에 클래스를 넘기면 refresh 까지 되므로 register 만 한다
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.register(AppConfig.class);
// 2. 생성물을 메모리에 모으는 GenerationContext
InMemoryGeneratedFiles files = new InMemoryGeneratedFiles();
DefaultGenerationContext generationContext = new DefaultGenerationContext(
new ClassNameGenerator(ClassName.get(AotGenerateExample.class)), files);
// 3. 미리 처리 — 돌려받는 것은 생성된 ApplicationContextInitializer 의 클래스 이름
ClassName initializer = new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext);
generationContext.writeGeneratedContent();
AppConfig는 Engine을 돌려주는 @Bean engine()과 그것을 인자로 받는 @Bean car(Engine engine) 둘뿐인 설정 클래스입니다. 출력에서 맨 앞에 찍히는 Hibernate Validator의 INFO 로그(두 줄)는 뺐고, 나머지는 그대로입니다.
initializer = ex.u14.AotGenerateExample__ApplicationContextInitializer
== SOURCE (5개)
ex/u14/AotGenerateExample__ApplicationContextInitializer.java
ex/u14/AotGenerateExample__BeanDefinitions.java
ex/u14/AotGenerateExample__BeanFactoryRegistrations.java
org/springframework/context/event/DefaultEventListenerFactory__BeanDefinitions.java
org/springframework/context/event/EventListenerMethodProcessor__BeanDefinitions.java
== RESOURCE (0개)
== CLASS (3개)
ex/u14/AotGenerateExample$AppConfig$$SpringCGLIB$$0.class
ex/u14/AotGenerateExample$AppConfig$$SpringCGLIB$$FastClass$$0.class
ex/u14/AotGenerateExample$AppConfig$$SpringCGLIB$$FastClass$$1.class
소스 다섯, 클래스 파일 셋입니다. CLASS 셋은 @Configuration 클래스를 위해 CGLIB가 만든 바이트코드로, 생성기가 CglibClassHandler로 가로채 저장해 둔 것입니다(63–67행). 실행 시점에는 이 클래스를 만드는 대신 불러오면 됩니다. EventListenerMethodProcessor와 DefaultEventListenerFactory의 빈 정의도 생성된 반면, 같은 자리에서 등록되는 ConfigurationClassPostProcessor나 AutowiredAnnotationBeanPostProcessor는 보이지 않습니다. 둘 다 AOT 처리기를 구현한 빈이라 위에서 말한 규칙대로 빠진 것입니다.
생성된 초기화 클래스입니다(package·import·Javadoc 주석은 뺐습니다).
@Generated
public class AotGenerateExample__ApplicationContextInitializer implements ApplicationContextInitializer<GenericApplicationContext> {
@Override
public void initialize(GenericApplicationContext applicationContext) {
DefaultListableBeanFactory beanFactory = applicationContext.getDefaultListableBeanFactory();
beanFactory.setAutowireCandidateResolver(new ContextAnnotationAutowireCandidateResolver());
beanFactory.setDependencyComparator(AnnotationAwareOrderComparator.INSTANCE);
new AotGenerateExample__BeanFactoryRegistrations().registerBeanDefinitions(beanFactory);
new AotGenerateExample__BeanFactoryRegistrations().registerAliases(beanFactory);
}
}
그리고 __BeanDefinitions에서 빈 셋이 어떻게 생겼는지입니다(출력의 일부입니다. Javadoc 주석은 뺐고 줄인 메서드는 // ...로 표시했습니다).
public class AotGenerateExample__BeanDefinitions {
@Generated
public static class AppConfig {
public static BeanDefinition getAppConfigBeanDefinition() {
RootBeanDefinition beanDefinition = new RootBeanDefinition(AotGenerateExample.AppConfig.class);
beanDefinition.setTargetType(AotGenerateExample.AppConfig.class);
ConfigurationClassUtils.initializeConfigurationClass(AotGenerateExample.AppConfig.class);
beanDefinition.setInstanceSupplier(AotGenerateExample$AppConfig$$SpringCGLIB$$0::new);
return beanDefinition;
}
private static BeanInstanceSupplier<AotGenerateExample.Engine> getEngineInstanceSupplier() {
return BeanInstanceSupplier.<AotGenerateExample.Engine>forFactoryMethod(AotGenerateExample$AppConfig$$SpringCGLIB$$0.class, "engine")
.withGenerator((registeredBean) -> registeredBean.getBeanFactory().getBean("aotGenerateExample.AppConfig", AotGenerateExample.AppConfig.class).engine());
}
// ...
private static BeanInstanceSupplier<AotGenerateExample.Car> getCarInstanceSupplier() {
return BeanInstanceSupplier.<AotGenerateExample.Car>forFactoryMethod(AotGenerateExample$AppConfig$$SpringCGLIB$$0.class, "car", AotGenerateExample.Engine.class)
.withGenerator((registeredBean, args) -> registeredBean.getBeanFactory().getBean("aotGenerateExample.AppConfig", AotGenerateExample.AppConfig.class).car(args.get(0)));
}
// ...
세 줄만 짚습니다. appConfig의 공급자는 CGLIB 클래스의 생성자 참조 ::new입니다. engine은 설정 빈을 꺼내 .engine()을 직접 부릅니다. car는 인자가 있으므로 (registeredBean, args) 람다를 받고, args.get(0)에 들어갈 Engine은 실행 시점에 BeanInstanceSupplier가 해석해 넘깁니다. 어노테이션을 읽고 메서드를 찾는 일은 어디에도 없습니다. 이 예제도 Spring Framework 7.0.9로 실행했습니다.
헷갈리기 쉬운 곳
- 이름이 바뀐 클래스가 하나 있습니다. 예제가 쓴
ClassNameGenerator는 7.0.9의 이름이고, 이 글이 읽는 7.1 소스에서는 리소스 이름까지 만드는NameGenerator로 바뀌었습니다(NameGenerator40–42행@since 7.1,DefaultGenerationContext의 생성자도 58행에서NameGenerator를 받습니다). 7.1로 올리면 예제의 그 한 줄은 고쳐야 합니다. refresh()된 컨텍스트를 넘기면 안 됩니다.new AnnotationConfigApplicationContext(AppConfig.class)처럼 생성자에 클래스를 넘기면 1편에서 봤듯refresh()까지 실행됩니다. 그래서 예제는 빈 생성자로 만들고register만 했습니다.- 생성된 것은 소스 코드입니다. 컴파일해서 클래스패스에 넣고, 실행할 때 그 초기화 클래스를 적용하는 일은 빌드 도구와 컨테이너를 켜는 쪽의 몫입니다. 이 저장소 안에서 그 "명령" 모양을 보여 주는 것이
ContextAotProcessor.performAotProcessing으로,FileSystemGeneratedFiles에 쓰고 힌트를 네이티브 이미지 설정 파일로 내보냅니다(102–113행, 파일 이름은NativeConfigurationWriter41행의reachability-metadata.json). 이것을 빌드에 엮어 주는 Spring Boot의 플러그인은 이 저장소 밖이라 이 글에서 소스로 확인한 것은 아닙니다.
7. 실행 시점: 미리 만든 것은 누가 쓰나
AotDetector.useGeneratedArtifacts()를 이 저장소 안에서 누가 보는지 찾아보면 크게 두 갈래입니다(그 밖에 Kotlin 빈 DSL과 spring-test의 AOT 지원 클래스들도 이 스위치를 봅니다).
생성된 초기화 클래스를 적용하는 쪽. 이름 목록으로 초기화 클래스들을 불러 차례로 initialize를 부르는 AotApplicationContextInitializer.forInitializerClasses가 있습니다(57–62행, 로딩은 1절의 ClassUtils로 77–88행). 이 저장소에서 그것을 실제로 쓰는 곳은 spring-test입니다. 테스트 컨텍스트를 올릴 때 useGeneratedArtifacts()가 참이면 그 테스트 클래스용 생성 초기화 클래스를 찾고, 없으면 예외를 던집니다(DefaultCacheAwareContextLoaderDelegate 312–325행). AotDetector의 주석도 "최적화가 없으면 평소 동작으로 돌아가지 말고 예외를 던지는 것을 권한다"고 적습니다(34–38행). 일반 애플리케이션에서 이 초기화 클래스를 적용하는 것은 Spring Boot의 일이고, 역시 이 글에서 확인한 범위 밖입니다.
CGLIB 클래스를 만들지 않고 불러오는 쪽. @Configuration 클래스를 감쌀 때(ConfigurationClassEnhancer 184행), 메서드 주입 빈의 서브클래스를 만들 때(CglibSubclassingInstantiationStrategy 157행), 10편의 CGLIB AOP 프록시를 만들 때(CglibAopProxy 204행) 세 곳 모두 AotDetector.useGeneratedArtifacts()의 결과를 enhancer.setAttemptLoad(...)에 넘깁니다(184행과 204행은 앞에 enhancer.getUseCache() &&가 붙어 있습니다. 같은 호출이 ConfigurationClassEnhancer 578행의 FactoryBean 프록시와 spring-webmvc의 MvcUriComponentsBuilder 798행에도 있습니다). attemptLoad는 "생성하기 전에 그 이름의 클래스를 먼저 불러 보라"는 CGLIB 옵션입니다(AbstractClassGenerator 233–240행). 6절 예제의 CLASS 파일 셋이 실행 시점에 이 길로 쓰입니다.
미리 만든 클래스가 있으면 불러 쓰고, 없으면 평소처럼 만듭니다. 네이티브 이미지 안에서는 만들 수 없어서 예외입니다(AbstractClassGenerator 351–370행).
예제로 확인하기 ⑤ — 힌트에 무엇이 쌓였나
ex/u14/AotHintsExample.java는 설정 클래스에 @ImportRuntimeHints(ReportHints.class)를 붙이고, ReportHints에서 Report 클래스의 필드 접근 힌트와 리소스 패턴 하나를 등록하며, 빈 클래스의 메서드 하나에 @Reflective를 붙인 뒤 같은 방법으로 미리 처리하고 RuntimeHints를 찍습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
public static class ReportHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection().registerType(Report.class, MemberCategory.ACCESS_DECLARED_FIELDS);
hints.resources().registerPattern("config/app.properties");
}
}
== reflection hints (ex.u14 패키지만)
ex.u14.AotHintsExample$AppConfig
categories = [INVOKE_PUBLIC_METHODS, INVOKE_DECLARED_METHODS]
ex.u14.AotHintsExample$AppConfig$$SpringCGLIB$$0
categories = [INVOKE_DECLARED_CONSTRUCTORS]
ex.u14.AotHintsExample$AppConfig$$SpringCGLIB$$0
categories = [INVOKE_DECLARED_METHODS, ACCESS_DECLARED_FIELDS, INVOKE_DECLARED_CONSTRUCTORS]
ex.u14.AotHintsExample$AppConfig$$SpringCGLIB$$FastClass$$0
categories = [INVOKE_DECLARED_CONSTRUCTORS]
ex.u14.AotHintsExample$AppConfig$$SpringCGLIB$$FastClass$$1
categories = [INVOKE_DECLARED_CONSTRUCTORS]
ex.u14.AotHintsExample$Report
categories = [ACCESS_DECLARED_FIELDS]
ex.u14.AotHintsExample$ReportService
method = render[] INVOKE
== reflection hints 전체 = 66개 타입
== resource patterns = [/, org/springframework/mail/javamail/mime.types, org, org/springframework, org/springframework/mail, org/springframework/mail/javamail, /, org/springframework/http/mime.types, org, org/springframework, org/springframework/http, /, org/springframework/http/codec/CodecConfigurer.properties, org, org/springframework, org/springframework/http, org/springframework/http/codec, /, org/springframework/web/util/HtmlCharacterEntityReferences.properties, org, org/springframework, org/springframework/web, org/springframework/web/util, /, META-INF/spring.factories, META-INF, /, spring.properties, /, config/app.properties, config]
== jdk proxy hints = 4개
== AotDetector.useGeneratedArtifacts()
기본값 = false
spring.aot.enabled=true 뒤 = true
대괄호 안 카테고리의 순서는 집합을 그대로 찍은 것이라 실행할 때마다 달라질 수 있습니다. 직접 적은 것은 Report의 ACCESS_DECLARED_FIELDS, render 메서드의 INVOKE, 리소스 패턴 config/app.properties 셋뿐인데 힌트는 66개 타입에 걸쳐 있습니다. 나머지는 Spring이 자동으로 적은 것입니다. AppConfig에 붙은 INVOKE_PUBLIC_METHODS·INVOKE_DECLARED_METHODS와 CGLIB 클래스에 붙은 세 가지는 6절 preDetermineBeanTypes가 부른 ClassHintUtils의 두 묶음입니다(44–51행). 리소스 패턴에 mail, http, web이 섞여 있는 것은 예제 프로젝트의 클래스패스에 그 모듈들이 있고, 각 모듈의 aot.factories에 적힌 등록기가 실행됐기 때문입니다. 힌트는 "내 코드"가 아니라 "클래스패스 전체"에 대해 모입니다. 마지막 두 줄은 5절의 스위치가 시스템 속성 하나로 바뀌는 것을 보여 줍니다.
헷갈리기 쉬운 곳: 같은 CGLIB 클래스가 두 번 나온다
위 출력에서 AppConfig$$SpringCGLIB$$0이 두 항목으로 나뉘어 있습니다. 소스를 따라가면 이유가 보입니다. 힌트의 열쇠인 TypeReference는 정규화된 이름(canonical name)으로 같음을 판정합니다(AbstractTypeReference 86–89행). 그런데 CglibClassHandler는 바이트코드를 받을 때 문자열 이름으로 참조를 만들고(63–64행), 문자열 쪽 구현은 $ 하나를 중첩 클래스 구분자로 보고 이름을 쪼갭니다(SimpleTypeReference 49–57행). 반면 ClassHintUtils는 Class 객체로 참조를 만들고, 그쪽은 Class.getCanonicalName()을 그대로 씁니다(ReflectionTypeReference 52–54행). 예제의 AppConfig가 중첩 클래스라 이름에 $가 하나 더 있고, 두 방식이 서로 다른 정규화 이름을 내놓아 열쇠가 둘이 된 것입니다. 최종 설정 파일에 두 항목이 들어가도 뜻이 겹칠 뿐 틀리지는 않지만, "같은 클래스인데 왜 둘이지" 하고 헤매기 쉬운 곳입니다.
더 깊이 들어가면
유틸리티 쪽에서 뺀 것의 이름과 위치만 적어 둡니다. StringUtils.hasText(공백만 있어도 거짓, 203–205행)와 8편에서 빈 이름을 만들던 uncapitalizeAsProperty(593행). 배열까지 내용으로 비교하는 ObjectUtils.nullSafeEquals(340–354행). null과 빈 컬렉션을 한 번에 거르는 CollectionUtils.isEmpty(68–70행). ${...} 자리 표시자를 푸는 PropertyPlaceholderHelper와 크기 제한 캐시 ConcurrentLruCache(같은 util 패키지). AntPathMatcher의 실제 매칭 알고리즘 doMatch(210행부터).
AOT 쪽에서는 다음을 뺐습니다. JSON 바인딩용 힌트를 타입 계층을 따라 자동으로 적는 BindingReflectionHintsRegistrar와 @RegisterReflectionForBinding. 스코프 프록시와 AspectJ 어스펙트를 위한 spring-aop의 처리기들(spring-aop/.../aot.factories). ConfigurationClassPostProcessor가 보태는 세 가지 기여(@PropertySource, ImportAware, BeanRegistrar, 353–367행). @Reflective를 빈이 아닌 패키지 전체에서 찾게 하는 @ReflectiveScan. 힌트를 JSON으로 쓰는 FileNativeConfigurationWriter. 생성 코드가 private 멤버를 만났을 때의 처리(AccessControl). 그리고 spring-test의 AOT 지원은 26편에서 다시 만납니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| Spring의 유틸리티 | "주로 프레임워크 내부용". 바깥 의존 없이, 프레임워크 특유의 사정(CGLIB 이름, 클래스로더, 캐시)을 담아, 모든 모듈이 같은 도구를 쓴다 |
Assert |
인자 오류는 IllegalArgumentException, 상태 오류는 IllegalStateException |
ClassUtils.getUserClass |
이름에 $$가 있으면 부모 클래스가 사용자 클래스. 어노테이션을 읽기 전에 부른다 |
ReflectionUtils.doWithMethods |
부모 클래스·상위 인터페이스까지 올라가며 순회. 결과는 ConcurrentReferenceHashMap에 캐시 |
LinkedCaseInsensitiveMap |
소문자 열쇠 → 원래 표기 → 값, 맵 둘. 다른 표기로 다시 넣으면 표기가 바뀐다 |
MultiValueMap |
Map<K, List<V>> + getFirst·add. LinkedMultiValueMap은 스레드 안전하지 않다 |
ConcurrentReferenceHashMap |
soft/weak 참조라 비워질 수 있는 동시성 맵. 다시 계산할 수 있는 캐시에만 |
OrderComparator |
PriorityOrdered 먼저 → Ordered 값 → (어노테이션 버전) @Order·@Priority → 없으면 Integer.MAX_VALUE. 작은 값이 앞 |
| AOT | 컨테이너가 켜질 때 하던 결정을 빌드 시점에 하고, Java 소스·바이트코드·RuntimeHints로 내놓는다 |
RuntimeHints |
리플렉션·리소스·프록시 힌트 묶음. 자동으로, RuntimeHintsRegistrar로, @Reflective로 적는다 |
refreshForAotProcessing |
refresh()의 앞 다섯 단계 + 설정 얼리기 + MergedBeanDefinitionPostProcessor + 타입 미리 결정. 빈 객체는 만들지 않는다 |
| 처리기와 기여 | BeanFactoryInitializationAotProcessor(팩토리 전체)와 BeanRegistrationAotProcessor(빈 하나)가 …Contribution을 돌려주고, 기여가 applyTo로 코드와 힌트를 보탠다 |
| 생성 코드 | X__ApplicationContextInitializer → X__BeanFactoryRegistrations.registerBeanDefinitions → X__BeanDefinitions.getXxxBeanDefinition() |
BeanInstanceSupplier |
"무엇을 부를지"는 코드에 박혀 있고, 인자 해석만 실행 시점에 한다 |
AotDetector.useGeneratedArtifacts() |
네이티브 이미지이거나 spring.aot.enabled가 참이면 생성물을 쓴다. CGLIB는 만들지 않고 불러온다 |
다음에 볼 것
이 글로 1편 지도의 Phase 3(기반 기술)이 끝났습니다. 13편의 타입·어노테이션 읽기, 14편의 타입 변환, 15편의 SpEL, 그리고 이 글의 유틸리티와 AOT가 컨테이너와 AOP 아래에 깔린 바닥입니다.
다음부터는 Phase 4, 트랜잭션과 데이터 접근입니다. 첫 글은 @Transactional입니다. 메서드에 어노테이션 하나를 붙였을 뿐인데 트랜잭션은 누가 열고 누가 닫는지, 10편·11편의 프록시와 어드바이스 체인 위에 TransactionInterceptor가 어떻게 얹히는지, 전파(propagation) 속성이 실제로 어떤 분기를 타는지를 spring-tx에서 따라갑니다.