이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 열네 번째 글입니다. 13편에서는 Spring이 타입과 어노테이션을 읽는 도구인 ResolvableType과 MergedAnnotations를 봤습니다. 이번에는 그 도구를 써서 하는 일 가운데 가장 흔한 것, 문자열을 객체의 속성에 넣는 일을 따라갑니다. 1편의 지도에서는 Phase 3(기반 기술)의 U12, "주 경로를 가다가 막히면 꺼내 보는" 단위입니다.
설정 파일에 port=8080이라고 적으면 그 값은 문자열 "8080"입니다. 그런데 빈의 필드는 int port입니다. 누군가 문자열을 숫자로 바꾸고, setPort(8080)을 불러야 합니다. 답하려는 질문은 세 가지입니다.
- 타입 변환은 누가 하는가.
PropertyEditor와ConversionService라는 체계가 둘 있는데, 왜 둘이고 어느 쪽을 먼저 쓰는가 - 이름으로 속성에 값을 넣는
BeanWrapper는 어떻게 동작하는가."address.city"처럼 점으로 이어진 경로나"tags[0]"같은 인덱스는 어떻게 따라 내려가는가 - 컨테이너는 이 둘을 어디서 쓰는가.
conversionService라는 이름의 빈은 어디서 찾고,@Value("8080")은 어느 길로int가 되는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-core(core.convert), spring-beans(beans) |
| 주로 읽는 클래스 | GenericConversionService, DefaultConversionService, TypeConverterDelegate, PropertyEditorRegistrySupport, AbstractNestablePropertyAccessor, BeanWrapperImpl |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
원래 공부 노트는 줄 단위로 훨씬 깊이 들어가지만, 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.
먼저 알아 둘 말
프로퍼티(property)는 Java 객체가 getXxx()·setXxx() 한 쌍으로 밖에 내놓는 값입니다. getCity()와 setCity(String)이 있으면 그 객체에는 city라는 프로퍼티가 있다고 말합니다. 이 규약을 JavaBeans 규약이라고 하고, 이름만 보고 getter·setter를 찾아내는 일을 인트로스펙션(introspection)이라고 합니다. 이 글에서는 프로퍼티를 "속성"이라고도 부릅니다.
**PropertyEditor**는 JDK의 java.beans 패키지에 있는 인터페이스입니다. 원래는 GUI 도구에서 사용자가 입력한 문자열을 값으로 바꾸려고 만든 것이라 setAsText(String)으로 문자열을 넣고 getValue()로 꺼내는 방식입니다. Spring은 처음부터 이것을 설정 문자열 변환에 써 왔습니다.
**ConversionService**는 Spring 3.0에 들어온 Spring 자신의 변환 체계입니다. 문자열뿐 아니라 어떤 타입에서 어떤 타입으로든 바꿀 수 있고, 변환 규칙 하나를 **Converter**라는 작은 인터페이스로 등록합니다.
**TypeDescriptor**는 "바꿀 목표가 정확히 무엇인가"를 담는 객체입니다. List.class만으로는 원소 타입을 알 수 없지만 List<Integer>라는 필드의 TypeDescriptor에는 원소가 Integer라는 것과 그 필드에 붙은 어노테이션까지 들어 있습니다. 안에서는 13편의 ResolvableType을 씁니다.
**BeanWrapper**는 객체 하나를 감싸고, 프로퍼티 이름으로 값을 읽고 쓰게 해 주는 Spring의 인터페이스입니다. setPropertyValue("port", "8080")이라고 부르면 안에서 변환과 setPort() 호출을 다 해 줍니다. 구현 클래스는 BeanWrapperImpl입니다.
**PropertyValues**는 "이 프로퍼티에 이 값을"이라는 쌍의 목록입니다. 3편에서 본 BeanDefinition이 설정 값을 이 형태로 들고 있고, 4편의 populateBean()이 이것을 빈에 넣습니다.
1. 변환 체계가 둘인 이유
Spring에는 문자열을 값으로 바꾸는 길이 둘 있습니다. 하나가 다른 하나를 대체한 것이 아니라 둘 다 살아 있고, 한 메서드가 둘을 순서대로 묻습니다. 왜 그렇게 됐는지부터 봅니다.
왼쪽이 오래된 체계, 오른쪽이 3.0에 들어온 체계입니다. 파란 상자가 둘을 묶는 자리입니다.
PropertyEditor — JDK가 준 것
PropertyEditor는 Spring이 만든 것이 아니라 JDK의 java.beans에 있습니다. PropertyEditorRegistry 인터페이스의 import 문에서 확인할 수 있습니다(PropertyEditorRegistry 19행). 쓰는 방법은 두 단계입니다. setAsText("8080")으로 문자열을 넣으면 에디터가 안에서 숫자로 바꿔 들고 있고, getValue()로 꺼냅니다. 그래서 에디터는 상태를 가지고, 문자열에서 시작하는 변환만 할 수 있습니다.
Spring은 이런 에디터를 직접 많이 만들어 두었습니다. beans.propertyeditors 패키지에 CustomNumberEditor, CustomBooleanEditor, ClassEditor, URLEditor 같은 클래스가 28개 있습니다(파일 29개 가운데 package-info.java를 뺀 수). 그리고 PropertyEditorRegistrySupport.createDefaultEditors()가 그 가운데 어떤 것을 어떤 타입에 붙일지 정합니다.
private Map<Class<?>, PropertyEditor> createDefaultEditors() {
Map<Class<?>, PropertyEditor> defaultEditors = new HashMap<>(64);
// Simple editors, without parameterization capabilities.
// The JDK does not contain a default editor for any of these target types.
defaultEditors.put(Charset.class, new CharsetEditor());
defaultEditors.put(Class.class, new ClassEditor());
// ...
// The JDK does not contain default editors for number wrapper types!
// Override JDK primitive number editors with our own CustomNumberEditor.
defaultEditors.put(byte.class, new CustomNumberEditor(Byte.class, false));
defaultEditors.put(Byte.class, new CustomNumberEditor(Byte.class, true));
// ...
defaultEditors.put(int.class, new CustomNumberEditor(Integer.class, false));
defaultEditors.put(Integer.class, new CustomNumberEditor(Integer.class, true));
// ...
// Only register config value editors if explicitly requested.
if (this.configValueEditorsActive) {
StringArrayPropertyEditor sae = new StringArrayPropertyEditor();
defaultEditors.put(String[].class, sae);
// ...
}
return defaultEditors;
}
spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrySupport.java 223–294행
이 메서드의 put은 모두 47번인데, 마지막 if 안의 4번은 useConfigValueEditors()를 부른 레지스트리에만 들어갑니다(161–163행). 그래서 기본 에디터는 43종, 설정용 에디터까지 켜면 47종입니다. "8080"을 int로 바꾸는 주인공이 여기 있는 CustomNumberEditor(Integer.class, false)입니다. 뒤의 false는 빈 문자열을 허용하지 않는다는 뜻입니다.
기본 에디터 위에 커스텀 에디터를 더 얹을 수 있습니다. registerCustomEditor(타입, 에디터)로 타입마다, 또는 registerCustomEditor(타입, "프로퍼티 경로", 에디터)로 특정 프로퍼티에만 붙입니다(PropertyEditorRegistry 44행, 69행). 찾을 때는 프로퍼티 경로에 붙인 것을 먼저, 그다음 타입에 붙인 것을 봅니다(PropertyEditorRegistrySupport 338–362행).
ConversionService — 3.0에 들어온 Spring의 체계
에디터의 두 한계, 곧 상태를 가진다는 것과 문자열에서만 시작한다는 것을 풀려고 3.0에서 core.convert 패키지가 생겼습니다. 입구는 ConversionService 인터페이스입니다. 메서드는 canConvert 둘과 convert 둘(과 기본 메서드 하나)뿐이고, 설명 주석에 "thread-safe"라고 적혀 있습니다(ConversionService 21–29행). 상태가 없으니 여러 스레드가 함께 써도 됩니다.
<T> @Nullable T convert(@Nullable Object source, Class<T> targetType);
// ...
@Nullable Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
spring-core/src/main/java/org/springframework/core/convert/ConversionService.java 75–106행
변환 규칙 하나는 네 가지 인터페이스 가운데 하나로 씁니다. 모두 core.convert.converter 패키지에 있습니다.
| 인터페이스 | 뜻 | 언제 쓰나 |
|---|---|---|
Converter<S, T> |
S 하나를 T 하나로. 메서드는 convert(S) 하나 (37–46행) |
대부분. 람다로도 쓴다 |
ConverterFactory<S, R> |
S에서 R의 하위 타입 전부로. getConverter(Class<T>)가 타입별 컨버터를 만들어 준다 (33–43행) |
String → 모든 Number, String → 모든 Enum |
GenericConverter |
여러 타입 쌍(ConvertiblePair)을 하나가 맡고, TypeDescriptor를 받아 원소 타입·어노테이션까지 본다 (49–66행) |
컬렉션·배열·맵 변환 |
ConditionalConverter |
타입 쌍만으로는 모자라 matches(source, target)로 그때그때 판단한다 (43–52행) |
위 셋에 덧붙여 쓴다 |
ConvertiblePair는 "어느 타입에서 어느 타입으로"를 담는 작은 값 객체입니다(72–119행). 2절에서 컨버터를 찾는 맵의 열쇠로 쓰입니다.
그래서 왜 둘 다 남아 있나
둘을 비교하면 이렇습니다.
PropertyEditor |
ConversionService |
|
|---|---|---|
| 어디 것인가 | JDK java.beans |
Spring core.convert (3.0부터) |
| 시작 타입 | 문자열만 | 무엇이든 |
| 상태 | 있다 (setAsText → getValue) |
없다 (스레드 안전) |
| 목표 타입의 세부 | Class 하나 |
TypeDescriptor (제네릭 원소, 어노테이션) |
| 기본 등록 | 에디터 43종 | 컨버터 52종 (아래 2절) |
ConversionService가 모든 면에서 낫지만 Spring은 에디터를 걷어 내지 않았습니다. 수많은 사용자 코드와 Spring 자신의 코드(Resource, Class, URL 같은 설정 전용 타입)가 에디터로 되어 있기 때문입니다. 대신 ConfigurableBeanFactory.setConversionService()의 설명 주석이 말하듯 "JavaBeans PropertyEditors의 대안으로" 끼울 수 있게 했습니다(ConfigurableBeanFactory 165–168행). 둘이 만나는 자리는 3절에서 봅니다.
헷갈리기 쉬운 곳: 기본 컨버터는 몇 개인가
"기본 컨버터 몇 종"이라는 수는 무엇을 세느냐에 따라 달라집니다. core.convert.support 패키지에는 파일이 54개 있지만, 그 가운데 GenericConversionService, DefaultConversionService, ConversionUtils, ConvertingPropertyEditorAdapter처럼 컨버터가 아닌 것과 package-info.java를 빼면 컨버터 클래스는 47개(추상 기반 클래스 하나 포함)입니다. 한편 DefaultConversionService.addDefaultConverters()가 실제로 등록하는 횟수는 52번이고, Kotlin이 클래스패스에 있을 때만 2번이 더해져 54번입니다(DefaultConversionService 89–185행, Kotlin 분기는 181–184행). 등록 수가 클래스 수보다 많은 것은 ObjectToStringConverter 하나를 Number→String, Boolean→String, Locale→String처럼 여러 쌍에 거듭 등록하기 때문입니다(143행, 155행, 164행). 이 글에서는 "등록 52종"으로 적습니다.
2. ConversionService는 컨버터를 어떻게 찾나
ConversionService의 기본 구현은 GenericConversionService이고(63행), 거기에 기본 컨버터를 채운 것이 DefaultConversionService입니다. 생성자가 하는 일은 addDefaultConverters(this) 한 줄입니다(53–55행).
public static void addDefaultConverters(ConverterRegistry converterRegistry) {
addScalarConverters(converterRegistry);
addCollectionConverters(converterRegistry);
converterRegistry.addConverter(new ByteBufferConverter((ConversionService) converterRegistry));
converterRegistry.addConverter(new DateToInstantConverter());
// ...
converterRegistry.addConverter(new ObjectToObjectConverter());
converterRegistry.addConverter(new IdToEntityConverter((ConversionService) converterRegistry));
converterRegistry.addConverter(new FallbackObjectToStringConverter());
converterRegistry.addConverter(new ObjectToOptionalConverter((ConversionService) converterRegistry));
converterRegistry.addConverter(new OptionalToObjectConverter((ConversionService) converterRegistry));
}
addScalarConverters가 숫자·불리언·enum·Locale·UUID 같은 단일 값 변환을(139–185행), addCollectionConverters가 배열·컬렉션·맵 사이의 변환을(114–137행) 등록합니다. "8080" → Integer는 첫 줄의 StringToNumberConverterFactory가 맡고(142행), 안에서는 NumberUtils.parseNumber를 부릅니다(StringToNumberConverterFactory 64행).
convert() 한 번의 흐름
convert()가 하는 일은 "맞는 컨버터를 찾아서 부른다"입니다.
@Override
public @Nullable Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType) {
Assert.notNull(targetType, "Target type to convert to cannot be null");
if (sourceType == null) {
Assert.isTrue(source == null, "Source must be [null] if source type == [null]");
return handleResult(null, targetType, convertNullSource(null, targetType));
}
if (source != null && !sourceType.getObjectType().isInstance(source)) {
throw new IllegalArgumentException("Source to convert from must be an instance of [" +
sourceType + "]; instead it was a [" + source.getClass().getName() + "]");
}
GenericConverter converter = getConverter(sourceType, targetType);
if (converter != null) {
Object result = ConversionUtils.invokeConverter(converter, source, sourceType, targetType);
return handleResult(sourceType, targetType, result);
}
return handleConverterNotFound(source, sourceType, targetType);
}
핵심은 getConverter()입니다. 여기에 캐시가 있습니다.
노란색이 캐시, 파란색이 찾는 절차, 빨간색이 못 찾았을 때입니다.
protected @Nullable GenericConverter getConverter(TypeDescriptor sourceType, TypeDescriptor targetType) {
ConverterCacheKey key = new ConverterCacheKey(sourceType, targetType);
GenericConverter converter = this.converterCache.get(key);
if (converter != null) {
return (converter != NO_MATCH ? converter : null);
}
converter = this.converters.find(sourceType, targetType);
if (converter == null) {
converter = getDefaultConverter(sourceType, targetType);
}
if (converter != null) {
this.converterCache.put(key, converter);
return converter;
}
this.converterCache.put(key, NO_MATCH);
return null;
}
- 캐시부터 봅니다.
converterCache는 (소스 타입, 타깃 타입) 쌍을 열쇠로 하는 맵입니다(79행). "없더라"는 결과도NO_MATCH라는 표시 객체로 캐시합니다(74행). 같은 쌍을 두 번째 물을 때는 아래 탐색을 하지 않습니다. - 없으면
Converters.find()로 찾습니다. 소스 타입의 상위 타입(부모 클래스와 인터페이스) 목록과 타깃 타입의 상위 타입 목록을 만들고, 그 모든 쌍을 차례로 시도합니다(500–514행).String→Integer를 물으면String→Integer,String→Number,String→Comparable, …Object→Object까지 내려갑니다. 그래서Integer용 컨버터를 따로 두지 않고String→Number하나(ConverterFactory)로 모든 숫자를 받을 수 있습니다. - 쌍마다
ConvertersForPair를 봅니다. 같은 쌍에 등록된 컨버터는 덱(deque)에addFirst로 쌓이므로(617행) 나중에 등록한 것이 먼저 검사됩니다(620–636행). 쌍에 등록된 것이 없으면 "어떤 쌍이든 받겠다"고 한 조건부 컨버터들에게matches로 묻습니다(527–532행). - 그래도 없으면 기본 컨버터. 소스가 타깃에 그냥 대입할 수 있는 타입이면 아무것도 하지 않는
NO_OP_CONVERTER를 돌려주고(253–255행), 아니면null입니다.convert()는null을 받으면ConverterNotFoundException을 던집니다(278–290행).
컨버터를 실행하다 예외가 나면 ConversionUtils.invokeConverter가 ConversionFailedException으로 감쌉니다(36–48행). "못 찾았다"와 "찾았는데 실패했다"가 다른 예외라는 점을 기억해 두세요. 3절에서 중요해집니다.
예제: 기본 컨버터로 되는 것과 안 되는 것
DefaultConversionService만 만들어 몇 가지를 바꿔 봤습니다. 이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 결과를 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다).
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
DefaultConversionService cs = new DefaultConversionService();
// 1) 문자열 → Integer (StringToNumberConverterFactory)
Integer port = cs.convert("8080", Integer.class);
// 2) 문자열 → List (StringToCollectionConverter). 원소 타입을 모르면 문자열 그대로
List<?> raw = cs.convert("a, b,c", List.class);
// 3) 원소 타입까지 알려 주면 원소도 바꿔 준다 — TypeDescriptor 가 그 정보를 나른다
TypeDescriptor listOfInteger = TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(Integer.class));
Object numbers = cs.convert("1,2,3", TypeDescriptor.valueOf(String.class), listOfInteger);
// 4) 문자열 → ZoneId — 전용 컨버터는 없지만 ObjectToObjectConverter 가 ZoneId.of(String) 을 찾아 부른다
ZoneId zone = cs.convert("Asia/Seoul", ZoneId.class);
// 5) 문자열 → LocalDate — valueOf/of/from(String) 도, String 생성자도 없어 컨버터를 못 찾는다
cs.convert("2026-10-10", LocalDate.class); // ConverterNotFoundException
// 6) 컨버터를 하나 등록하면 된다 — Converter<String, LocalDate> 는 람다 하나로 충분하다
cs.addConverter(String.class, LocalDate.class, LocalDate::parse);
LocalDate date = cs.convert("2026-10-10", LocalDate.class);
출력입니다.
① 8080 (Integer)
② [a, b, c] / 첫 원소 타입 String
③ [1, 2, 3] / 첫 원소 타입 Integer
④ Asia/Seoul (java.time.ZoneRegion)
⑤ ConverterNotFoundException: No converter found capable of converting from type [java.lang.String] to type [java.time.LocalDate]
⑥ 2026-10-10 (LocalDate)
canConvert(String → LocalDate) = true
②와 ③의 차이가 TypeDescriptor가 있는 이유입니다. List.class만 주면 StringToCollectionConverter는 쉼표로 자른 문자열을 그대로 넣고, 원소 타입이 담긴 TypeDescriptor를 주면 원소마다 다시 ConversionService에 물어 바꿉니다(StringToCollectionConverter 62–84행). 빈의 List<Integer> 필드에 넣을 때는 컨테이너가 필드에서 TypeDescriptor를 만들어 주므로(TypeDescriptor 101행) ③처럼 동작합니다.
헷갈리기 쉬운 곳
ZoneId는 되는데LocalDate는 안 되는 이유. 둘 다 전용 컨버터가 없습니다. 차이는ObjectToObjectConverter가 타깃 타입에서 찾는 메서드 이름이valueOf,of,from(그리고 소스 쪽의toXxx(), 소스 타입 하나를 받는 생성자)로 정해져 있다는 데서 옵니다(설명 주석 42–60행, 182–197행).ZoneId.of(String)은 있고,LocalDate에는parse(CharSequence)만 있어서 목록에 없습니다. 이 컨버터는Object→Object쌍으로 등록되어 있고(82–84행), 위의 상위 타입 탐색이 마지막에Object→Object까지 내려가므로 어떤 쌍에서든 마지막 후보로 불립니다.- 같은 쌍에 컨버터를 하나 더 등록하면 새것이 이깁니다.
addFirst때문입니다. 기본 컨버터가 마음에 들지 않으면 지우지 않고 같은 쌍으로 하나 더 등록하면 됩니다. - 캐시는 등록할 때마다 비워집니다.
addConverter가invalidateCache()를 부릅니다(104–107행, 274–276행). 위 예제에서 ⑤가NO_MATCH를 캐시했는데도 ⑥이 성공한 이유입니다.
3. 누가 먼저인가 — TypeConverterDelegate.convertIfNecessary
두 체계는 TypeConverterDelegate라는 패키지 전용 클래스 한 곳에서 만납니다(57행). BeanWrapperImpl도, @Value를 처리하는 SimpleTypeConverter도 안에서는 이 클래스의 convertIfNecessary()를 부릅니다. 170줄짜리 메서드지만 순서만 보면 됩니다.
번호가 묻는 순서입니다. 줄 번호는 TypeConverterDelegate 기준입니다.
public <T> @Nullable T convertIfNecessary(@Nullable String propertyName, @Nullable Object oldValue, @Nullable Object newValue,
@Nullable Class<T> requiredType, @Nullable TypeDescriptor typeDescriptor) throws IllegalArgumentException {
// Custom editor for this type?
PropertyEditor editor = this.propertyEditorRegistry.findCustomEditor(requiredType, propertyName);
ConversionFailedException conversionAttemptEx = null;
// No custom editor but custom ConversionService specified?
ConversionService conversionService = this.propertyEditorRegistry.getConversionService();
if (editor == null && conversionService != null && newValue != null && typeDescriptor != null) {
TypeDescriptor sourceTypeDesc = TypeDescriptor.forObject(newValue);
if (conversionService.canConvert(sourceTypeDesc, typeDescriptor)) {
try {
return (T) conversionService.convert(newValue, sourceTypeDesc, typeDescriptor);
}
catch (ConversionFailedException ex) {
// fallback to default conversion logic below
conversionAttemptEx = ex;
}
}
}
Object convertedValue = newValue;
// Value not of required type?
if (editor != null || (requiredType != null && !ClassUtils.isAssignableValue(requiredType, convertedValue))) {
// ...
if (editor == null) {
editor = findDefaultEditor(requiredType);
}
convertedValue = doConvertValue(oldValue, convertedValue, requiredType, editor);
}
// ...
}
spring-beans/src/main/java/org/springframework/beans/TypeConverterDelegate.java 114–284행
| 순서 | 무엇을 | 어디서 |
|---|---|---|
| ① | 커스텀 에디터가 있나 (프로퍼티 경로 → 타입 순) | 118행 |
| ② | 커스텀 에디터가 없고 ConversionService가 있으면 canConvert → convert. 성공하면 여기서 반환 |
123–135행 |
| ③ | 타입이 아직 안 맞으면 기본 에디터를 찾아 변환 | 155–158행, 341–352행 |
| ④ | 그래도 안 맞으면 표준 규칙: 배열·컬렉션·맵 원소 변환, String을 받는 생성자, enum 이름, Number 사이 변환 |
163–239행 |
| ⑤ | 끝까지 안 맞으면 예외 | 241–272행 |
정리하면 커스텀 에디터 > ConversionService > 기본 에디터 순입니다. PropertyEditorRegistrySupport.overrideDefaultEditor()의 설명 주석이 이것을 한 문장으로 적어 두었습니다. "A ConversionService will override such a default editor, whereas custom editors usually override the ConversionService"(181–182행).
③의 findDefaultEditor는 1절의 43종을 보고, 거기에도 없으면 JavaBeans 규약대로 "타입 이름 + Editor"라는 클래스가 있는지 클래스 로더에 물어봅니다(BeanUtils.findEditorByConvention 552–590행). 못 찾은 타입은 집합에 기억해 두고 다시 묻지 않습니다(588행).
헷갈리기 쉬운 곳
ConversionService가 실패해도 바로 예외가 아닙니다. ②에서ConversionFailedException이 나면 변수에 담아 두고(conversionAttemptEx) 에디터 쪽을 계속 시도합니다. 에디터가 성공하면 그 결과를 쓰고 예외는 디버그 로그로만 남기며(275–281행), 에디터도 안 되면 **원래의ConversionFailedException**을 던집니다(242–244행). 반대로 "컨버터가 없다"(canConvert가false)는 예외 없이 곧바로 에디터로 넘어갑니다.- 문자열을 받는 생성자가 있으면 컨버터 없이도 들어갑니다. ④의 규칙 가운데 하나로,
requiredType.getConstructor(String.class)를 찾아 부릅니다(203–208행). 6절의 예제에서Tag("sale")이 이 길로 들어갑니다. 단, 인터페이스나 enum이 목표이거나 ②에서 이미 실패한 적이 있으면(conversionAttemptEx != null) 시도하지 않습니다. - 밖으로 나가는 예외는 두 종류로 바뀝니다. 이 메서드는
IllegalStateException("no matching editors or conversion strategy found", 269–270행)이나IllegalArgumentException을 던지는데, 부르는 쪽인TypeConverterSupport가 "방법이 없다"(ConverterNotFoundException·IllegalStateException)는ConversionNotSupportedException으로, "방법은 있는데 값이 틀렸다"(ConversionException·IllegalArgumentException)는TypeMismatchException으로 감쌉니다(70–84행).BeanWrapperImpl쪽도 같은 규칙입니다(AbstractNestablePropertyAccessor566–584행). 예외 메시지를 읽을 때 이 둘을 구분하면 원인을 빨리 찾습니다.
4. BeanWrapper — 이름으로 속성에 값을 넣기
변환된 값을 어디에 넣을지 정하는 쪽이 BeanWrapper입니다. 인터페이스가 층층이 있으니 먼저 지도를 봅니다.
| 인터페이스 / 클래스 | 하는 일 | 위치 |
|---|---|---|
PropertyAccessor |
이름으로 읽고 쓰기: getPropertyValue, setPropertyValue, setPropertyValues, getPropertyType |
37–188행 |
ConfigurablePropertyAccessor |
위에 더해 PropertyEditorRegistry와 TypeConverter를 상속. setConversionService, setAutoGrowNestedPaths |
37행, 61행, 89행 |
BeanWrapper |
위에 더해 감싼 객체와 PropertyDescriptor(getter·setter 정보) 접근 |
49–89행 |
AbstractNestablePropertyAccessor |
경로 따라 내려가기, 인덱스, 변환 호출 — 본론은 여기 | 71행 |
BeanWrapperImpl |
위 추상 클래스에 "getter·setter로 접근"을 붙인 구현 | 62행 |
ConfigurablePropertyAccessor가 PropertyEditorRegistry와 TypeConverter를 상속한다는 점이 중요합니다. BeanWrapper는 속성 접근기이면서 동시에 에디터 등록소이고 타입 변환기입니다. 그래서 컨테이너가 빈 하나를 만들 때 BeanWrapperImpl 하나만 있으면 변환과 주입을 다 할 수 있습니다. 생성자에서 기본 에디터를 켜고 TypeConverterDelegate를 만들어 둡니다(AbstractNestablePropertyAccessor 103–108행).
getter·setter는 어떻게 찾나 — CachedIntrospectionResults
BeanWrapperImpl이 "port"라는 이름으로 setPort를 찾는 일은 CachedIntrospectionResults가 합니다. 클래스마다 한 번만 인트로스펙션해서 static 맵에 보관합니다. 설명 주석도 "Information is cached statically"라고 적고 있습니다(51–54행). 맵은 둘입니다. 클래스 로더가 안전한 클래스는 강한 참조 맵에(95–96행), 아니면 소프트 참조 맵에 둡니다(102–103행, 고르는 코드는 145–171행).
BeanWrapperImpl은 이름을 받으면 그 캐시에서 PropertyDescriptor를 꺼내 BeanPropertyHandler로 감쌉니다.
@Override
protected @Nullable PropertyHandler getLocalPropertyHandler(String propertyName) {
PropertyDescriptor pd = getCachedIntrospectionResults().getPropertyDescriptor(propertyName);
return (pd != null ? new BeanPropertyHandler((GenericTypeAwarePropertyDescriptor) pd) : null);
}
spring-beans/src/main/java/org/springframework/beans/BeanWrapperImpl.java 192–196행
BeanPropertyHandler의 setValue가 마지막에 리플렉션으로 setter를 부르는 자리입니다(283–287행). 같은 클래스의 toTypeDescriptor()가 setter의 인자에서 만든 TypeDescriptor를 돌려주고(245–248행), 그것이 3절의 변환에 전달됩니다.
단일 속성 하나를 넣는 길
"port"처럼 점도 괄호도 없는 이름은 processLocalProperty가 처리합니다.
private void processLocalProperty(PropertyPath.Segment segment, PropertyValue pv) {
// ...
String name = segment.name();
PropertyHandler ph = getLocalPropertyHandler(name);
if (ph == null || !ph.isWritable()) {
// ...
throw createNotWritablePropertyException(name);
}
Object oldValue = null;
try {
Object originalValue = pv.getValue();
Object valueToApply = originalValue;
if (!Boolean.FALSE.equals(pv.conversionNecessary)) {
if (pv.isConverted()) {
valueToApply = pv.getConvertedValue();
}
else {
// ...
valueToApply = convertForProperty(name, oldValue, originalValue, ph.toTypeDescriptor());
}
pv.getOriginalPropertyValue().conversionNecessary = (valueToApply != originalValue);
}
ph.setValue(valueToApply);
}
// ...
}
spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java 395–470행
순서는 셋입니다. 핸들러(getter·setter)를 찾고, 없으면 NotWritablePropertyException. convertForProperty로 값을 setter의 타입에 맞게 바꾸고(안에서 3절의 convertIfNecessary를 부릅니다, 586–591행). 마지막으로 ph.setValue로 setter를 부릅니다. PropertyValue에 이미 변환된 값이 들어 있으면(isConverted) 변환을 건너뛰는데, 이것은 6절에서 컨테이너가 쓰는 최적화입니다.
위 코드의 세 걸음(핸들러 찾기 → 변환 → setter 호출)을 그린 것입니다. 노란 원통은 클래스마다 한 번만 만드는 캐시입니다.
5. "address.city"와 "tags[0]" — 중첩 경로
BeanWrapper의 진짜 힘은 경로입니다. setPropertyValue("address.city", "Seoul")은 getAddress().setCity("Seoul")과 같고, "tags[0]"은 getTags().set(0, …)과 같습니다. 경로의 문법은 이 커밋에서 새로 들어온 PropertyPath 클래스(@since 7.1, 51행)의 설명 주석에 적혀 있습니다.
* PropertyPath := Segment ('.' Segment)*
* Segment := Name Index* -- Name may be empty only if at least one Index follows
* Name := char* excluding '.', '[', ']'
* Index := '[' Key ']'
* Key := QuotedKey | RawKey
spring-beans/src/main/java/org/springframework/beans/PropertyPath.java 39–43행
점으로 나뉜 조각 하나가 세그먼트이고, 세그먼트는 이름과 키 목록으로 된 레코드입니다(198행). "address.city"는 [address], [city] 두 세그먼트이고, "tags[0]"은 이름 tags에 키 0이 붙은 세그먼트 하나입니다.
노란 상자는 자식 BeanWrapperImpl의 캐시, 빨간 상자가 autoGrowNestedPaths를 켜지 않았을 때 나는 예외입니다. 줄 번호는 AbstractNestablePropertyAccessor 기준입니다.
setPropertyValue(String, Object)는 먼저 경로를 풀어서 "마지막 세그먼트를 맡을 접근기"와 "마지막 세그먼트"를 얻습니다(216–232행, 799–805행). 마지막 하나를 남기고 세그먼트를 하나씩 벗기며 재귀로 내려갑니다(831–839행). 한 단계 내려가는 일이 getNestedPropertyAccessor입니다.
private AbstractNestablePropertyAccessor getNestedPropertyAccessor(PropertyPath.Segment segment) {
// ...
// Get value of bean property.
Object value = getPropertyValue(segment);
if (value == null || (value instanceof Optional<?> optional && optional.isEmpty())) {
if (isAutoGrowNestedPaths()) {
value = setDefaultValue(segment);
}
else {
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + segment.toCanonicalName());
}
}
// Lookup cached sub-PropertyAccessor, create new one if not found.
AbstractNestablePropertyAccessor nestedPa = nestedAccessors.get(segment);
if (nestedPa == null || nestedPa.getWrappedInstance() != ObjectUtils.unwrapOptional(value)) {
String canonicalName = segment.toCanonicalName();
// ...
nestedPa = newNestedPropertyAccessor(value, this.nestedPath + canonicalName + NESTED_PROPERTY_SEPARATOR);
// Inherit all type-specific PropertyEditors.
copyDefaultEditorsTo(nestedPa);
copyCustomEditorsTo(nestedPa, canonicalName);
nestedAccessors.put(segment, nestedPa);
}
// ...
return nestedPa;
}
spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java 849–885행
읽으면 이렇습니다.
getAddress()를 불러 값을 읽습니다. 이것이null이면 더 내려갈 수 없습니다.null일 때 두 갈래.autoGrowNestedPaths가 켜져 있으면setDefaultValue가 그 타입의 기본 생성자로 객체를 만들어setAddress로 끼워 넣고 계속 갑니다(887–931행, 배열·컬렉션·맵이면 빈 것을 만듭니다). 꺼져 있으면NullValueInNestedPathException입니다. 기본값은 꺼짐입니다(AbstractPropertyAccessor45행).- 값을 감싸는 자식
BeanWrapperImpl을 만들어 캐시합니다. 자식은 부모의 에디터와ConversionService,autoGrow설정을 물려받습니다(147–154행). 이후"city"는 그 자식 안에서 4절의 단일 속성 처리로 넣습니다.
"tags[0]"처럼 키가 있는 세그먼트는 processKeyedProperty가 맡습니다. 먼저 키를 뺀 tags의 값을 읽고(없으면 위와 같은 autoGrow 규칙, 368–393행), 그 값이 배열이면 Array.set, List면 list.set, Map이면 map.put을 합니다(271–366행). List에 크기보다 큰 인덱스를 넣으면 그 앞을 null로 채우고 add합니다(317–331행). 원소의 타입은 ph.getCollectionType(…)이 제네릭에서 알아내므로 List<Integer>에 "3"을 넣으면 Integer로 바뀝니다(309–316행).
예제: BeanWrapperImpl 직접 써 보기
Person에 name, int port, Address address(처음에는 null), List<String> tags(처음에는 null)가 있습니다. 이 예제도 Spring Framework 7.0.9로 실행했습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
Person person = new Person();
BeanWrapperImpl bw = new BeanWrapperImpl(person);
bw.setPropertyValue("name", "Kim"); // ① setName("Kim")
bw.setPropertyValue("port", "8080"); // ② 문자열 → int (CustomNumberEditor)
try {
bw.setPropertyValue("address.city", "Seoul"); // ③ address 가 null
}
catch (BeansException ex) { /* 출력 */ }
bw.setAutoGrowNestedPaths(true);
bw.setPropertyValue("address.city", "Seoul"); // ④ Address 를 만들어 끼운 뒤 내려간다
bw.setPropertyValue("tags[0]", "spring"); // ⑤ List 를 만들고
bw.setPropertyValue("tags[2]", "beans"); // 빈 자리는 null 로 채운다
try { bw.setPropertyValue("port", "eighty"); } // ⑥ 바꿀 수 없는 문자열
catch (BeansException ex) { /* 출력 */ }
try { bw.setPropertyValue("nmae", "Lee"); } // ⑦ 없는 이름
catch (BeansException ex) { /* 출력 */ }
출력입니다.
① name = Kim
② port = 8080 (int)
③ NullValueInNestedPathException: Invalid property 'address' of bean class [ex.u12.BeanWrapperExample$Person]: Value of nested property 'address' is null
④ address.city = Seoul
⑤ tags = [spring, null, beans]
⑥ TypeMismatchException: Failed to convert property value of type 'java.lang.String' to required type 'int' for property 'port'; For input string: "eighty"
⑦ NotWritablePropertyException: Invalid property 'nmae' of bean class [ex.u12.BeanWrapperExample$Person]: Bean property 'nmae' is not writable or has an invalid setter method. Did you mean 'name'?
③의 메시지 "Value of nested property 'address' is null"은 NullValueInNestedPathException의 생성자가 만듭니다(37–38행). ⑦의 "Did you mean 'name'?"은 BeanWrapperImpl이 예외를 만들 때 비슷한 이름을 찾아 붙여 주는 것입니다(204–208행). ⑥은 3절에서 본 "방법은 있는데 값이 틀렸다" 쪽의 예외입니다.
헷갈리기 쉬운 곳
autoGrowNestedPaths의 기본값은 쓰는 자리마다 다릅니다.BeanWrapperImpl을 직접 만들면 꺼져 있지만(위 ③), 웹 요청을 객체에 묶는DataBinder는 자기 필드에서true로 시작합니다(DataBinder168행). 폼에서address.city가 저절로 되는 것은 그 때문입니다.DataBinder는 27편에서 다룹니다.- 인덱스로 늘어나는 데에도 한계가 있습니다.
autoGrowCollectionLimit이고 기본값은Integer.MAX_VALUE입니다(AbstractPropertyAccessor47행). 바깥에서 들어오는 인덱스를 그대로 쓰는 곳에서는 줄여 두는 것이 좋습니다.DataBinder가 그렇게 합니다. - 이 예제는 7.0.9로 돌렸습니다.
PropertyPath클래스와InvalidPropertyPathException은@since 7.1이라 7.0.9에는 없고, 경로를 자르는 안쪽 코드가 다릅니다. 위 출력의 예외 클래스와 메시지는 7.0.9의 것이고, 이 글의 줄 번호는 7.1.0-SNAPSHOT의 것입니다. 7.1에서 경로 문법이 잘못됐을 때("a["같은 것) 새 예외가 나는 것은 소스로만 확인했고 돌려 보지는 않았습니다.
6. 컨테이너는 이 둘을 어디서 쓰나
여기까지는 도구였습니다. 컨테이너가 그 도구를 꺼내 드는 자리는 세 군데입니다.
위가 컨테이너를 켤 때, 아래 왼쪽이 빈을 만들 때, 아래 오른쪽이 @Value를 넣을 때입니다.
① 켤 때: conversionService 빈을 찾아 끼운다
7편에서 본 refresh()의 열한 번째 단계 finishBeanFactoryInitialization이 싱글톤을 전부 만들기 직전에 이 일을 합니다.
// Initialize conversion service for this context.
if (beanFactory.containsBean(CONVERSION_SERVICE_BEAN_NAME) &&
beanFactory.isTypeMatch(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class)) {
beanFactory.setConversionService(
beanFactory.getBean(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class));
}
CONVERSION_SERVICE_BEAN_NAME은 문자열 "conversionService"입니다(ConfigurableApplicationContext 73행). 타입으로 찾는 것이 아니라 이름으로 찾습니다. 그 이름의 빈이 없으면 컨테이너의 conversionService 필드는 null로 남고(AbstractBeanFactory 136행), 3절의 ②가 통째로 건너뛰어져 에디터만으로 돕니다. 즉 아무것도 설정하지 않은 컨테이너는 에디터 체계로만 변환합니다.
에디터 쪽 준비는 그보다 훨씬 앞, refresh()의 세 번째 단계 prepareBeanFactory에서 합니다. ResourceEditorRegistrar를 등록해 두는데(734행), 이것이 Resource, File, URL, Class 같은 설정용 타입의 에디터를 기본 에디터 자리에 덮어씁니다(ResourceEditorRegistrar 102–123행, 129–136행). 12편의 Resource가 @Value("classpath:…")로 들어올 수 있는 이유입니다. 커스텀이 아니라 "기본 에디터 덮어쓰기"로 등록하므로 3절 순서에서 ConversionService보다 뒤에 섭니다.
② 빈을 만들 때: BeanWrapperImpl 하나에 다 싣는다
4편의 인스턴스화 단계가 돌려주는 것이 실은 빈이 아니라 **빈을 감싼 BeanWrapper**였습니다. 기본 생성자로 만드는 경우가 가장 짧습니다.
protected BeanWrapper instantiateBean(String beanName, RootBeanDefinition mbd) {
try {
Object beanInstance = getInstantiationStrategy().instantiate(mbd, beanName, this);
BeanWrapper bw = new BeanWrapperImpl(beanInstance);
initBeanWrapper(bw);
return bw;
}
// ...
}
initBeanWrapper가 컨테이너의 설정을 이 래퍼에 옮겨 싣습니다. 두 줄입니다.
protected void initBeanWrapper(BeanWrapper bw) {
bw.setConversionService(getConversionService());
registerCustomEditors(bw);
}
①에서 끼운 ConversionService가 첫 줄로 들어가고, 둘째 줄 registerCustomEditors가 설정용 에디터를 켜고(useConfigValueEditors, 1절의 47종이 되는 지점) ResourceEditorRegistrar 같은 등록자와 커스텀 에디터를 적용합니다(1322–1342행). 생성자 주입으로 만드는 경우도 ConstructorResolver가 같은 initBeanWrapper를 부르고(139행), 생성자 인자의 변환에 그 래퍼를 TypeConverter로 씁니다(670행).
그다음 populateBean이 applyPropertyValues를 부릅니다(1458행). 여기가 3편의 BeanDefinition에 담긴 PropertyValues가 실제 객체로 들어가는 자리입니다.
TypeConverter converter = getCustomTypeConverter();
if (converter == null) {
converter = bw;
}
BeanDefinitionValueResolver valueResolver = new BeanDefinitionValueResolver(this, beanName, mbd, converter);
// Create a deep copy, resolving any references for values.
List<PropertyValue> deepCopy = new ArrayList<>(original.size());
boolean resolveNecessary = false;
for (PropertyValue pv : original) {
if (pv.isConverted()) {
deepCopy.add(pv);
}
else {
String propertyName = pv.getName();
Object originalValue = pv.getValue();
// ...
Object resolvedValue = valueResolver.resolveValueIfNecessary(pv, originalValue);
Object convertedValue = resolvedValue;
boolean convertible = isConvertibleProperty(propertyName, bw);
if (convertible) {
convertedValue = convertForProperty(resolvedValue, propertyName, bw, converter);
}
// Possibly store converted value in merged bean definition,
// in order to avoid re-conversion for every created bean instance.
if (resolvedValue == originalValue) {
if (convertible) {
pv.setConvertedValue(convertedValue);
}
deepCopy.add(pv);
}
// ...
}
}
// ...
// Set our (possibly massaged) deep copy.
try {
bw.setPropertyValues(new MutablePropertyValues(deepCopy));
}
// ...
세 단계입니다. converter = bw — 따로 지정한 변환기가 없으면 BeanWrapper 자신이 변환기입니다. resolveValueIfNecessary가 "다른 빈을 가리키는 참조"를 실제 빈으로, 설정 파일의 문자열(TypedStringValue)을 문자열로 풉니다(BeanDefinitionValueResolver 131행, 문자열 분기는 220–226행). convertForProperty가 그 값을 setter의 타입으로 바꾸는데, BeanWrapperImpl이면 캐시된 PropertyDescriptor에서 바로 TypeDescriptor를 꺼내는 짧은 길을 탑니다(1777–1788행, BeanWrapperImpl 181–190행). 마지막 bw.setPropertyValues가 4·5절의 setPropertyValue를 하나씩 부릅니다(AbstractPropertyAccessor 128–133행).
③ @Value를 넣을 때: SimpleTypeConverter
@Value("8080")은 populateBean의 PropertyValues가 아니라 5편의 doResolveDependency가 처리합니다. 거기서 문자열을 풀고 나서 부르는 것이 getTypeConverter()입니다.
TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
try {
return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
}
@Override
public TypeConverter getTypeConverter() {
TypeConverter customConverter = getCustomTypeConverter();
if (customConverter != null) {
return customConverter;
}
else {
// Build default TypeConverter, registering custom editors.
SimpleTypeConverter typeConverter = new SimpleTypeConverter();
typeConverter.setConversionService(getConversionService());
registerCustomEditors(typeConverter);
return typeConverter;
}
}
SimpleTypeConverter는 BeanWrapperImpl에서 "감쌀 객체"만 뺀 변환기입니다. 같은 TypeConverterSupport를 상속하고(33행), initBeanWrapper와 똑같이 ConversionService와 커스텀 에디터를 받습니다. 그래서 @Value와 XML·PropertyValues 설정은 같은 규칙(3절의 순서)으로 변환됩니다. descriptor.getTypeDescriptor()가 필드에서 만든 TypeDescriptor를 넘기므로 List<Integer> 필드면 원소까지 바뀝니다.
예제: 직접 만든 Converter 를 conversionService 빈으로
Money(long amount, String currency) 레코드와 "1000 KRW"를 Money로 바꾸는 Converter<String, Money>를 만들고, DefaultConversionService를 conversionService라는 이름의 @Bean으로 등록했습니다. 비교하려고 int port와, String 생성자를 가진 Tag도 함께 두었습니다. Spring Framework 7.0.9로 실행했습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Component
static class Shop {
@Value("1000 KRW") Money price; // Converter 가 있어야 한다
@Value("8080") int port; // 기본 에디터로 된다
@Value("sale") Tag tag; // String 생성자로 된다
}
@Configuration
static class ConversionConfig {
@Bean
DefaultConversionService conversionService() { // 메서드 이름이 곧 빈 이름
DefaultConversionService cs = new DefaultConversionService();
cs.addConverter(new StringToMoneyConverter());
return cs;
}
}
ConversionConfig와 함께 띄운 컨테이너와, Shop만 띄운 컨테이너의 출력입니다.
① price = Money[amount=1000, currency=KRW], port = 8080, tag = Tag(sale)
② UnsatisfiedDependencyException: Error creating bean with name 'moneyConverterExample.Shop': Unsatisfied dependency expressed through field 'price': Failed to convert value of type 'java.lang.String' to required type 'ex.u12.MoneyConverterExample$Money'; Cannot convert value of type 'java.lang.String' to required type 'ex.u12.MoneyConverterExample$Money': no matching editors or conversion strategy found
root cause: IllegalStateException: Cannot convert value of type 'java.lang.String' to required type 'ex.u12.MoneyConverterExample$Money': no matching editors or conversion strategy found
②의 가장 안쪽 예외 메시지가 3절 표의 ⑤, TypeConverterDelegate 256–270행이 만드는 문장입니다. Money에는 String 생성자도, MoneyEditor라는 클래스도 없으니 네 단계를 다 지나 여기까지 옵니다. port와 tag는 컨버터 없이도 됐을 자리지만, 같은 빈의 price에서 실패했으니 빈 전체가 만들어지지 않습니다.
값과 예외 메시지는 위 예제의 실제 출력에서 가져왔습니다. 칸 안의 ①~⑤는 3절 표의 순서입니다.
DefaultConversionService를 @Bean으로 직접 두는 대신 ConversionServiceFactoryBean을 쓰는 방법도 있습니다. setConverters로 받은 집합을 afterPropertiesSet에서 새 DefaultConversionService에 등록해 주는 FactoryBean입니다(52행, 65–83행). 등록할 때 Converter·ConverterFactory·GenericConverter 가운데 무엇인지 보고 알맞은 addConverter를 부릅니다(ConversionServiceFactory 49–62행). 돌려 보지는 않았습니다.
헷갈리기 쉬운 곳
- 빈 이름이 정확히
conversionService여야 합니다.@Bean DefaultConversionService myConverters()라고 지으면 컨테이너는 끼우지 않고,@Value는 에디터만으로 돕니다. 6절 ①의 코드가 이름으로 찾기 때문입니다. 참고로 Spring Boot는 이와 별개로 자기 방식으로ConversionService를 끼우는 것으로 알려져 있는데, 이 저장소의 소스로 확인한 내용은 아닙니다. - 변환된 값은 설계도에 되돌려 저장됩니다.
applyPropertyValues의pv.setConvertedValue(convertedValue)가 병합된BeanDefinition안의PropertyValue에 결과를 적어 둡니다(1715–1728행). 프로토타입 빈처럼 같은 설계도로 객체를 여러 번 만들 때 두 번째부터는 변환을 건너뛰기 위해서입니다(1669–1674행). 4절의pv.isConverted()분기가 그것을 읽는 쪽입니다. BeanWrapperImpl은 "거의 내부 클래스"입니다. 설명 주석에 "for almost all purposes - an internal class"라고 적혀 있고 바깥에서는PropertyAccessorFactory.forBeanPropertyAccess를 쓰라고 합니다(44–47행). 이 글의 예제는 동작을 보려고 직접 썼습니다.
더 깊이 들어가면
이 글은 큰 줄기를 위해 많은 것을 뺐습니다. 이름과 위치만 적어 둡니다. 두 체계를 거꾸로 잇는 어댑터, 곧 ConversionService를 PropertyEditor처럼 보이게 하는 ConvertingPropertyEditorAdapter(34–74행). ConverterAdapter가 제네릭까지 맞춰 보는 matches와 6.2.3 이전 동작으로 되돌리는 matchesFallback(GenericConversionService 331–350행). getClassHierarchy가 배열과 enum을 다루는 방식(541–560행). TypeDescriptor가 자주 쓰는 타입 18개를 미리 만들어 두는 캐시(56–71행). 기본 에디터를 게으르게 덮어쓰는 setDefaultEditorRegistrar(6.2.3부터, 174–176행)와 컨테이너 쪽의 짝 BeanFactoryDefaultEditorRegistrar(AbstractBeanFactory 2135–2141행). 인트로스펙션의 실제 구현을 바꾸는 BeanInfoFactory와 기본값인 SimpleBeanInfoFactory(CachedIntrospectionResults 218–226행). Map 키의 변환과 Optional 속성의 처리(AbstractNestablePropertyAccessor 343–366행). getter·setter 대신 필드에 바로 쓰는 DirectFieldAccessor. BeanUtils의 copyProperties와 instantiateClass(735행, 131행). 그리고 웹 요청을 객체에 묶고 검증하는 DataBinder는 이 글의 변환기를 그대로 가져다 쓰는데, 27편에서 다룹니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
PropertyEditor |
JDK java.beans의 문자열 → 값 변환기. 상태가 있고 문자열만 받는다. Spring이 기본 에디터 43종을 둔다 |
ConversionService |
3.0에 들어온 Spring의 변환 체계. 상태가 없고 어떤 타입 쌍이든 받는다. DefaultConversionService가 52종을 등록한다 |
Converter 네 식구 |
Converter(1:1), ConverterFactory(1:하위 타입 전부), GenericConverter(여러 쌍, TypeDescriptor), ConditionalConverter(그때그때 판단) |
GenericConversionService.getConverter |
캐시 → 상위 타입 쌍 전부 탐색 → 조건부 컨버터 → 대입 가능하면 NO_OP. 못 찾으면 NO_MATCH도 캐시 |
TypeDescriptor |
목표 타입의 원소 타입·어노테이션까지 담는 설명서. List와 List<Integer>를 구분하는 열쇠 |
convertIfNecessary 순서 |
커스텀 에디터 → ConversionService → 기본 에디터 → 표준 규칙(String 생성자 등) → 예외 |
BeanWrapper |
이름으로 속성을 읽고 쓰는 접근기이자 에디터 등록소이자 타입 변환기 |
CachedIntrospectionResults |
클래스마다 한 번 인트로스펙션해 static 맵에 보관 |
| 중첩 경로 | 세그먼트마다 자식 BeanWrapperImpl을 만들어 내려간다. 중간이 null이면 autoGrowNestedPaths에 따라 만들거나 예외 |
| 컨테이너 연결 | conversionService 이름의 빈을 finishBeanFactoryInitialization이 끼우고, 빈마다 BeanWrapperImpl에, @Value마다 SimpleTypeConverter에 같은 설정을 싣는다 |
다음에 볼 것
이 글에서 설정 값은 전부 "문자열 그대로"였습니다. 그런데 @Value("#{systemProperties['user.home']}")처럼 #{…}로 감싼 값은 문자열이 아니라 계산해야 하는 식입니다. 6절 ③의 doResolveDependency에서 convertIfNecessary 바로 앞에 있던 evaluateBeanDefinitionString(1682행)이 그 식을 푸는 자리이고, 푸는 도구가 SpEL입니다. 다음 글 15편: SpEL — 표현식은 어떻게 파싱되고 계산되나에서 그 식이 어떻게 토큰이 되고 트리가 되고 값이 되는지, 그리고 그 값이 어떻게 다시 이 글의 TypeConverter를 거치는지 봅니다.