스프링과 타입 변환
- 타입을 변환해야 하는 경우는 상당히 많다.
- 스프링 MVC 요청 파라미터 -> ex> @RequestParam , @ModelAttribute , @PathVariable
- @Value 등으로 YML 정보 읽기
- XML에 넣은 스프링 빈 정보를 변환
- 뷰를 렌더링 할 때
- 이런게 가능한 것은 스프링이 중간에 타입 변환기를 사용해서 타입을 String Integer 로 변환해주었기 때문에 개발자는 편리하게 해당 타입을 바로 받을 수 있는 것이다.
- 만약 개발자가 새로운 타입을 만들어서 변환하고 싶으면 아래와 같이
컨버터 인터페이스
를 사용하면 된다.
public interface Converter<S, T> {
T convert(S source);
}
- 스프링은 확장 가능한 컨버터 인터페이스를 제공한다.
- 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다.
- 이 컨버터 인터페이스는 모든 타입에 적용할 수 있다.
- 필요하면 X Y 타입으로 변환하는 컨버터 인터페이스를 만들고, 또 Y X 타입으로 변환하는 컨버터 인터페이스를 만들어서 등록하면 된다.
컨버터 인터페이스 사용하기
- 컨버터 인터페이스를 사용해서 타입 컨퍼터를 구현하면 아래와 같다.
@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
log.info("convert source={}", source);
return Integer.valueOf(source);
}
}
@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private int port;
public IpPort(String ip, int port) {
this.ip = ip;
this.port = port;
}
}
@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
@Override
public IpPort convert(String source) {
log.info("convert source={}", source);
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
- 위와 같이 문자로 들어온 정보를 원하는데로 커스터 마이징해서 객체로 반환할 수 있다.
- 하지만 여전히 사용자가 번거롭게 구현해 줘야한다.
- 스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.
- Converter , ConverterFactory , GenericConverter 의 구현체를 찾아보면 수 많은 컨버터를 확인할 수 있다.
- Converter 기본 타입 컨버터
- ConverterFactory 전체 클래스 계층 구조가 필요할 때
- GenericConverter 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
- ConditionalGenericConverter 특정 조건이 참인 경우에만 실행
- 하지만 여전히 구현체를 찾아야한다는 번거로움이 존재한다.
- 컨버전 서비스을 사용하면 이러한 고민을 해결할 수 있다.
@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
@Override
public IpPort convert(String source) {
return objectMapper.readValue(source, IpPort.class);
}
}
- json 형식으로 데이터를 받아서, 위와 같이 objectMapper 를 사용해서 IpPort 로 바인행 해주는 방식도 실무에서는 많이 사용한다.
컨버전 서비스
- 타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것은 매우 불편하다.
- 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공한다.
- ConversionService 인터페이스는 아래와 같다.
public interface ConversionService {
boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType);
boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
<T> T convert(@Nullable Object source, Class<T> targetType);
Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
}
- ConversionService 는 아래와 같이 사용할 수 있다.
@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());
}
}
- 스프링은 내부에서 ConversionService 를 제공한다.
- WebMvcConfigurer 가 제공하는 addFormatters() 를 사용해서 추가하고 싶은 컨버터를 등록하면 된다.
- 스프링은 내부에서 사용하는 ConversionService 에 컨버터를 추가해준다.
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
- 만약 helloV2에 요청을 문자로 보낸다면 registry.addConverter(new StringToIntegerConverter()) 에 등록된 StringToIntegerConverter가 호출 된다.
- 하지만 StringToIntegerConverter 를 등록하기 전에도 이 코드는 잘 수행되었다.
- 스프링이 내부에서 수 많은 기본 컨버터들을 제공하기 때문이다.
- 컨버터를 추가하면 추가한 컨버터가 기본 컨버터 보다 높은 우선순위를 가진다.
@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
System.out.println("ipPort IP = " + ipPort.getIp());
System.out.println("ipPort PORT = " + ipPort.getPort());
return "ok";
}
- 만약 ipPort에 요청으로 문자 "127.0.0.1:8080" 를 보낸다면 StringToIpPortConverter가 호출 되어 객체로 변환 된다.
- 자세한 동작과정은 아래와 같다.
@RequestParam 은 @RequestParam 을 처리하는 ArgumentResolver 인 RequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다.
포맷터 - Formatter
- Converter 는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다.
- 하지만 개발자 입장에서는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.
- 예를들어 1000 문자를 "1,000" 이렇게 1000 단위에 쉼표를 넣어서 출력하거나, 또는 "1,000" 라는 문자를 1000 이라는 숫자로 변경해야 한다.
- 또한 추가로 날짜 숫자의 표현 방법은
Locale
현지화 정보가 사용될 수 있다.
- 이렇게 객체를 특정한 포멧에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터( Formatter )이다.
- Converter vs Formatter
- Converter 는 범용(객체 객체)
- Formatter 는 문자에 특화(객체 문자, 문자 객체) + 현지화(Locale) -> Converter 의 특별한 버전
- Formatter 인터페이스는 아래와 같다.
public interface Printer<T> {
String print(T object, Locale locale);
}
public interface Parser<T> {
T parse(String text, Locale locale) throws ParseException;
}
public interface Formatter<T> extends Printer<T>, Parser<T> {
}
- String print(T object, Locale locale) : 객체를 문자로 변경한다.
- T parse(String text, Locale locale) : 문자를 객체로 변경한다.
- 숫자 1000 을 문자 "1,000" 로 만드는 것을 포맷터를 이용해서 구현하면 아래와 같다.
@Slf4j
public class MyNumberFormatter implements Formatter<Number> {
@Override
public Number parse(String text, Locale locale) throws ParseException {
log.info("text={}, locale={}", text, locale);
NumberFormat format = NumberFormat.getInstance(locale);
return format.parse(text);
}
@Override
public String print(Number object, Locale locale) {
log.info("object={}, locale={}", object, locale);
return NumberFormat.getInstance(locale).format(object);
}
}
- "1,000" 처럼 숫자 중간의 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용하면 된다. 이 객체는 Locale 정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.
- parse() 를 사용해서 문자를 숫자로 변환한다.
- print() 를 사용해서 객체를 문자로 변환한다.
포맷터를 지원하는 컨버전 서비스
- FormattingConversionService 는 포맷터를 지원하는 컨버전 서비스이다.
- FormattingConversionService 는 ConversionService 관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다.
- DefaultFormattingConversionService 는 FormattingConversionService 에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다.
- 테스트 코드를 통해서 사용법을 알아보면 아래와 같다.
public class FormattingConversionServiceTest {
@Test
void formattingConversionService() {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
//컨버터 등록
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
//포맷터 등록
conversionService.addFormatter(new MyNumberFormatter());
//컨버터 사용
IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080)); //포맷터 사용
assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
}
}
- DefaultFormattingConversionService를 사용해서 포맷터를 지원하는 컨버전 서비스를 구현한다.
- 위와 같이 컨버터와 포맷터를 등록해서 사용할 수 있다.
@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());
//추가
registry.addFormatter(new MyNumberFormatter());
}
}
- 우선순위는 컨버터가 우선하므로 포맷터가 적용되지 않고, 컨버터가 적용된다.
스프링에서 기본적으로 제공하는 기본 포맷터
- @NumberFormat : 숫자관련 형식 지정할때 사용한다.
- @DateTimeFormat : 날짜관련 형식을 지정할때 사용한다.
- 아래와 같이 구현할 수 있다.
@Data
static class Form {
@NumberFormat(pattern = "###,###")
private Integer number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
}
메시지 컨버터( HttpMessageConverter ) 와 착각하지 말자!
- 메시지 컨버터( HttpMessageConverter )에는 컨버전 서비스가 적용되지 않는다.
- HttpMessageConverter 의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다.
- 만약 JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용한다.
- JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다.
- 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없다.
- 컨버전 서비스는 @RequestParam , @ModelAttribute , @PathVariable , 뷰 템플릿 등에서 사용할 수 있다.
REFERENCES