namalzi

← 글 목록

Spring 소스 파헤치기 13: ResolvableType과 MergedAnnotations — 타입과 어노테이션을 읽는 법

Spring · 게시

Spring이 List<String>의 String을 어떻게 알아내는지(ResolvableType), 어노테이션 위에 붙은 어노테이션과 @AliasFor를 어떻게 하나로 합쳐 읽는지(MergedAnnotations), 클래스를 로딩하지 않고 .class 파일에서 어떻게 읽는지(MetadataReader)를 Spring을 처음 보는 사람 기준으로 실제 소스와 예제로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 열세 번째 글입니다. 12편까지는 컨테이너(Phase 1)와 AOP(Phase 2)를 읽었습니다. 이번 글부터는 그 아래층, 모두가 가져다 쓰는 기반 기술(Phase 3)로 내려갑니다. 1편의 지도에서 "따로 진도를 나가지 않고 막히는 곳에서 꺼내 본다"고 했던 U11이 이 글입니다.

앞 글에서 설명 없이 지나간 곳이 세 군데 있습니다. 5편에서 DependencyDescriptor가 List<Service> 필드의 원소 타입이 Service라는 것을 "안다"고 했고, 8편에서 컴포넌트 스캔이 클래스를 "로딩하지 않고" 어노테이션을 읽는다고 했고, 9편에서 @EnableXxx가 @Import를 "메타 어노테이션으로 가진다"고 했습니다. 이 세 가지가 어떻게 가능한지가 이 글의 내용입니다.

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

항목 내용
기준 소스 Spring Framework 7.1.0-SNAPSHOT
커밋 f447f3c310 (2026-10-01, main 브랜치)
주로 읽는 모듈 spring-core
주로 읽는 클래스 ResolvableType, MergedAnnotations, AnnotationTypeMapping, MetadataReader, SimpleAnnotationMetadataReadingVisitor

글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.

원래 공부 노트는 줄 단위로 훨씬 깊이 들어가지만, 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.

먼저 알아 둘 말

제네릭(generic)은 List<String>처럼 타입 안에 다른 타입을 적어 넣는 자바 문법입니다. <String> 부분을 타입 인자라고 부릅니다. 자바 컴파일러는 이것을 검사만 하고 실행 코드에서는 지워 버립니다(타입 소거). 그래서 실행 중에 List 객체를 들고 "이 리스트의 원소 타입이 무엇이냐"고 물어볼 방법은 없습니다. 다만 필드나 메서드 선언에 적힌 제네릭은 .class 파일에 남아 있어서 리플렉션으로 읽을 수 있습니다. 이 글의 ResolvableType은 그 선언을 읽는 도구입니다.

리플렉션(reflection)은 실행 중에 클래스의 필드·메서드·어노테이션을 들여다보는 자바 API입니다. Field.getGenericType(), Class.getAnnotation() 같은 메서드가 리플렉션입니다. 쓰려면 그 클래스가 메모리에 올라와 있어야 합니다.

메타 어노테이션(meta-annotation)은 어노테이션 위에 붙은 어노테이션입니다. @Service의 선언을 열어 보면 그 위에 @Component가 붙어 있습니다. 이때 @Component는 @Service의 메타 어노테이션입니다. 자바의 리플렉션은 클래스에 직접 붙은 @Service만 보여 주고 그 위의 @Component는 보여 주지 않습니다. Spring은 그것을 따라 올라가서 읽습니다.

클래스 로딩과 초기화는 다릅니다. 로딩은 .class 파일을 JVM이 읽어 Class 객체를 만드는 것이고, 초기화는 그 클래스의 static 필드 값을 채우고 static {} 블록을 실행하는 것입니다. Class.forName("...")은 둘 다 합니다. 이 글의 세 번째 도구는 둘 다 하지 않고 파일만 읽습니다.

바이트코드는 .class 파일 안의 내용입니다. 자바 코드가 아니라 JVM이 읽는 명령어와 표입니다. 바이트코드를 읽고 쓰는 라이브러리로 ASM이 유명하고, Spring은 ASM을 자기 소스 안에 복사해 넣고 씁니다.

동적 프록시(JDK dynamic proxy)는 인터페이스를 구현하는 클래스를 실행 중에 만들어 주는 자바 기능입니다. 자바에서 어노테이션은 인터페이스의 한 종류라서, 어노테이션 객체도 동적 프록시로 만들 수 있습니다. 10편의 JDK 프록시와 같은 기능입니다.

1. 세 도구는 어디에서 쓰이나

먼저 전체 그림입니다. 이 글의 세 도구는 모두 spring-core 모듈에 있고, 컨테이너와 그 주변 코드가 가져다 씁니다.

spring-core의 세 도구와 그것을 쓰는 곳. DependencyDescriptor와 이벤트 멀티캐스터는 ResolvableType을, AnnotationUtils와 AnnotationMetadata는 MergedAnnotations를, 컴포넌트 스캔과 Configuration 판정은 MetadataReader를 쓴다. MetadataReader가 읽은 어노테이션은 MergedAnnotations가 된다. 파란 상자가 이 글의 세 도구, 회색 상자가 그것을 쓰는 곳입니다.

도구 패키지 답하는 질문 쓰는 곳 (예)
ResolvableType org.springframework.core 이 필드·파라미터의 제네릭 타입은 무엇인가 주입할 빈 고르기(5편), 이벤트 리스너 고르기(12편)
MergedAnnotations org.springframework.core.annotation 이 클래스에 (메타 어노테이션까지 포함해) 어떤 어노테이션이 어떤 값으로 붙어 있는가 AnnotationUtils, AnnotatedElementUtils, 빈 이름 정하기, @Import 찾기
MetadataReader org.springframework.core.type.classreading 클래스를 로딩하지 않고 위 질문에 답할 수 있는가 컴포넌트 스캔, @Configuration 판정(8편)

세 도구는 서로 기대고 있습니다. MetadataReader가 바이트코드에서 읽은 어노테이션은 MergedAnnotations로 만들어져서, 리플렉션으로 읽었을 때와 같은 API로 다룰 수 있습니다. 4절에서 그 연결을 봅니다.

2. ResolvableType — 제네릭을 읽는 법

자바 리플렉션으로도 되지만 불편합니다

