Java Spring MVC part 16
Converter & Formatter
스프링에서는 대부분의 파라미터를 문자로 입력받게 되는데, 이를 적절한 타입을 변환하는 작업이 필요하다.
하지만, Spring에서는 @RequestParam, @ModelAttribute, @PathVariable과 같은 annotation이 포함된 파라미터에는 저절로 타입 변환기가 적용된다.
Example
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
http://localhost:8080/hello-v2?data=10
위의 url로 요청을 하게 되면, 자바에서는 data 값을 문자로 받게 되는데, @ReguestParam이 적용된 파라미터는 Integer 타입이다. 이때, Spring 내부에 구현되어 있는 StringtoIntegerConverter가 실행되어 문자가 숫자로 변환된다.
위 언급한 annotation 이외에도,
- @Value YML 정보 요청
- XML 스프링 빈 정보 요청
- 빈 랜더링 등에서도 타입 변환이 자동으로 이루어지게 된다.
만약 Spring 내부적으로 구현된 타입 변환기가 아닌 새로운 형태의 Converter을 구현하고자 하면 Spring이 제공하는 Converter 인터페이스를 구현하는 Converter 클래스를 생성하면 된다.
Converter
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
S Type 에서 T Type로 변환하는 컨버터를 만들고자 할때 위의 converter 인터페이스를 구현한다.
Integer < - > String Converter
Converter는 단 방향의 변환을 허용한다. 하지만, 단 방향을 2개를 이용하면 양방향으로 변환되도록 할 수 있다.
StringToIntegerConverter
@Slf4j
public class StringToIntegerConverter implements Converter<String,Integer> {
@Override
public Integer convert(String source) {
log.info("convert source={}", source);
return Integer.valueOf(source);
}
}
IntegerToStringConverter
@Slf4j
public class IntegerToStringConverter implements Converter<Integer,String> {
@Override
public String convert(Integer source) {
log.info("convert source={}", source);
return String.valueOf(source);
}
}
Test
@Test
void stringToInteger(){
StringToIntegerConverter stringToIntergerConverter = new StringToIntegerConverter();
Integer convert = stringToIntergerConverter.convert("10");
Assertions.assertThat(convert).isEqualTo(10);
}
@Test
void integerToString(){
IntegerToStringConverter integerToStringConverter = new IntegerToStringConverter();
String convert = integerToStringConverter.convert(10);
Assertions.assertThat(convert).isEqualTo("10");
}
위와 같은 기본형 간의 타입 변환은 이미 스프링 내부적으로 대부분 구현되어 있다. 따라서, 우리가 Converter을 쓰느 경우는 객체 타입을 변환 하게 될떄 자주 사용한다.
IpPort < - > String
Ip와 Port 정보를 담은 객체를 문자열로 변환, 문자열을 해당 객체로 변환하는 converter
IpPort Class
@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private Integer port;
public IpPort(String ip, Integer port) {
this.ip = ip;
this.port = port;
}
}
@EqualAndHashCode 는 클래스 내부의 멤버변수 값이 일치할 때 해당 인스턴스가 동등하다는 것을 의미하도록 annotation 설정
StringToIpPortConverter
@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];
Integer port = Integer.valueOf(split[1]);
return new IpPort(ip, port);
}
}
IpPortToStringConverter
@Slf4j
public class IpPortToStringConverter implements Converter<IpPort,String> {
@Override
public String convert(IpPort source) {
log.info("convert_source= {}", source);
return source.getIp() + ":" +source.getPort();
}
}
Test
@Test
void stringToIpPort(){
StringToIpPortConverter stringToIpPortConverter = new StringToIpPortConverter();
IpPort convert = stringToIpPortConverter.convert("127.0.0.1:8080");
Assertions.assertThat(convert).isEqualTo(new IpPort("127.0.0.1", 8080));
}
@Test
void ipPortToString(){
IpPortToStringConverter ipPortToStringConverter = new IpPortToStringConverter();
IpPort source = new IpPort("127.0.0.1", 8080);
String convert = ipPortToStringConverter.convert(source);
Assertions.assertThat(convert).isEqualTo("127.0.0.1:8080");
}
ConversionService
위와 같이 Converter 직접 호출해서 사용하게 되는 경우는 없다. Spring이 Converter Interface를 제공하는 이유는, 바로 이러한 Converter 클래스를 관리하기 용이하게 하기 위해서이다.
이를 ConversionService를 이용해서 여러 Converter에 대한 호출을 손쉽게 할 수 있다.
ConversionService Interface
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을 통해 convert 메소드를 호출하게 되면 canConvert 메소드가 호출되어 타입간에 convert을 수행가능 여부를 판단하여 convert를 수행할 수 있다.
Converter Register
@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 Test
assertThat(conversionService.convert("10", Integer.class)).isEqualTo(10);
assertThat(conversionService.convert(10, String.class)).isEqualTo("10");
assertThat(conversionService.convert("127.0.0.1:8080", IpPort.class)).isEqualTo(new IpPort("127.0.0.1", 8080));
assertThat(conversionService.convert(new IpPort("127.0.0.1", 8080), String.class)).isEqualTo("127.0.0.1:8080");
위의 설계 방식을 보면, Converter을 등록하는 부분과 Converter을 사용하는 부분이 철저하게 분리되어 있다. 또한, 사용자는 Conversion Service를 이용하면서 특정 구현체에 의존하지 않고, Interface만을 통해 conversion을 수행하는 데 이는 객체지향 원칙 중 하나인 ISP(Interface Segregation Principal)을 지키는 것이다.
인터페이스를 이용한 관리를 통해 OCP도 지키게 되는 부가적인 효과를 얻는다.
위와 같이 WebMvcConfigurer를 이용해서 Converter을 등록해 놓으면 Spring이 자동으로 Type 변환을 요청하는 파라미터에 대해서도 자동으로 직접 구현한 Converter가 활용된다.
Spring 내부적으로 이미 구현된 Converter가 있다고 하더라도, 수동으로 등록한 Converter가 더 높은 우선순위를 가진다.
Auto Conversion Example
@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";
}
아래와 같이 호출을 하게 되면 위의 controller가 수행되고 StringToIpPortConverter가 호출되어 IpPort 객체로 변환되어 파라미터로 전달된다.
http://localhost:8080/ip-port?ipPort=127.0.0.1:8080
@RequestParam과 같은 파라미터를 처리하는 것은 ArgumentResolver 중의 하나로, RequestParamMethodArgumentResolver이 수행하는데, 해당 ArgumentResolver에서 Type Conversion 해당 파라미터 값을 만들 때, type converter을 호출하게 된다.
View Template + Conversion
View Controller
@GetMapping("/converter-view")
public String converterView(Model model) {
model.addAttribute("number", 10000);
model.addAttribute("ipPort", new IpPort("127.0.0.1", 8080));
return "converter-view";
}
위와 같이 숫자와, IpPort 객체를 View Template에 전달하면 View에서는 어떻게 이들을 처리할 까?
converter-view.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<ul>
<li>${number}: <span th:text="${number}" ></span></li>
<li>$: <span th:text="$" ></span></li>
<li>${ipPort}: <span th:text="${ipPort}" ></span></li>
<li>$: <span th:text="$" ></span></li>
</ul>
</body>
</html>
Rendering Result
• ${number}: 10000
• $: 10000
• ${ipPort}: hello.typeconverter.type.IpPort@59cb0946
• $: 127.0.0.1:8080
Thymeleaf에서 ${}는 변수 표현식을 의미하고, $는 conversion 서비스를 적용한 변수를 의미한다. 따라서 $을 활용하게 되면 랜더링을 수행할 때, type conversion을 자동으로 적용하게 된다.
Controller
@GetMapping("/converter/edit")
public String converterForm(Model model) {
IpPort ipPort = new IpPort("127.0.0.1", 8080);
Form form = new Form(ipPort);
model.addAttribute("form", form);
return "converter-form";
}
View
<form th:object="${form}" th:method="post">
th:field <input type="text" th:field="*{ipPort}"><br/>
th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/>
<input type="submit"/>
</form>
thymeleaf의 th:field을 이용하면 type conversion을 자동으로 수행해서 값을 출력하게 된다.
Formatter
Converter가 범용적인 타입 변환 기능을 수행한다고 하면, Formatter은 문자와 다른 타입간 변환에 초점을 맞춘 converter이다. 따라서, Spring에서는 주로, Formatter을 이용하는 경우가 많다.
또한, Formatter을 이용하게 되면, format 기능을 추가 이용할 수 있다. 가령, 숫자 1000을 입력받으면 문자 1,000와 같이 형식으로 변환하거나, 또는 그 반대방향으로 변환할 수 있도록 지원한다.
그리고, 이러한 형식을 맞춘 변환을 수행할 때, Locale을 받는데, 이는 특정 지역에 따른 형식 변환을 지원하기 위함이다.
Formatter Interface
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> {
}
print 함수를 통해 Object -> String, parse를 통해 String -> Object 간 타입 변환을 수행한다.
NumberFormatter
NumberFormatter
@Slf4j
public class MyNumberFormatter implements Formatter<Number> {
@Override
public Number parse(String text, Locale locale) throws ParseException {
log.info("text={}, locale={}", text, locale);
NumberFormat instance = NumberFormat.getInstance(locale);
return instance.parse(text);
}
@Override
public String print(Number object, Locale locale) {
log.info("object={}, locale={}", object, locale);
NumberFormat instance = NumberFormat.getInstance(locale);
return instance.format(object);
}
}
실제로, “1,000,000”와 같은 문자열을 1000000와 같은 숫자 형태로 바꿔주는 작업 꽤나 구현하기 까다롭다. 하지만 자바에서는 이러한 format에 대한 메소드를 지원하기 때문에 이를 활용한다.
Test
MyNumberFormatter myNumberFormatter=new MyNumberFormatter();
@Test
void parse() throws ParseException {
Number result = myNumberFormatter.parse("1,000", Locale.KOREA);
assertThat(result).isEqualTo(1000L);
}
@Test
void print() {
String result = myNumberFormatter.print(1000, Locale.KOREA);
assertThat(result).isEqualTo("1,000");
}
FormattingConversionService
물론 Formatter도 마찬가지로, Converter와 비슷하게, 직접 Formatter을 호출하는 경우는 없다. Spring에서는 Formatter와 Converter을 동시에 지원하기 위해 FormattingConversionService를 지원한다.
Formatter Register
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
registry.addFormatter(new MyNumberFormatter());
}
}
FormattingConversionService Test
assertThat(conversionService.convert("127.0.0.1:8080", IpPort.class)).isEqualTo(new IpPort("127.0.0.1", 8080));
assertThat(conversionService.convert(new IpPort("127.0.0.1", 8080), String.class)).isEqualTo("127.0.0.1:8080");
assertThat(conversionService.convert(new IpPort("127.0.0.1", 8080), String.class)).isEqualTo("127.0.0.1:8080");
assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
assertThat(conversionService.convert(1000,String.class)).isEqualTo("1,000");
print/parse를 할 필요 없이, convert 메소드 만을 이용해서 타입간 변환을 수행할 수 있다.
Converter와 Formatter 간의 우선순위를 살펴보면, Converter가 높은 우선순위를 가지게 된다.
View
<ul>
<li>${number}: <span th:text="${number}" ></span></li>
<li>$: <span th:text="$" ></span></li>
<li>${ipPort}: <span th:text="${ipPort}" ></span></li>
<li>$: <span th:text="$" ></span></li>
</ul>
formatter을 적용한 후 위의 view rendering을 수행하게 되면 아래와 같이 number < - > string 변환에서 formatter가 적용되는 것을 확인할 수 있다.
Rendering Result
• ${number}: 10000
• $: 10,000
• ${ipPort}: hello.typeconverter.type.IpPort@59cb0946
• $: 127.0.0.1:8080
Basic Formatters
스프링에서는, 각 필드마다 원하는 형식으로 타입 변환을 수행할 수 있도록 Annotation 기반의 formatter을 지원한다.
@NumberFormat: 숫자 관련 형식 지정 @DateTimeFormat: 날짜 관련 형식 지정
Form Class
@Data
class Form {
@NumberFormat(pattern = "###,###")
private Integer number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
}
Form Controller
@GetMapping("/formatter/edit")
public String formatterForm(Model model) {
Form form = new Form();
form.setNumber(10000);
form.setLocalDateTime(LocalDateTime.now());
model.addAttribute("form", form);
return "formatter-form";
}
위와 같이 Formatter가 적용된 Form class을 모델로 전달해서, 아래와 같이 랜더링을 수행하면 아래와 같이, Formatter에 지정한 형식대로 타입이 변환되는 것을 확인할 수 있다.
Formatter-View
<ul>
<li>${form.number}: <span th:text="${form.number}" ></span></li>
<li>$: <span th:text="$" ></span></li>
<li>${form.localDateTime}: <span th:text="${form.localDateTime}" ></span></li>
<li>$: <span th:text="$" ></span></li>
</ul>
Rendering Result
• ${form.number}: 10000
• $: 10,000
• ${form.localDateTime}: 2021-01-01T00:00:00
• $: 2021-01-01 00:00:00
JSON 객체에 대한 Type Conversion??
JSON 객체를 이용한 파라미터에서는 Type Conversion이 이루어지지 않는다. JSON 객체를 만드는 작업은 JaskSon 라이브러리를 통해서 처리되기 때문에 내부적으로 Type Conversion이 구현되어 있지 않는 한, Spring에서 등록된 Converter, Formatter가 호출되지 않는다.
References
link: inflearn
link:springmvc
link:converter
link:formatter
댓글남기기