namalzi

← 글 목록

Spring 소스 파헤치기 16: 유틸리티와 AOT — 모두가 쓰는 도구 상자와 미리 계산해 두기

Spring · 게시

Spring의 모든 모듈이 가져다 쓰는 spring-core의 유틸리티(Assert, ClassUtils, ReflectionUtils, 맵 세 가지, OrderComparator)가 왜 따로 있고 어디서 쓰이며 어디가 함정인지, 그리고 Spring 6부터 들어온 AOT가 컨테이너를 빌드 시점에 "미리 계산"해 Java 소스와 RuntimeHints로 바꾸는 흐름을 실제 소스와 실행 예제로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 열여섯 번째 글입니다. 15편에서는 Spring의 작은 표현식 언어 SpEL이 문자열을 어떻게 파싱하고 계산하는지 봤습니다. 이번 글은 1편 지도의 Phase 3(기반 기술)을 마무리하는 단위 U14로, 두 가지를 다룹니다. 하나는 지금까지 열다섯 편에서 계속 스쳐 지나간 유틸리티 클래스들이고, 다른 하나는 Spring 6부터 들어온 AOT(Ahead-of-Time, 미리 계산해 두기)입니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 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의 util 패키지에서 Assert, ClassUtils, ReflectionUtils, 맵 세 가지, OrderComparator가 뻗어 나가고, 각각이 5편 주입, 10·11편 AOP, 11편 어드바이스 수집, 13편 어노테이션 캐시와 HTTP 헤더, 5편·11편의 정렬에서 쓰인다는 지도 이 글에서 보는 도구와, 앞 글들에서 그 도구가 쓰인 자리. 회색은 앞 글이고 노란색은 캐시입니다.

유틸리티 하는 일 앞 글에서 본 자리
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이 가장 자주 쓰는 필터입니다.

세 가지가 더 있습니다.

예제로 확인하기 ①

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행

LinkedCaseInsensitiveMap의 조회 흐름. get(CONTENT-TYPE)이 들어오면 convertKey로 소문자 content-type을 만들고, caseInsensitiveKeys에서 처음 넣은 표기 Content-Type을 찾은 다음, targetMap에서 그 표기로 값 text/html을 꺼낸다. put으로 다른 표기를 넣으면 옛 표기를 지우고 새 표기로 다시 넣는다. 조회는 맵 두 개를 차례로 거칩니다. 빨간 상자가 "헷갈리기 쉬운 곳"입니다.

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행).

Spring이 캐시를 이 맵으로 두는 이유는 분명합니다. 클래스마다 리플렉션 결과를 영원히 들고 있으면 클래스를 많이 올리는 환경에서 메모리가 새고, 특히 클래스로더가 내려갈 때 클래스가 회수되지 못합니다. 없으면 다시 계산하면 되는 값만 넣는다는 조건으로, 비워질 수 있는 캐시를 쓰는 것입니다.

헷갈리기 쉬운 곳

예제로 확인하기 ②

같은 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면 그쪽이 앞. 아니면 Ordered 구현이면 getOrder 값, 아니면 AnnotationAwareOrderComparator에 한해 @Order나 @Priority 값, 그것도 없으면 LOWEST_PRECEDENCE인 Integer.MAX_VALUE. 값이 작은 쪽이 앞이고 같으면 순서 보장이 없다. 파란 마름모가 세 번의 판정이고, 빨간 상자가 "순서를 말하지 않은 객체"의 자리입니다.

  1. PriorityOrdered가 먼저입니다. 한쪽만 PriorityOrdered면 값을 보지도 않고 그쪽이 앞입니다. 6편에서 후처리기를 세 묶음으로 나눠 등록한 이유가 이것입니다.
  2. 값은 getOrder로 구합니다. 기본 구현은 Ordered를 구현했으면 getOrder(), 아니면 Ordered.LOWEST_PRECEDENCE입니다(128–136행, 145–147행). LOWEST_PRECEDENCE는 Integer.MAX_VALUE입니다(Ordered 55행). 순서를 말하지 않은 객체는 맨 뒤로 갑니다.
  3. 작은 값이 앞입니다. 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);
	}

spring-core/src/main/java/org/springframework/core/annotation/AnnotationAwareOrderComparator.java 62–69행

어노테이션은 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로 맨 뒤입니다.

헷갈리기 쉬운 곳

4. 짧게 보고 지나갈 두 가지