먼저 Spring 없이 해 봅니다. 필드의 제네릭은 Field.getGenericType()으로 읽을 수 있는데, 돌아오는 것이 Class가 아니라 Type입니다. Type은 Class, ParameterizedType(List<String>), TypeVariable(T), WildcardType(?), GenericArrayType(T[])의 공통 부모라서, 원하는 것을 꺼내려면 매번 instanceof로 갈라 타야 합니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
Field names = ResolvableTypeExample.class.getDeclaredField("names");   // List<String> names;
Type generic = names.getGenericType();
if (generic instanceof ParameterizedType pt) {
    Type first = pt.getActualTypeArguments()[0];     // String — 그러나 타입은 Type
}

Map<String, List<Integer>>에서 Integer를 꺼내려면 이 instanceof를 두 번 겹쳐야 하고, T나 ?가 나오면 또 다른 분기가 필요합니다. 그 일을 대신해 주는 것이 ResolvableType입니다.

예제: 같은 것을 ResolvableType으로

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
List<String> names;
Map<String, List<Integer>> scores;

ResolvableType namesType = ResolvableType.forField(names);
namesType.getGeneric(0).resolve();           // String.class

ResolvableType scoresType = ResolvableType.forField(scores);
scoresType.getGeneric(1);                    // List<Integer> (ResolvableType)
scoresType.getGeneric(1, 0).resolve();       // Integer.class
scoresType.getGeneric(5);                    // ResolvableType.NONE (예외도 null도 아님)

실행 결과입니다(전체 코드는 ex/u11/ResolvableTypeExample.java).

getType()        = interface java.util.List
getGenericType() = java.util.List<java.lang.String>
원소 타입(리플렉션) = class java.lang.String / Class 인가? true
ResolvableType   = java.util.List<java.lang.String>
getGeneric(0)    = class java.lang.String
scores           = java.util.Map<java.lang.String, java.util.List<java.lang.Integer>>
getGeneric(1)    = java.util.List<java.lang.Integer>
getGeneric(1, 0) = class java.lang.Integer
getGeneric(5)    = ? (NONE 인가? true)
asMap()          = java.util.Map<java.lang.String, java.util.List<java.lang.Integer>>
List<String> <- List<String>  : true
List<String> <- List<Integer> : false
List.class.isAssignableFrom(List.class) : true
equals? true / 같은 객체? false

이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 위 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다).

Map<String, List>를 ResolvableType으로 탐색하는 트리. getGeneric(0)은 String, getGeneric(1)은 List, 거기서 다시 getGeneric(0)은 Integer이고, getGeneric(1, 0)은 두 단계를 한 번에 간다. 범위를 벗어난 getGeneric(5)는 NONE을 돌려주고 NONE의 resolve()는 null이다. getGeneric()은 한 단계 안으로, resolve()는 Class로. 초록은 Class를 얻은 곳, 빨강은 없는 것을 물었을 때입니다.

설계: Type을 감싸고, 메서드마다 다시 ResolvableType을 돌려준다

ResolvableType의 클래스 설명 주석은 "자바 Type을 감싸서 상위 타입, 인터페이스, 제네릭 파라미터에 접근하게 하고, 마지막에 Class로 풀 수(resolve) 있게 한다"고 적고, getGeneric·as·getSuperType 같은 대부분의 메서드가 또 ResolvableType을 돌려주므로 체인으로 이어 쓸 수 있다는 점을 강조합니다(ResolvableType 49–88행). 체인이 중간에 끊기지 않게 하려고 "없음"을 null이 아니라 NONE이라는 특별한 객체로 돌려줍니다(95행). 위 예제의 getGeneric(5)가 그것입니다.

getGeneric()은 짧습니다.

	public ResolvableType getGeneric(int @Nullable ... indexes) {
		ResolvableType[] generics = getGenerics();
		if (indexes == null || indexes.length == 0) {
			return (generics.length == 0 ? NONE : generics[0]);
		}
		ResolvableType generic = this;
		for (int index : indexes) {
			generics = generic.getGenerics();
			if (index < 0 || index >= generics.length) {
				return NONE;
			}
			generic = generics[index];
		}
		return generic;
	}

spring-core/src/main/java/org/springframework/core/ResolvableType.java 754–768행

인덱스를 여러 개 주면 한 단계씩 안으로 들어가고, 범위를 벗어나면 NONE입니다. getGenerics()는 감싼 Type이 ParameterizedType이면 getActualTypeArguments()를, 그냥 Class면 getTypeParameters()를 하나씩 ResolvableType으로 감싸서 배열로 만들고 필드에 캐시해 둡니다(783–819행).

resolve()는 미리 계산해 둔 resolved 필드를 돌려줄 뿐입니다(882–884행). 그 값을 계산하는 resolveClass()는 Class면 그대로 돌려주고, 배열(GenericArrayType)이면 원소 타입을 먼저 푼 뒤 배열 클래스를 만들고, 나머지는 resolveType()에 맡깁니다(901–913행). Type의 종류에 따라 가르는 곳은 그 resolveType()입니다. ParameterizedType이면 원시 타입(List<String>의 List), 와일드카드와 타입 변수면 경계(bound)를 따라가고, 타입 변수에 대해서는 **VariableResolver**에게 "이 T가 실제로 무엇이냐"고 물어봅니다(920–943행). ResolvableType.forClass(MyList.class).as(List.class)처럼 상위 타입으로 올라가면(485–500행) 바깥 타입이 안쪽의 VariableResolver 역할을 맡아 class MyList extends ArrayList<String>의 E를 String으로 풀어 줍니다. 이 글에서는 이 정도만 적고, 타입 변수 해석의 세부는 "더 깊이 들어가면"으로 미룹니다.

어디서 만드나: forField, forMethodParameter, forClass, forType

만드는 쪽 정적 메서드는 출발점에 따라 넷으로 나뉩니다.

메서드 출발점 위치
forClass(Class) Class 객체 1104–1106행
forField(Field) 필드 선언 1222–1225행
forMethodParameter(Method, int) 메서드 파라미터. 안에서 MethodParameter를 만든다 1364–1366행
forType(Type) 이미 손에 있는 자바 Type 1481–1483행

MethodParameter는 "어느 메서드(또는 생성자)의 몇 번째 파라미터인가"를 담는 작은 명세 객체입니다(MethodParameter 47–64행). List<List<String>>처럼 겹친 타입의 몇 번째 겹을 보는지를 nestingLevel로 들고 다니는데(298–300행), 5편의 DependencyDescriptor가 Optional<Service>나 ObjectProvider<Service>를 다룰 때 이 값을 씁니다.

