설정 값은 거의 항상 문자열로 들어온다. "true", "30s", "1,2,3", "com.example.Foo". 그런데 빈의 프로퍼티는 boolean, Duration, List<Integer>, Class<?> 같은 강타입이다. 이 간극을 메우는 일이 타입 변환이며, 과거 JavaBeans PropertyEditor의 한계(상태 보유, 비스레드세이프, String 중심)를 넘어서기 위해 Spring 3.0이 도입한 것이 core.convert 패키지다.
핵심 약속은 세 가지다. (1) 스레드세이프한 단일 진입점 ConversionService. (2) 단순/복합을 아우르는 계층화된 Converter SPI. (3) 필드의 제네릭·어노테이션 맥락까지 전달하는 TypeDescriptor. 이 시스템은 spring-beans의 데이터 바인딩, @Value 해석, Spring MVC 파라미터 변환의 공통 엔진이다.
소스: spring-core/src/main/java/org/springframework/core/convert/
진입점
┌─────────────────────┐ convert(source, srcTD, tgtTD)
│ ConversionService │ canConvert(...)
└──────────▲──────────┘
│ extends (등록 API 추가)
┌─────────────────────────────┐
│ ConfigurableConversionService│ = ConversionService + ConverterRegistry
└──────────▲──────────────────┘
│ implements
┌─────────────────────────────┐
│ GenericConversionService │ 실제 엔진 (매칭 + 캐시)
└──────────▲──────────────────┘
│ extends
┌─────────────────────────────┐
│ DefaultConversionService │ + 기본 변환기 100여 개 사전 등록
└─────────────────────────────┘
Converter SPI (단순 → 복잡)
┌──────────────────────┬──────────────────────────────────────────┐
│ Converter<S,T> │ 1:1 변환. 가장 단순. │
│ ConverterFactory<S,R>│ S → R의 하위타입군 (예: String→모든 Enum) │
│ GenericConverter │ 여러 타입쌍 + TypeDescriptor 맥락 접근 │
│ ConditionalConverter │ 매칭 시점에 적용 여부 동적 판단 │
└──────────────────────┴──────────────────────────────────────────┘
내부적으로 Converter/ConverterFactory 는 모두 GenericConverter 로 어댑팅된다.
Class만으로는 List<Integer>와 List<String>을 구분할 수 없다. TypeDescriptor는 대상 타입에 더해 제네릭 정보(ResolvableType 기반), 어노테이션, 배열/컬렉션/맵 요소 타입까지 담는 컨텍스트 객체다. forObject(source)로 인스턴스에서, 필드/메서드파라미터에서도 만들 수 있다. 변환기는 이 맥락을 보고 "필드에 붙은 @DateTimeFormat" 같은 정보까지 반영할 수 있다.
GenericConversionService.convert(source, sourceType, targetType)가 전체 흐름의 중심이다.
convert(source, srcTD, tgtTD)
│
├─ srcTD == null ? → null 소스 처리(convertNullSource), 끝
│
├─ source 가 srcTD 인스턴스인지 검증 (아니면 IllegalArgument)
│
▼
getConverter(srcTD, tgtTD) ← 핵심: 적합한 GenericConverter 찾기
│ ┌──────────────────────────────────────────────┐
│ │ 1) converterCache 조회 (ConverterCacheKey) │
│ │ 2) miss 면 Converters.find(srcTD,tgtTD): │
│ │ - src/tgt 의 클래스 계층을 BFS 로 펼쳐 │
│ │ 모든 (S,T) 쌍 후보를 순서대로 시도 │
│ │ - ConditionalConverter.matches() 통과해야 채택 │
│ │ 3) 못 찾으면 NO_MATCH 캐싱 │
│ └──────────────────────────────────────────────┘
▼
converter != null ?
├─ yes → ConversionUtils.invokeConverter(...) → handleResult
└─ no → handleConverterNotFound (ConverterNotFoundException 등)
핵심은 타입 계층 탐색 매칭이다. getConverter는 소스/타깃의 슈퍼클래스·인터페이스를 펼쳐 가능한 모든 ConvertiblePair를 우선순위 순서로 시도하고, 후보가 ConditionalConverter라면 matches()까지 통과해야 선택한다. 결과는 ConcurrentReferenceHashMap 캐시에 키(ConverterCacheKey)로 저장되어 다음 호출부터는 매칭 비용을 건너뛴다.
addConverter(Converter)를 호출하면 내부에서 제네릭 시그니처(<S,T>)를 ResolvableType으로 뽑아내(getRequiredTypeInfo) ConverterAdapter로 감싼다. ConverterFactory도 ConverterFactoryAdapter로 감싼다. 즉 레지스트리 내부에는 오직 GenericConverter만 존재한다. 시그니처를 못 뽑으면(타입 파라미터 미지정) 명확한 예외를 던진다 — 람다 컨버터를 등록할 때 addConverter(Class,Class,Converter) 오버로드를 써야 하는 이유다.
// addConverter(Converter) — 시그니처 추출 후 어댑팅
ResolvableType[] typeInfo = getRequiredTypeInfo(converter.getClass(), Converter.class);
if (typeInfo == null && converter instanceof DecoratingProxy dp) { // 프록시면 원본 클래스로
typeInfo = getRequiredTypeInfo(dp.getDecoratedClass(), Converter.class);
}
if (typeInfo == null) throw new IllegalArgumentException("...does the class parameterize those types?");
addConverter(new ConverterAdapter(converter, typeInfo[0], typeInfo[1]));canConvert(src, tgt):getConverter가 null이 아닌지로 판단. 단 컬렉션/배열/맵은 "원소가 변환 불가여도true"를 반환할 수 있다는 점을 주석이 명시한다 — 실제 변환에서ConversionException이 날 수 있으니 호출자가 처리해야 한다.canBypassConvert(src, tgt): 변환기가NO_OP_CONVERTER인지, 즉 "그냥 원본을 그대로 돌려줘도 되는 경우"인지 알려줘 불필요한 변환을 건너뛰게 한다.DefaultConversionService.addDefaultConverters(registry): String↔Number/Boolean/Enum/UUID/Charset/Locale, Collection↔Array↔Object, Map↔Map, Object→Optional, 그리고ObjectToStringConverter/FallbackObjectToStringConverter(최후의toString())까지 수십 개를 한 번에 등록하는 정적 유틸.getSharedInstance()로 지연 초기화된 공용 인스턴스를 제공한다.
- SPI 계층화: 90%는
Converter<S,T>로 충분하고, "하나의 부모로 여러 자식 타입"은ConverterFactory, "맥락 필요 + 다중 타입쌍"은GenericConverter로 단계적으로 복잡도를 올린다. 문서가 명시적으로 "단순한 걸로 충분하면 GenericConverter 쓰지 말라"고 권한다. ConditionalConverter: 동일 타입쌍에 대해 어노테이션 유무 등으로 적용을 분기.ConditionalGenericConverter는 둘을 합친 형태.- 캐시 무효화: 컨버터를 추가/제거하면
invalidateCache()로 매칭 캐시를 비운다 — 런타임 동적 등록을 안전하게 한다. PropertyEditor다리:ConvertingPropertyEditorAdapter가 레거시PropertyEditor세계와 새 변환 세계를 연결한다.
core.convert는 "값 ↔ 타입" 변환을 스레드세이프한 ConversionService 한 점으로 모으고, 그 뒤에 Converter→ConverterFactory→GenericConverter로 복잡도를 단계화한 SPI를 둔다. 모든 등록은 GenericConverter로 정규화되어 타입 계층 탐색으로 매칭되고 캐시된다. TypeDescriptor가 제네릭·어노테이션 맥락을 운반해, 단순 클래스 변환을 넘어 List<Integer>나 @DateTimeFormat 같은 실제 필드 의미까지 다룬다.