PropertyEditor / Converter/ Formatter
PropertyEditor는 스프링이 기본적으로 제공하는 바인딩용 타입 변환 API다.
아래는 propertyEditor가 지원하는 타입들이다. ( 스프링 5.3.23 기준 )
org.springframework.beans.propertyeditors
ByteArrayPropertyEditor | CharacterEditor | CharArrayPropertyEditor | CharsetEditor |
ClassArrayEditor | ClassEditor | CurrencyEditor | CustomBooleanEditor |
CustomCollectionEditor | CustomDateEditor | CustomMapEditor | CustomNumberEditor |
FileEditor | InputSourceEditor | InputStreamEditor | LocaleEditor |
PathEditor | PatternEditor | PropertiesEditor | ReaderEditor |
ResourceBundleEditor | StringArrayPropertyEditor | StringTrimmerEditor | TimeZoneEditor |
URIEditor | URLEditor | UUIDEditor | ZoneIdEditor |
바인딩 과정에서 변환할 파라미터 또는 모델 프로퍼티의 타입에 맞는 프로퍼티 에디터가 자동으로 선정되어 사용된다.
스프링이 지원하지 않는 타입을 파라미터로 사용한다면 커스텀을 통해 프로퍼티 에디터를 등록하면 된다.
PropertyEditorSupport 클래스를 상속받아 getAsText와 setAsText를 구현하고
사용할 컨트롤러마다 @InitBinder 설정하면 된다.
전역적으로 사용하고 싶다면 WebBindingInitializer 를 사용해서 구현하고 빈을 등록한 뒤에
RequestMappingHandlerAdapter(구 AnnotationMethodHandlerAdapter)도 빈으로 등록하고 DI를 걸어주면 된다.
@Data
public class Product {
private Integer id;
private String name;
public Product(Integer id) {
this.id = id;
}
}
public class ProductEditor extends PropertyEditorSupport {
@Override
public String getAsText() {
Product product = (Product) getValue();
return product.getId().toString();
}
@Override
public void setAsText(String text) throws IllegalArgumentException {
setValue(new Product(Integer.parseInt(text)));
}
}
@InitBinder
public void init(WebDataBinder webDataBinder){
webDataBinder.registerCustomEditor(Product.class, new ProductEditor());
}
@GetMapping("/product/{product}")
public String product(@PathVariable Product product){
return "product " + product.getId().toString();
}
public class CustomWebBindingInitializer implements WebBindingInitializer {
@Override
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(Product.class, new ProductEditor());
}
}
<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
<property name="webBindingInitializer">
<bean class="....CustomWebBindingInitializer"/>
</property>
</bean>
PropertyEditor는 매번 바인딩을 할 때마다 새로운 오브젝트를 만들어야 한다는 약점과 이러한 특징은 싱글톤 서비스 오브젝트 중심의
스프링과 어울리지 않기 때문에 스프링 3.0에서 Convert 인터페이스가 제공 되었다.
Convert는 변환 과정에서 메서드가 한 번만 호출되기 때문에 인스턴스 변수로 저장하지 않기 때문에 싱글톤 빈으로 등록해 두고 사용하며
멀티스레드 환경에서 안전하게 공유해서 사용할 수 있다. 단 Converter 메서드는 단방향 변환만 지원하기 때문에
Converter를 양쪽으로 만들어야 양방향 변환이 가능하다.
Converter 기본 타입 컨버터
ConverterFactory 전체 클래스 계층 구조가 필요할 때
GenericConverter 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
ConditionalGenericConverter 특정 조건이 참인 경우에만 실행
https://docs.spring.io/spring-framework/reference/core/validation/convert.html
Spring Type Conversion :: Spring Framework
When you require a sophisticated Converter implementation, consider using the GenericConverter interface. With a more flexible but less strongly typed signature than Converter, a GenericConverter supports converting between multiple source and target types
docs.spring.io
DefaultConversionService 는 다음 두 인터페이스를 구현했다.
ConversionService : 컨버터 사용에 초점
ConverterRegistry : 컨버터 등록에 초점
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
}
}
public interface Converter<S, T> {
T convert(S source);
}
public class StringToProductConverter implements Converter<String, Product> {
@Override
public Product convert(String source) {
return new Product(Integer.parseInt(source));
}
}
public class ProductToStringConverter implements Converter<Product, String> {
@Override
public String convert(Product product) {
return String.valueOf(product.getId());
}
}
@Bean
public ConfigurableWebBindingInitializer initializer() {
ConfigurableWebBindingInitializer initializer = new ConfigurableWebBindingInitializer();
ConfigurableConversionService conversionService = new FormattingConversionService();
conversionService.addConverter(new ProductToStringConverter());
conversionService.addConverter(new StringToProductConverter());
initializer.setConversionService(conversionService);
return initializer;
}
ConversionServiceFactoryBean을 빈으로 등록하고 ConversionService를 컨트롤러에 DI 받아두고
@InitBinder를 사용해 해당 클래스에만 적용시킬 수도 있지만 컨버터는 싱글톤으로 사용하기 때문에
ConfigurableWebBindingIntializer를 통하여 일괄 등록하여 사용해도 문제가 없다.
Converter는 일반적인 타입 변환과 데이터 바인딩 시에 실행했다면 Formatter는 특정 값을 문자로 변환하거나 문자열을 날짜로
파싱 하는 사용자의 입력 데이터를 처리하는 데 사용된다.
public interface Formatter<T> extends Printer<T>, Parser<T> { }
public class ProductFormatter implements Formatter<Product> {
@Override
public Product parse(String text, Locale locale) throws ParseException {
return new Product(Integer.parseInt(text));
}
@Override
public String print(Product object, Locale locale) {
return object.getId().toString();
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatter(new ProductFormatter());
}
}
@DateTimeFormat의 iso 속성은 ISO 날짜 및 시간 형식을 사용하여 날짜를 지정하는 데 사용된다.
이를 통해 ISO 8601 표준에 따라서 특정한 형식의 날짜 및 시간을 표현하고 파싱할 수 있습니다.
주요 ISO 형식:
- ISO.DATE: "yyyy-MM-dd" 형식으로 날짜를 지정합니다.
- ISO.TIME: "HH:mm:ss.SSSZ" 형식으로 시간을 지정합니다.
- ISO.DATE_TIME: "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 형식으로 날짜 및 시간을 지정합니다.
주의사항:
- ISO.DATE를 사용할 경우, 날짜의 시간 부분은 00:00:00으로 간주됩니다.
- ISO.TIME을 사용할 경우, 날짜는 1970-01-01로 간주됩니다.
- ISO.DATE_TIME을 사용할 경우, 날짜와 시간이 모두 포함되어 있습니다.
AnnotationFormatterFactory를 이용해서 어노테이션에 따른 포맷터를 생성할 수 있다.
public interface AnnotationFormatterFactory<A extends Annotation> {
// 지원하는 필드 타입들의 집합 반환
Set<Class<?>> getFieldTypes();
// 어노테이션과 필드 타입에 대한 Printer를 반환
Printer<?> getPrinter(A annotation, Class<?> fieldType);
// 어노테이션과 필드 타입에 대한 Parser를 반환
Parser<?> getParser(A annotation, Class<?> fieldType);
}
class MaskFormatter implements Formatter<String> {
// javax.swing.text.MaskFormatter를 사용하여 포맷을 적용
private javax.swing.text.MaskFormatter delegate;
// 생성자에서 포맷을 초기화
public MaskFormatter(String mask) {
try {
this.delegate = new javax.swing.text.MaskFormatter(mask);
this.delegate.setValueContainsLiteralCharacters(false);
this.delegate.setPlaceholderCharacter('*');
} catch (ParseException e) {
throw new IllegalStateException("Mask could not be parsed " + mask, e);
}
}
// 문자열을 출력 포맷에 맞게 변환
@Override
public String print(String object, Locale locale) {
try {
return delegate.valueToString(object);
} catch (ParseException e) {
throw new IllegalArgumentException("Unable to print using mask " + delegate.getMask(), e);
}
}
// 문자열을 입력 포맷에 맞게 파싱
@Override
public String parse(String text, Locale locale) throws ParseException {
System.out.println("====>maskFormat : " + text);
System.out.println(delegate.getPlaceholderCharacter());
String str = (String) delegate.stringToValue(text);
System.out.println(str);
return str;
}
}
public class MaskFormatAnnotationFormatterFactory implements AnnotationFormatterFactory<MaskFormat> {
@Override
public Set<Class<?>> getFieldTypes() {
Set<Class<?>> fieldTypes = new HashSet<Class<?>>(1, 1);
fieldTypes.add(String.class);
return fieldTypes;
}
@Override
public Parser<?> getParser(MaskFormat annotation, Class<?> fieldType) {
return new MaskFormatter(annotation.value());
}
@Override
public Printer<?> getPrinter(MaskFormat annotation, Class<?> fieldType) {
return new MaskFormatter(annotation.value());
}
}
@Target(value={ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MaskFormat {
String value();
}
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatterForFieldAnnotation(new MaskFormatAnnotationFormatterFactory());
}
}
public class MaskFormatter extends DefaultFormatter {
private static final char DIGIT_KEY = '#'; // 0부터 9까지의 숫자
private static final char LITERAL_KEY = '\''; // 리터럴 문자
private static final char UPPERCASE_KEY = 'U'; // 대문자 알파벳
private static final char LOWERCASE_KEY = 'L'; // 소문자 알파벳
private static final char ALPHA_NUMERIC_KEY = 'A'; // 알파벳 또는 숫자
private static final char CHARACTER_KEY = '?'; // 임의의 문자
private static final char ANYTHING_KEY = '*'; // 모든 문자
private static final char HEX_KEY = 'H'; // 16진수 문자
}
주의 사항
JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용한다.
JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다.