네 메서드 모두 결국 하나의 forType(Type, TypeProvider, VariableResolver)로 모입니다. 여기에 캐시가 있습니다.

	static ResolvableType forType(
			@Nullable Type type, @Nullable TypeProvider typeProvider, @Nullable VariableResolver variableResolver) {

		if (type == null && typeProvider != null) {
			type = SerializableTypeWrapper.forTypeProvider(typeProvider);
		}
		if (type == null) {
			return NONE;
		}

		// For simple Class references, build the wrapper right away -
		// no expensive resolution necessary, so not worth caching...
		if (type instanceof Class) {
			return new ResolvableType(type, null, typeProvider, variableResolver);
		}

		// Check the cache - we may have a ResolvableType which has been resolved before...
		ResolvableType resultType = new ResolvableType(type, typeProvider, variableResolver);
		ResolvableType cachedType = cache.get(resultType);
		if (cachedType == null) {
			cachedType = new ResolvableType(type, typeProvider, variableResolver, resultType.hash);
			cache.put(cachedType, cachedType);
		}
		resultType.resolved = cachedType.resolved;
		return resultType;
	}

spring-core/src/main/java/org/springframework/core/ResolvableType.java 1533–1558행

forField가 forType까지 가는 길. FieldTypeProvider가 필드를 기억하고 SerializableTypeWrapper가 Type을 직렬화 가능한 프록시로 감싼다. Type이 그냥 Class면 캐시하지 않고 바로 돌려주고, 아니면 열쇠용 ResolvableType을 만들어 cache를 찾는다. 없으면 resolveClass로 풀어 넣고, 있으면 풀어 둔 Class를 새 객체에 복사한다. 노란 상자가 정적 캐시입니다. 캐시에 남는 것은 "풀어 둔 결과"이고, 호출자가 받는 객체는 매번 새것입니다.

읽을 점은 세 가지입니다.

  1. 그냥 Class는 캐시하지 않습니다. 풀 것이 없으니 바로 감싸서 돌려줍니다. 주석에 "not worth caching"이라고 적혀 있습니다.
  2. 캐시의 열쇠는 ResolvableType 자신입니다. equals()가 Type·TypeProvider·VariableResolver를 모두 비교하도록 되어 있어서(992–1015행) 같은 필드를 두 번 물으면 같은 열쇠가 됩니다. 캐시 맵은 ConcurrentReferenceHashMap인데(99–100행), 항목을 소프트 참조(soft reference)로 들고 있어서 메모리가 모자라면 비워지는 맵입니다. 클래스를 동적으로 많이 만드는 환경에서 캐시가 끝없이 자라지 않게 하는 선택입니다.
  3. 돌려주는 것은 캐시 항목이 아니라 새 객체입니다. 마지막 두 줄에서 캐시에 있던 resolved만 복사해 옵니다. 예제의 마지막 줄 equals? true / 같은 객체? false가 이것입니다.

SerializableTypeWrapper는 왜 있나

forField는 Field.getGenericType()을 직접 쓰지 않고 FieldTypeProvider를 거쳐 SerializableTypeWrapper.forTypeProvider()에 맡깁니다. 이 클래스의 설명 주석은 "java.lang.reflect.Type의 직렬화 가능한 변형을 얻기 위한 내부 유틸리티"라고 적습니다(SerializableTypeWrapper 38–56행). 문제는 JDK의 ParameterizedType 구현체가 Serializable이 아니라는 것입니다. ResolvableType은 Serializable인데(89행) 안에 든 Type이 직렬화되지 않으면 소용이 없습니다. 그래서 Type을 동적 프록시로 감싸고, 프록시 안에는 "어느 클래스의 어느 필드였는지"만 적어 둡니다. 역직렬화할 때는 그 이름으로 필드를 다시 찾습니다(229–262행). 감쌀 수 있는 Type은 GenericArrayType, ParameterizedType, TypeVariable, WildcardType 네 가지이고 Class는 이미 Serializable이라 감싸지 않습니다(59–60행, 103–131행). GraalVM 네이티브 이미지처럼 애초에 직렬화가 안 되는 환경에서는 감싸지 않고 원래 Type을 그대로 씁니다(109–113행).

헷갈리기 쉬운 곳

3. MergedAnnotations — 어노테이션 위의 어노테이션

예제: 직접 만든 스테레오타입

@Component를 메타 어노테이션으로 가진 어노테이션을 하나 만들고, 자바와 Spring이 각각 무엇을 보는지 비교합니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component                                  // 메타 어노테이션
@interface MyService {
    @AliasFor(annotation = Component.class, attribute = "value")
    String name() default "";               // @Component 의 value 를 덮어쓴다
}

@MyService(name = "greeter")
static class Greeter {
}

// 자바 리플렉션
clazz.getAnnotation(Component.class);       // null — 직접 붙은 것이 아니므로

// Spring
MergedAnnotations annotations = MergedAnnotations.from(clazz, SearchStrategy.TYPE_HIERARCHY);
MergedAnnotation<Component> component = annotations.get(Component.class);
component.getString("value");               // "greeter"
component.synthesize().value();             // "greeter" — 진짜 Component 객체

실행 결과입니다(전체 코드는 ex/u11/MergedAnnotationExample.java).

getAnnotation(Component) = null
getAnnotation(MyService) = @ex.u11.MergedAnnotationExample.MyService(name="greeter")
isPresent         = true
isDirectlyPresent = false
isMetaPresent     = true
getDistance       = 1
getString(value)  = greeter
getMetaSource     = MyService
synthesized.value() = greeter
프록시인가?         = true
findMergedAnnotation(...).value() = greeter
빈 이름 = [greeter]

자바는 @Component를 보지 못하는데 Spring은 보고, 값도 @Component의 value가 아니라 @MyService의 name에서 가져왔습니다. 마지막 줄은 컨테이너에 Greeter를 등록했을 때 빈 이름이 greeter가 되었다는 뜻입니다. 8편에서 본 AnnotationBeanNameGenerator가 같은 길로 값을 읽기 때문입니다(AnnotationBeanNameGenerator 135–148행). Spring 자신의 @Service도 같은 방식입니다. 속성 이름이 name이 아니라 value라서 attribute를 생략한 @AliasFor(annotation = Component.class)가 value()에 붙어 있습니다(Service 53–54행).

전체 흐름

MergedAnnotations.from(클래스, 전략)이 한 번 불리면 안에서 이렇게 흘러갑니다.