**AntPathMatcher**는 com/**/*.jsp 같은 Ant 식 경로 패턴을 맞춰 보는 클래스입니다(주석 43–58행). 12편에서 classpath*:**/*.xml을 풀던 PathMatchingResourcePatternResolver가 이것을 씁니다(259행). 눈여겨볼 점은 패턴을 쪼갠 결과와 정규식을 캐시해 두되(96–98행), 캐시에 쌓인 서로 다른 패턴이 65,536개에 이르면 "되풀이되지 않는 패턴이 쏟아지는 상황"으로 보고 캐시를 스스로 끈다는 것입니다(79행, 401–421행). 캐시가 공격 통로가 되지 않게 한 장치입니다.

AntPathMatcher 의 패턴 캐시가 놓이는 세 갈래. 아무 설정이 없으면 cachePatterns 가 null(자동)이고 새 패턴을 쪼갤 때마다 tokenizedPatternCache 에 넣는다. 쌓인 패턴이 65,536개 미만이면 그대로 쌓고, 65,536개 이상에서 또 새 패턴이 오면 deactivatePatternCache() 가 cachePatterns 를 false 로 바꾸고 두 캐시 맵을 비워 이후로는 쌓지 않는다. setCachePatterns(true) 로 만들면 개수와 상관없이 계속 쌓고, setCachePatterns(false) 로 만들면 처음부터 쌓지 않는다 캐시를 스스로 끄는 것은 기본값(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의 두 시점. 빌드 시점에 refresh하지 않은 GenericApplicationContext를 ApplicationContextAotGenerator.processAheadOfTime이 처리해 GeneratedFiles(Java 소스, 리소스, CGLIB 클래스)와 RuntimeHints(리플렉션·리소스·프록시 힌트)를 내놓는다. 소스는 컴파일해 애플리케이션에 넣고 힌트는 네이티브 이미지 설정 파일이 된다. 실행 시점에 AotDetector.useGeneratedArtifacts()가 참이면 생성된 ApplicationContextInitializer가 빈 정의를 코드로 등록하고, 거짓이면 평소대로 스캔·파싱·리플렉션의 길을 간다. 위가 빌드 시점, 아래가 실행 시점입니다. 노란 상자 둘이 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행).

힌트를 누가 적는가는 세 갈래입니다.

  1. Spring 자신이 자동으로. 6절의 처리 과정에서 빈 클래스, 생성자, 팩토리 메서드, 프록시 클래스에 대한 힌트가 저절로 쌓입니다. 아래 예제에서 그 대부분을 보게 됩니다.
  2. 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).
  3. 어노테이션으로 표시만. @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();
		});
	}

spring-context/src/main/java/org/springframework/context/aot/ApplicationContextAotGenerator.java 51–62행

processAheadOfTime의 안. refreshForAotProcessing이 prepareRefresh부터 invokeBeanFactoryPostProcessors까지 하고 freezeConfiguration, MergedBeanDefinitionPostProcessor, preDetermineBeanTypes를 더 한다. 그다음 BeanFactoryInitializationAotContributions가 aot.factories와 빈에서 처리기를 모으는데, BeanRegistrationsAotProcessor는 빈 정의마다 BeanDefinitionMethodGenerator를 돌려 X__BeanDefinitions.java와 X__BeanFactoryRegistrations.java를 만들고, RuntimeHintsBeanFactoryInitializationAotProcessor는 RuntimeHintsRegistrar와 @ImportRuntimeHints를 모아 RuntimeHints에 기록하며, 그 밖의 처리기도 있다. 마지막에 X__ApplicationContextInitializer.java가 registerBeanDefinitions와 registerAliases를 부른다. 파란 상자가 절차, 초록 상자가 생성되는 Java 소스, 노란 상자가 힌트입니다.

인자 둘과 반환값부터 봅니다.

이름 규칙도 한 줄로 정리됩니다. 대상 클래스 이름 뒤에 __와 기능 이름을 붙입니다. 주석의 예로 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);
	}

spring-context/src/main/java/org/springframework/context/support/GenericApplicationContext.java 412–424행

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);
	}

spring-context/src/main/java/org/springframework/context/aot/BeanFactoryInitializationAotContributions.java 55–60행

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행).

  1. X__BeanFactoryRegistrations 클래스를 만들고(120–126행) 거기에 registerBeanDefinitions와 registerAliases 메서드를 넣습니다.
  2. 그 두 메서드를 "초기화 때 부를 것" 목록에 올립니다(addInitializer). 이 목록이 __ApplicationContextInitializer의 initialize() 본문이 됩니다(ApplicationContextInitializationCodeGenerator 98–113행). 그 본문 앞머리에서 dependencyComparator를 AnnotationAwareOrderComparator.INSTANCE로 놓는 줄(105–106행)은 3절에서 본 AnnotationConfigUtils가 실행 시점에 하던 일을 코드로 박아 둔 것입니다.
  3. 빈 클래스마다 리플렉션 힌트를 적습니다(142–149행).

빈 하나의 코드는 BeanDefinitionMethodGenerator가 만듭니다. 빈 클래스가 속한 최상위 클래스 이름으로 X__BeanDefinitions 클래스를 얻고(중첩 클래스면 같은 구조의 중첩 클래스를 안에 만듭니다, 117–138행), 거기에 BeanDefinition을 돌려주는 static 메서드를 하나 더합니다(169–177행). 메서드 본문은 new RootBeanDefinition(클래스)로 시작해(DefaultBeanRegistrationCodeFragments 131–132행) 속성을 채우고, 마지막에 인스턴스 공급자를 꽂습니다. 그 공급자 코드를 만드는 것이 InstanceSupplierCodeGenerator입니다(141–151행).

실행 시점에 이 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로 실행했습니다.

헷갈리기 쉬운 곳

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 파일 셋이 실행 시점에 이 길로 쓰입니다.

실행 시점에 CGLIB 클래스가 정해지는 길. AotDetector.useGeneratedArtifacts() 는 네이티브 이미지이거나 spring.aot.enabled=true 면 참이고, ConfigurationClassEnhancer 184행, CglibSubclassingInstantiationStrategy 157행, CglibAopProxy 204행이 그 결과를 enhancer.setAttemptLoad 에 넘긴다. AbstractClassGenerator.generate 는 클래스 이름을 먼저 정하고, attemptLoad 가 참이면 그 이름의 클래스를 클래스 로더에서 불러 본다(ReflectUtils.loadClass, 355행). 있으면 빌드 시점에 미리 만든 CGLIB 클래스(6절 예제의 CLASS 파일)를 그대로 쓴다. 없으면(ClassNotFoundException 은 무시) 또는 attemptLoad 가 거짓이면, 네이티브 이미지 안에서는 UnsupportedOperationException 'CGLIB runtime enhancement not supported on native image' 를 던지고, 그 밖에서는 평소처럼 실행 중에 바이트코드를 만든다(strategy.generate) 미리 만든 클래스가 있으면 불러 쓰고, 없으면 평소처럼 만듭니다. 네이티브 이미지 안에서는 만들 수 없어서 예외입니다(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에서 따라갑니다.

다른 글