MergedAnnotations.from이 처리되는 흐름. AnnotationsScanner가 클래스, 인터페이스, 상위 클래스 순으로 직접 붙은 어노테이션을 모으고, AnnotationFilter.PLAIN이 java.lang 어노테이션을 거른다. AnnotationTypeMappings가 어노테이션마다 메타 어노테이션을 너비 우선으로 따라 올라가 mapping 0(MyService, distance 0)과 mapping 1(Component, distance 1, aliasMappings가 name을 value에 잇는다)을 만든다. get(Component.class)의 결과인 TypeMappedAnnotation에서 getString은 root의 name()을 읽어 greeter를 돌려주고 synthesize는 JDK 동적 프록시를 만든다. 노란 상자 AnnotationTypeMappings는 어노테이션 타입마다 한 번만 계산해 캐시합니다.

  1. MergedAnnotations.from(element, strategy)는 인자를 검사하고 TypeMappedAnnotations.from()에 넘깁니다(MergedAnnotations 331–333행, 373–383행). MergedAnnotations는 인터페이스이고 from()이 돌려주는 구현이 TypeMappedAnnotations입니다(TypeMappedAnnotations 34–42행). 이때는 아직 아무것도 읽지 않습니다. 어노테이션이 있을 리 없는 경우(java. 패키지의 클래스 등)만 미리 걸러서 공유 객체 NONE을 돌려줍니다(249–256행, AnnotationsScanner 471–482행).
  2. get(Component.class)을 부르면 그제야 scan()이 AnnotationsScanner를 돌립니다(143–153행, 236–246행). 스캐너는 전략에 따라 어느 클래스까지 뒤질지 정하고(AnnotationsScanner 99–108행), 클래스마다 직접 붙은 어노테이션 배열을 처리기에 넘깁니다.
  3. 처리기 MergedAnnotationFinder는 어노테이션 하나를 받을 때마다 그 타입의 AnnotationTypeMappings를 얻어, 그 안의 매핑 가운데 찾는 타입(Component)과 맞는 것이 있으면 TypeMappedAnnotation을 만들어 돌려줍니다(404–430행). 여러 후보 가운데 무엇을 고를지는 MergedAnnotationSelector가 정하고 기본은 "가장 가까운 것"(nearest)입니다.

핵심은 3번의 AnnotationTypeMappings입니다.

AnnotationTypeMappings: 메타 어노테이션을 따라 올라가는 표

AnnotationTypeMappings의 설명 주석은 "어노테이션 타입 하나에 대해 모든 메타 어노테이션을 너비 우선으로 훑어, 루트 어노테이션의 속성을 빠르게 매핑하는 방법을 제공한다"고 적습니다(34–52행). 코드도 그대로입니다.

	private void addAllMappings(Class<? extends Annotation> annotationType,
			Set<Class<? extends Annotation>> visitedAnnotationTypes) {

		Deque<AnnotationTypeMapping> queue = new ArrayDeque<>();
		addIfPossible(queue, null, annotationType, null, false, visitedAnnotationTypes);
		while (!queue.isEmpty()) {
			AnnotationTypeMapping mapping = queue.removeFirst();
			this.mappings.add(mapping);
			addMetaAnnotationsToQueue(queue, mapping);
		}
	}

spring-core/src/main/java/org/springframework/core/annotation/AnnotationTypeMappings.java 81–91행

큐에 루트(@MyService)를 넣고, 꺼낼 때마다 그 어노테이션 선언 위에 붙은 메타 어노테이션들을 큐 뒤에 더합니다. 결과 목록의 0번은 항상 루트이고 뒤로 갈수록 멀리 있는 메타 어노테이션입니다(162–173행). 예제의 getDistance = 1은 @Component가 루트에서 한 칸 떨어져 있다는 뜻입니다.

이 표는 "어노테이션 타입"에 대한 것이지 "어노테이션 인스턴스"에 대한 것이 아니라서, 타입마다 한 번만 계산해 정적 캐시에 둡니다(57–59행, 231–253행). @Service가 붙은 클래스가 천 개여도 @Service의 매핑은 한 번만 만듭니다.

큐에 넣을지 말지를 정하는 isMappable()에 두 가지 보호 장치가 있습니다(136–140행).

AnnotationTypeMapping: @AliasFor로 속성을 잇는다

표의 항목 하나가 AnnotationTypeMapping입니다. 생성자에서 자기 어노테이션의 속성 메서드 목록(AttributeMethods)을 얻고, 각 속성에 붙은 @AliasFor를 읽어 "누가 누구의 별칭인가"를 정리한 뒤 processAliases()를 돌립니다(AnnotationTypeMapping 85–102행, 115–127행).

@AliasFor의 속성은 셋입니다. value와 attribute는 같은 뜻(가리킬 속성 이름)이고, annotation은 그 속성이 어느 어노테이션에 있는지입니다(AliasFor 191행, 200행, 207행). annotation을 비우면 같은 어노테이션 안의 두 속성이 서로 별칭이라는 뜻이고(@ComponentScan의 value와 basePackages, ComponentScan 80행, 98행), 다른 어노테이션을 적으면 메타 어노테이션의 속성을 덮어쓴다는 뜻입니다(예제의 name → @Component.value). AliasFor의 설명 주석은 이 두 경우와, 같은 메타 속성을 가리키는 속성들이 자동으로 서로 별칭이 되는 "암묵적 별칭"까지 세 가지 쓰임새를 적어 두었습니다(26–49행).

별칭을 처리하는 코드의 핵심은 이것입니다.

	private void processAliases(int attributeIndex, List<Method> aliases) {
		int rootAttributeIndex = getFirstRootAttributeIndex(aliases);
		AnnotationTypeMapping mapping = this;
		while (mapping != null) {
			if (rootAttributeIndex != -1 && mapping != this.root) {
				for (int i = 0; i < mapping.attributes.size(); i++) {
					if (aliases.contains(mapping.attributes.get(i))) {
						mapping.aliasMappings[i] = rootAttributeIndex;
					}
				}
			}
			mapping.mirrorSets.updateFrom(aliases);
			mapping.claimedAliases.addAll(aliases);
			// ...
			mapping = mapping.source;
		}
	}

spring-core/src/main/java/org/springframework/core/annotation/AnnotationTypeMapping.java 224–249행

별칭 묶음(aliases) 안에 루트 어노테이션의 속성이 있으면, 그 묶음에 속한 메타 어노테이션 쪽 속성마다 aliasMappings[i] = 루트 속성의 번호를 적어 둡니다. 예제라면 @Component 매핑의 value(0번) 자리에 "루트 @MyService의 name(0번)을 읽어라"가 적히는 셈입니다. 값을 실제로 읽는 TypeMappedAnnotation.getValue()는 이 표를 먼저 봅니다.

	private @Nullable Object getValue(int attributeIndex, boolean forMirrorResolution) {
		AnnotationTypeMapping mapping = this.mapping;
		if (this.useMergedValues) {
			int mappedIndex = this.mapping.getAliasMapping(attributeIndex);
			if (mappedIndex != -1) {
				mapping = mapping.getRoot();
				attributeIndex = mappedIndex;
			}
		}
		// ...
		if (mapping.getDistance() == 0) {
			Method attribute = mapping.getAttributes().get(attributeIndex);
			Object result = this.valueExtractor.extract(attribute, this.rootAttributes);
			return (result != null ? result : attribute.getDefaultValue());
		}
		return getValueFromMetaAnnotation(attributeIndex, forMirrorResolution);
	}

spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotation.java 395–417행

getString("value")를 부르면 @Component의 value(0번)에 매핑이 있으니 루트로 올라가 @MyService(name = "greeter")의 name을 읽습니다. 매핑이 없는 속성은 자기 메타 어노테이션 선언에 적힌 값(또는 기본값)을 읽습니다. "합쳐진 뷰"라는 말이 뜻하는 것이 이것입니다. 실제 어노테이션 객체를 고치는 것이 아니라 값을 읽을 때 어디서 읽을지를 바꿉니다.

synthesize(): 합쳐진 값을 가진 진짜 어노테이션 객체

getString으로 하나씩 꺼내는 대신 Component 타입의 객체가 필요할 때가 있습니다. synthesize()가 그것을 만듭니다. 설명 주석에 "JDK Proxy로 합성하므로 처음 부를 때 계산 비용이 든다"고 적혀 있습니다(MergedAnnotation 503–526행). 만드는 코드는 짧습니다.

	static <A extends Annotation> A createProxy(MergedAnnotation<A> annotation, Class<A> type) {
		ClassLoader classLoader = type.getClassLoader();
		Class<?>[] interfaces = new Class<?>[] {type};
		InvocationHandler handler = new SynthesizedMergedAnnotationInvocationHandler<>(annotation, type);
		return (A) Proxy.newProxyInstance(classLoader, interfaces, handler);
	}

spring-core/src/main/java/org/springframework/core/annotation/SynthesizedMergedAnnotationInvocationHandler.java 286–291행

프록시의 invoke()는 불린 메서드 이름이 속성 이름이면 MergedAnnotation에서 값을 꺼내 주고, annotationType·equals·hashCode·toString은 따로 처리합니다(74–90행). 예제의 프록시인가? = true가 이 객체입니다. 한 번 만든 프록시는 MergedAnnotation 안에 기억해 두어 두 번 만들지 않습니다(AbstractMergedAnnotation 202–213행). 합칠 것이 없는 어노테이션(@AliasFor가 어디에도 없는 경우)은 프록시를 만들지 않고 원래 어노테이션 객체를 그대로 돌려줍니다(TypeMappedAnnotation 330–343행).

SearchStrategy: 어느 클래스까지 뒤지는가

from()의 두 번째 인자 SearchStrategy는 네 가지입니다(MergedAnnotations 644–684행).

SearchStrategy 네 가지가 점점 넓어지는 순서. DIRECT는 그 클래스에 직접 붙은 것만, INHERITED_ANNOTATIONS는 Inherited가 붙은 상위 클래스 어노테이션까지, SUPERCLASS는 상위 클래스 전부, TYPE_HIERARCHY는 구현한 인터페이스까지. 네 가지 모두 메타 어노테이션은 따라 올라가고 차이는 어느 클래스까지 뒤지는가뿐이다. 아래로 갈수록 넓게 찾습니다. 메타 어노테이션을 따라 올라가는 것은 전략과 상관없이 항상 합니다.

전략 뒤지는 범위 소스
DIRECT 그 요소에 직접 붙은 것만. @Inherited도 무시 651행
INHERITED_ANNOTATIONS + @Inherited가 붙은 상위 클래스의 어노테이션(자바 규칙과 같음) 661행
SUPERCLASS + 상위 클래스 전부(@Inherited 없어도). 인터페이스는 안 봄 670행
TYPE_HIERARCHY + 구현한 인터페이스까지 전부 682행

TYPE_HIERARCHY일 때 스캐너는 클래스 자신 → 인터페이스들(재귀) → 상위 클래스(재귀) 순으로 돕니다. 직접 붙은 것(거리 0)을 찾으면 거기서 멈추고, 메타 어노테이션으로만 찾았으면 더 가까운 것이 있는지 끝까지 봅니다(AnnotationsScanner 173–233행, MergedAnnotationSelectors 65–70행). 바깥(enclosing) 클래스까지 뒤지는 전략은 이 네 가지에 따로 없고, MergedAnnotations.search(TYPE_HIERARCHY).withEnclosingClasses(조건)으로 켭니다(578–585행).

겉에서 쓰는 창구: AnnotationUtils와 AnnotatedElementUtils

Spring 코드 대부분은 MergedAnnotations를 직접 부르지 않고 두 유틸리티를 씁니다. 둘 다 안에서 MergedAnnotations.from(...)을 부릅니다.

메서드 안에서 하는 일 소스
AnnotationUtils.findAnnotation(clazz, type) from(clazz, TYPE_HIERARCHY).get(type).withNonMergedAttributes().synthesize(...) — 메타 어노테이션은 따라가되 @AliasFor 합치기는 하지 않음 570–595행
AnnotatedElementUtils.findMergedAnnotation(element, type) from(element, TYPE_HIERARCHY)...synthesize() — 합치기까지 함 631–641행, 800–801행

이름에 Merged가 들어간 쪽이 @AliasFor 덮어쓰기까지 적용한 값을 줍니다. 두 메서드 모두 맨 앞에 지름길이 있어서, 찾는 어노테이션이 java.lang 것이거나 클래스가 java. 패키지면 MergedAnnotations를 거치지 않고 자바 리플렉션으로 바로 답합니다.

헷갈리기 쉬운 곳

4. MetadataReader — 클래스를 로딩하지 않고 읽기

왜 로딩하지 않고 읽어야 하나

8편의 컴포넌트 스캔은 패키지 안의 .class 파일을 전부 열어 @Component가 붙었는지 봅니다. 그때 파일마다 Class.forName을 하면 두 가지가 문제입니다. 빈이 되지 않을 클래스까지 전부 메모리에 올라오고, 로딩에 딸려 오는 static {} 블록과 상위 클래스·인터페이스 로딩이 연쇄로 일어납니다. 상위 클래스가 클래스패스에 없으면 NoClassDefFoundError가 나서 스캔 자체가 멈춥니다. 그래서 Spring은 파일을 읽기만 합니다.

예제: static 블록이 돌지 않는다

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Service("heavy")
public class Heavy {
    static {
        System.out.println("   >>> Heavy 의 static 블록이 실행됐습니다 (클래스가 초기화됨)");
    }
}

MetadataReaderFactory factory = new SimpleMetadataReaderFactory();
MetadataReader reader = factory.getMetadataReader("ex.u11.Heavy");      // 이름만 준다
AnnotationMetadata metadata = reader.getAnnotationMetadata();
metadata.getClassName();                                               // "ex.u11.Heavy"
metadata.hasMetaAnnotation(Component.class.getName());                 // true
metadata.getAnnotations().get(Component.class).getString("value");     // "heavy"
Class.forName("ex.u11.Heavy");                                         // 여기서야 static 블록

실행 결과입니다(전체 코드는 ex/u11/MetadataReaderExample.java와 Heavy.java).

Java 21 / 기본 팩토리 = SimpleMetadataReaderFactory
1. 메타데이터를 읽습니다
   클래스 이름 = ex.u11.Heavy
   직접 붙은 어노테이션 = [org.springframework.stereotype.Service]
   @Component 가 메타로 있나 = true
   @Component 의 value = heavy
   (static 블록은 아직 돌지 않았습니다)
2. Class.forName 으로 로딩하고 초기화합니다
   >>> Heavy 의 static 블록이 실행됐습니다 (클래스가 초기화됨)
3. 끝

클래스 이름, 붙은 어노테이션, 메타 어노테이션, 심지어 @AliasFor로 합쳐진 값까지 읽었는데 static 블록은 Class.forName 줄에서야 돌았습니다. 첫 줄의 "기본 팩토리"는 아래에서 설명합니다.

두 가지 길, 같은 인터페이스

클래스 메타데이터를 얻는 두 길. 클래스를 이미 로딩했을 때는 AnnotationMetadata.introspect(Class)가 StandardAnnotationMetadata를 만들어 리플렉션으로 읽는다. 로딩하지 않을 때는 MetadataReaderFactory가 클래스 이름을 classpath 경로로 바꿔 Resource를 얻고, ASM ClassReader가 바이트를 읽어 SimpleAnnotationMetadataReadingVisitor의 visit, visitAnnotation, visitMethod를 부르며, 그 결과가 SimpleAnnotationMetadata가 된다. Java 24 이상에서는 ASM 대신 ClassFile API를 쓴다. CachingMetadataReaderFactory가 팩토리를 감싸 Resource마다 한 번만 읽는다. 두 길 모두 AnnotationMetadata 인터페이스로 모인다. 왼쪽이 바이트코드, 오른쪽이 리플렉션. 어느 쪽으로 만들었든 쓰는 쪽은 AnnotationMetadata만 봅니다.

ClassMetadata는 "클래스를 아직 로딩하지 않아도 되는 형태로 클래스의 추상적인 메타데이터를 정의하는 인터페이스"입니다(ClassMetadata 21–31행). 메서드는 getClassName(), isInterface(), isAbstract(), getSuperClassName(), getInterfaceNames()처럼 boolean 아니면 **이름(String)**을 돌려주고, Class 객체를 돌려주는 메서드는 하나도 없습니다. Class 객체를 돌려주면 로딩해야 하기 때문입니다. AnnotationMetadata는 여기에 어노테이션 쪽을 더한 인터페이스로(AnnotationMetadata 27–48행), hasMetaAnnotation() 같은 메서드가 기본 메서드로 들어 있는데 구현을 보면 getAnnotations()가 돌려주는 **MergedAnnotations**에 묻는 것뿐입니다(97–100행). 3절의 도구가 여기서 다시 쓰입니다.

구현은 둘입니다.

바이트코드 길: SimpleMetadataReader와 ASM 방문자

MetadataReader는 메서드가 셋뿐인 인터페이스입니다. getResource(), getClassMetadata(), getAnnotationMetadata()(MetadataReader 23–49행). ASM 구현인 SimpleMetadataReader의 생성자가 일을 다 합니다.

	SimpleMetadataReader(Resource resource, @Nullable ClassLoader classLoader) throws IOException {
		SimpleAnnotationMetadataReadingVisitor visitor = new SimpleAnnotationMetadataReadingVisitor(classLoader);
		getClassReader(resource).accept(visitor, PARSING_OPTIONS);
		this.resource = resource;
		this.annotationMetadata = visitor.getMetadata();
	}

	private static ClassReader getClassReader(Resource resource) throws IOException {
		try (InputStream is = resource.getInputStream()) {
			try {
				return new ClassReader(is);
			}
			catch (IllegalArgumentException ex) {
				throw new ClassFormatException("ASM ClassReader failed to parse class file - " +
						"probably due to a new Java class file version that is not supported yet. " +
						"Consider compiling with a lower '-target' or upgrade your framework version. " +
						"Affected class: " + resource, ex);
			}
		}
	}

spring-core/src/main/java/org/springframework/core/type/classreading/SimpleMetadataReader.java 46–65행

Resource(12편)에서 입력 스트림을 열어 ASM의 ClassReader에 넘기고, 방문자(visitor)를 붙여 accept합니다. PARSING_OPTIONS는 SKIP_DEBUG | SKIP_CODE | SKIP_FRAMES, 곧 디버그 정보·메서드 본문·스택 프레임은 읽지 않는다는 뜻입니다(38–39행). 어노테이션과 시그니처만 필요하기 때문입니다. ClassFormatException은 ASM이 모르는 새 클래스 파일 버전을 만났을 때 던지는 예외로 6.1.2에 생겼고, IOException의 하위 타입이라 호출 쪽의 기존 catch (IOException)에 그대로 잡힙니다(ClassFormatException 23–33행).

ASM은 파일을 앞에서부터 읽으며 "클래스 선언을 봤다", "어노테이션을 봤다", "메서드를 봤다"를 방문자의 메서드로 알려 줍니다. SimpleAnnotationMetadataReadingVisitor가 그 방문자입니다(37–44행). visit()에서 클래스 이름·상위 클래스·인터페이스 이름을 받아 두고(77–89행), 어노테이션과 끝은 이렇게 처리합니다.

	@Override
	public @Nullable AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
		return MergedAnnotationReadingVisitor.get(this.classLoader, getSource(),
				descriptor, visible, this.annotations::add);
	}
	// ...
	@Override
	public void visitEnd() {
		MergedAnnotations annotations = MergedAnnotations.of(this.annotations);
		this.metadata = new SimpleAnnotationMetadata(this.className, this.access,
				this.enclosingClassName, this.superClassName, this.independentInnerClass,
				this.interfaceNames, this.memberClassNames, this.declaredMethods, annotations);
	}

spring-core/src/main/java/org/springframework/core/type/classreading/SimpleAnnotationMetadataReadingVisitor.java 111–135행

어노테이션 하나를 만나면 MergedAnnotationReadingVisitor가 속성 이름과 값을 맵에 모았다가 MergedAnnotation.of(classLoader, source, 타입, 속성맵)으로 MergedAnnotation 객체를 만듭니다(MergedAnnotationReadingVisitor 93–99행). 파일 끝에서 그 목록을 MergedAnnotations.of(...)로 묶으면(MergedAnnotations 465–467행) 3절에서 본 것과 같은 MergedAnnotations 인터페이스가 됩니다. 구현 클래스는 TypeMappedAnnotations가 아니라 MergedAnnotationsCollection이지만, 안에서 같은 AnnotationTypeMappings를 쓰므로(MergedAnnotationsCollection 48–60행) 메타 어노테이션 매핑도, @AliasFor 합치기도 그대로 동작합니다. 예제에서 바이트코드로 읽었는데도 @Service("heavy")의 값이 @Component의 value로 나온 이유입니다.

팩토리와 캐시, 그리고 Java 24

MetadataReader를 만드는 것은 MetadataReaderFactory입니다. 클래스 이름을 받으면 classpath: + 패키지를 /로 바꾼 경로 + .class로 Resource를 만들고, 파일이 없으면 "안쪽 클래스를 .로 적은 것 아닌가" 하고 마지막 .을 $로 바꿔 한 번 더 찾습니다(AbstractMetadataReaderFactory 56–80행). 구현은 셋입니다.

구현 하는 일 소스
SimpleMetadataReaderFactory 요청마다 새 ASM ClassReader 26–64행
ClassFileMetadataReaderFactory Java 24부터 들어온 JDK 표준 java.lang.classfile API로 읽는다. src/main/java24 폴더에 따로 있다 java24/.../ClassFileMetadataReaderFactory 26–63행
CachingMetadataReaderFactory 위 둘 중 하나를 감싸고 Resource마다 결과를 캐시 30–48행, 122–145행

ASM과 ClassFile API 중 무엇을 쓸지는 MetadataReaderFactory.create(...)가 정합니다(63–81행). 안에서 부르는 MetadataReaderFactoryDelegate가 같은 이름으로 두 벌 있습니다.

	static MetadataReaderFactory create(@Nullable ResourceLoader resourceLoader) {
		return new ClassFileMetadataReaderFactory(resourceLoader);
	}

spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java 33–35행

src/main/java 쪽의 같은 메서드는 new SimpleMetadataReaderFactory(resourceLoader)를 돌려줍니다(java/.../MetadataReaderFactoryDelegate 33–35행). spring-core는 Java 21과 24용 클래스를 한 jar에 함께 넣는 멀티 릴리스 jar로 빌드되어서(spring-core.gradle 13–15행), 실행하는 JVM이 24 이상이면 java24 쪽 클래스가 골라집니다. 예제가 Java 21에서 SimpleMetadataReaderFactory를 찍은 이유입니다. 컨테이너의 컴포넌트 스캔은 new CachingMetadataReaderFactory(resourceLoader)를 쓰고(ClassPathScanningCandidateComponentProvider 267행), 그 생성자가 MetadataReaderFactory.create()로 안쪽 팩토리를 고릅니다(CachingMetadataReaderFactory 64–88행).

org.springframework.asm: ASM을 복사해 넣은 패키지

SimpleMetadataReader가 import하는 ClassReader는 org.objectweb.asm이 아니라 org.springframework.asm입니다. 이 패키지의 설명 주석은 "ASM 9.x를 Spring이 다시 패키징한 것(Spring 전용 패치 포함, 내부용)"이고, 이렇게 하는 이유는 "애플리케이션이나 다른 라이브러리가 쓰는 ASM과 충돌하지 않게 하기 위해서"라고 적습니다(asm/package-info.java 1–14행). 폴더를 세어 보면 package-info.java를 빼고 Java 파일 33개이고, 각 파일 첫 줄은 Spring이 아니라 ASM의 저작권 표시입니다(ClassReader 1–2행). Spring 쪽에서 쓴 파일은 ASM API 버전 상수를 둔 SpringAsmInfo 하나입니다(SpringAsmInfo 36행). 이 글에서 ASM의 내부는 다루지 않습니다.

헷갈리기 쉬운 곳

5. 컨테이너의 어디에서 쓰이나

앞 글들에서 지나간 자리를 세 도구와 짝지어 봅니다.

앞 글의 장면 쓰는 도구 소스
@Autowired List<Service>에 Service 빈을 전부 모아 넣는다 (5편) descriptor.getResolvableType().asCollection().resolveGeneric()으로 원소 타입을 얻는다 DefaultListableBeanFactory 1866행, DependencyDescriptor 267–275행
Repository<User>에 Repository<Order>가 들어가지 않는다 (5편) 후보 빈의 타입과 ResolvableType.isAssignableFrom으로 제네릭까지 맞춰 본다 GenericTypeAwareAutowireCandidateResolver 76–80행
ApplicationListener<OrderEvent>에 OrderEvent만 간다 (12편) 빈 정의의 타입을 .as(ApplicationListener.class).getGeneric()으로 올려 이벤트 타입과 비교한다 AbstractApplicationEventMulticaster 356–357행
컴포넌트 스캔이 @Service도 @Component로 본다 (8편) AnnotationTypeFilter(Component.class)가 MetadataReader로 읽은 AnnotationMetadata에 hasMetaAnnotation을 묻는다 ClassPathScanningCandidateComponentProvider 217행, 464–466행, AnnotationTypeFilter 99–103행
어떤 빈 정의가 @Configuration 클래스인지 가린다 (8편) 이미 로딩된 클래스면 AnnotationMetadata.introspect, 아니면 MetadataReaderFactory로 읽는다 ConfigurationClassUtils 114–143행
@EnableAsync를 붙이면 @Import가 따라온다 (9편) 설정 클래스의 어노테이션들을 재귀로 따라가며 @Import의 value를 모은다 ConfigurationClassParser 568–578행, EnableAsync 167행
@Service("greeter")의 이름이 빈 이름이 된다 (8편) 직접 붙은 어노테이션마다 메타 어노테이션 목록을 보고 @Component 계열이면 value를 읽는다 AnnotationBeanNameGenerator 135–148행

위 표의 일곱 장면을 언제 일어나는지로 나눈 그림. 설계도를 만들 때(8·9편)에는 MergedAnnotations 와 MetadataReader 가 쓰인다 — @EnableAsync 에서 어노테이션을 재귀로 따라가 @Import 를 모으고, @Service("greeter")의 메타 어노테이션이 @Component 계열인지 보고 빈 이름을 읽고, @Configuration 클래스인지 가릴 때 로딩된 클래스면 introspect, 아니면 MetadataReader 로 읽고, 컴포넌트 스캔이 MetadataReader → AnnotationMetadata → hasMetaAnnotation 으로 @Service 도 @Component 로 본다. 빈을 만들 때와 이벤트를 보낼 때(5·12편)에는 ResolvableType 이 쓰인다 — @Autowired List의 원소 타입 Service 를 얻고, ApplicationListener를 .as(ApplicationListener.class).getGeneric()으로 읽고, Repository 자리에 Repository가 들어가지 않게 isAssignableFrom 으로 제네릭까지 맞춘다 왼쪽은 클래스를 아직 로딩하지 않았을 수도 있는 때, 오른쪽은 빈과 그 타입이 이미 있는 때입니다. 각 칸의 소스 위치는 위 표에 있습니다.

한 줄로 줄이면, 컨테이너가 "타입"을 말할 때는 ResolvableType이고 "어노테이션"을 말할 때는 MergedAnnotations이며, 아직 로딩하지 않은 클래스에 대해 말할 때는 MetadataReader가 그 둘을 대신 만들어 줍니다.

더 깊이 들어가면

이 글은 큰 줄기를 위해 많은 것을 뺐습니다. 이름과 위치만 적어 둡니다. 타입 변수를 바깥 타입에서 찾아 풀어 주는 VariableResolver와 ResolvableType 안에 있는 두 구현(ResolvableType 1573–1637행), 와일드카드의 상한·하한을 다루는 WildcardBounds(1701행), 제네릭을 코드로 조립하는 forClassWithGenerics(1162–1193행)와 그때 쓰는 SyntheticParameterizedType(1640행). 어노테이션 쪽에서는 같은 어노테이션 안에서 서로 별칭인 속성들이 서로 다른 값을 갖지 않게 검사하는 MirrorSets(AnnotationTypeMapping 557행), @Repeatable 어노테이션을 담는 컨테이너를 푸는 RepeatableContainers, 같은 타입의 후보 가운데 무엇을 고를지 정하는 MergedAnnotationSelectors, 메서드 어노테이션을 상위 클래스·인터페이스의 같은 메서드에서 찾는 AnnotationsScanner.processMethodHierarchy(261행), 웹 계층이 파라미터 어노테이션을 합성해 읽을 때 쓰는 SynthesizingMethodParameter. 바이트코드 쪽에서는 메서드 메타데이터를 읽는 SimpleMethodMetadataReadingVisitor와 MethodMetadata, Java 24 쪽 구현의 실제 내용(ClassFileMetadataReader, ClassFileAnnotationDelegate), 그리고 CachingMetadataReaderFactory가 DefaultResourceLoader의 공유 캐시를 쓰는 경우와 자체 LRU 캐시(기본 256개)를 쓰는 경우의 차이(CachingMetadataReaderFactory 43행, 164–187행)가 있습니다.

정리

개념 한 줄 요약
타입 소거 실행 중인 객체에서는 제네릭을 알 수 없지만, 필드·메서드 선언에 적힌 제네릭은 .class에 남아 있다
ResolvableType 자바 Type을 감싸 getGeneric()·as()로 탐색하고 resolve()로 Class를 얻는다. 없으면 null 대신 NONE
forType 캐시 Class가 아닌 타입만 정적 ConcurrentReferenceHashMap에 풀어 둔 결과를 캐시. 호출자는 매번 새 객체를 받는다
SerializableTypeWrapper JDK의 Type 구현이 직렬화되지 않으므로 "어느 필드였는지"를 기억하는 프록시로 감싼다
메타 어노테이션 어노테이션 위의 어노테이션. 자바 리플렉션은 보지 못하고 Spring은 따라 올라간다
MergedAnnotations 어노테이션 묶음의 합쳐진 뷰. from(요소, 전략)으로 만들고 get(타입)으로 꺼낸다
AnnotationTypeMappings 어노테이션 타입마다 메타 어노테이션을 너비 우선으로 훑은 표. 타입별로 캐시
@AliasFor 같은 어노테이션 안의 두 속성을, 또는 메타 어노테이션의 속성을 잇는다. aliasMappings에 "루트의 몇 번 속성을 읽어라"로 적힌다
synthesize() 합쳐진 값을 돌려주는 JDK 동적 프록시 어노테이션 객체
SearchStrategy DIRECT → INHERITED_ANNOTATIONS → SUPERCLASS → TYPE_HIERARCHY. 어느 클래스까지 뒤지는가만 다르다
AnnotationFilter.PLAIN java.lang·org.springframework.lang 어노테이션은 언제나 건너뛴다
AnnotationMetadata 클래스를 로딩하지 않아도 되는 형태의 클래스·어노테이션 정보. 리플렉션(Standard…)과 바이트코드(Simple…) 두 구현
MetadataReader .class 파일을 ASM(또는 Java 24+ ClassFile API)으로 읽어 AnnotationMetadata를 만든다. static {}이 돌지 않는다
CachingMetadataReaderFactory Resource마다 한 번만 읽는다. 컴포넌트 스캔이 쓰는 것
org.springframework.asm ASM 9.x를 충돌 방지를 위해 복사해 넣은 패키지

다음에 볼 것

이 글의 ResolvableType은 "타입이 무엇인가"까지만 답했습니다. 다음 글은 그 타입으로 값을 바꾸는 이야기입니다. 설정 파일의 문자열 "8080"이 int 필드에 들어가고 "2026-10-10"이 LocalDate가 되는 과정, 곧 ConversionService와 PropertyEditor, 그리고 빈의 속성에 값을 써넣는 BeanWrapper입니다. TypeDescriptor가 이 글의 ResolvableType을 안에 품고 등장합니다.

다른 글