예전에 jsp와 servlet을 활용하여 팀 프로젝트를 진행한 적이 있었다. 그때는 클라이언트로부터 넘어온 파라미터를 integer형태로 사용하기 위해 Integer.valueOf(param) 같은 메서드를 활용하여 일일이 타입을 변경해줘야 했다. 하지만 스프링에서 컨트롤러로 Integer형 데이터를 받을때 따로 형변환을 하지 않고 파라미터로 바로 받아져서 원래 String형태에서 형변환을 해줘야 하지 않나?하는 생각을 하다가 나중에는 익숙해져서 그냥 신경쓰지 않고 사용해왔는데, 알고 보니 이런 역할을 타입 컨버터가 해주고 있었다.
@RestController
public class HelloController {
// 타입 직접 변환
@GetMapping("/hello-v1")
public String helloV1(HttpServletRequest request) {
String data = request.getParameter("data");
Integer intValue = Integer.valueOf(data);
System.out.println("intValue = " + intValue);
return "ok";
}
// 스프링이 변환
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
}
그렇다면 개발자가 만든 새로운 타입으로 타입을 변환하고 싶다면 어떻게 해야할까?
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
자바에서 제공하는 PropertyEditor라는 클래스가 있는데, 예전에는 이것을 활용해 타입을 변환했다고 한다. 하지만 동시성 문제로 인해 사용할 때마다 객체를 새로 생성해야 하는 문제가 있다고 한다.
@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);
//"127.0.0.1:8080"
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
@Test
void stringToIpPort() {
IpPortToStringConverter converter = new IpPortToStringConverter();
IpPort source = new IpPort("127.0.0.1", 8080);
String result = converter.convert(source);
assertThat(result).isEqualTo("127.0.0.1:8080");
}
테스트가 정상적으로 수행된 것을 확인할 수 있다.
그런데 이렇게 일일이 컨버터를 생성하고 메서드를 실행한다고 하면 결국 개발자가 직접 컨버팅을 하는 것과 같지 않나? 하는 생각이 든다. 그럼 그런 역할을 하는 것이 무엇일까?
스프링은 용도에 따라 다양한 타입 컨버터를 제공한다.
1. Converter : 기본 타입 컨버터
2. ConverterFactory : 전체 클래스 계층 구조가 필요할 때
3. GenericConverter : 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
4. ConditionalGenericConverter : 특정 조건이 참인 경우에만 실행
공식문서
@Test
void conversionService() {
// 등록
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter());
conversionService.addConverter(new IntegerToStringConverter());
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
//사용
// conversionService.convert(data, type);
assertThat(conversionService.convert("10", Integer.class)).isEqualTo(10);
assertThat(conversionService.convert(10, String.class)).isEqualTo("10");
IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
String ipPortString = conversionService.convert(new IpPort("127.0.0.1", 8080), String.class);
assertThat(ipPortString).isEqualTo("127.0.0.1:8080");
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IpPortToStringConverter());
registry.addConverter(new IntegerToStringConverter());
}
}
@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
System.out.println("ipPort.getIp() = " + ipPort.getIp());
System.out.println("ipPort.getPort() = " + ipPort.getPort());
return "OK";
}
컨버터는 입출력 타입에 제한이 없는 범용 타입 변환 기능을 제공한다. 그런데 일반적으로 웹을 제작할 때 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.
예를 들어 Integer 타입의 데이터를 문자로 변환하거나, 문자를 Integer타입으로 변환하는 상황도 마찬가지이다.
@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);
}
}
class MyNumberFormatterTest {
MyNumberFormatter formatter = new MyNumberFormatter();
@Test
void parse() throws ParseException {
Number result = formatter.parse("1,000", Locale.KOREA);
//parse의 결과가 Long이기 때문에 L명시
assertThat(result).isEqualTo(1000L); // Long타입 주의
}
@Test
void print() {
String result = formatter.print(1000, Locale.KOREA);
assertThat(result).isEqualTo("1,000");
}
}
@Test
void formattingConversionService() {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
//컨버터 등록
conversionService.addConverter(new StringToIntegerConverter());
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);
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// 주석처리
// 컨버터가 포매터보다 우선순위가 높기 떄문에 주석처리
registry.addConverter(new StringToIpPortConverter());
// registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IpPortToStringConverter());
// registry.addConverter(new IntegerToStringConverter());
// 추가
registry.addFormatter(new MyNumberFormatter());
}
}
@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";
}
타임리프에서 ${}는 변수를 ${{}}는 타입 컨버터를 적용한 변수를 나타낸다.
포맷터가 적용되어 10000이라는 숫자가 10,000이라는 문자로 변환된 것을 확인할 수 있다!!
포맷터는 기본 형식이 지정되어 있어 객체의 필드마다 다른 형식으로 포맷을 지정하기 어려운데, 스프링은 이런 문제를 해결하기 위해 어노테이션 기반으로 형식을 지정할 수 있는 두 가지 포맷터를 제공한다
@Controller
public class FormatterController {
@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";
}
@PostMapping("/formatter/edit")
public String formatterEdit(@ModelAttribute Form form) {
return "formatter-view";
}
@Data
static class Form {
@NumberFormat(pattern = "###,###")
private Integer number;
// 월은 대문자 M, 시간은 대문자 H -> 국제 표준임
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
}
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form th:object="${form}" th:method="post">
number <input type="text" th:field="*{number}"><br/>
localDateTime <input type="text" th:field="*{localDateTime}"><br/>
<input type="submit"/>
</form>
</body>
</html>
여기서 th:field는 타입 컨버터를 적용하는 역할도 한다. 참 많은 기능을 하는 것 같다.
th:field의 기능을 다시 짚고 넘어가보면,
id,name,value 속성을 생성해주고, 체크박스의 경우 name에 _(언더바)를 추가한 hidden타입의 태그를 생성해 체크가 되어 있지 않은 경우 값이 아예 넘어가지 않는 문제를 해결해준다. 거기다 오늘 확인한 타입 컨버터까지 적용해준다ㄷㄷ
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<ul>
<li>${form.number}: <span th:text="${form.number}" ></span></li>
<li>${{form.number}}: <span th:text="${{form.number}}" ></span></li>
<li>${form.localDateTime}: <span th:text="${form.localDateTime}" ></span></li>
<li>${{form.localDateTime}}: <span th:text="${{form.localDateTime}}" ></span></li>
</ul>
</body>
</html>
API응답을 보낼 때 사용되는 메세지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다. 메세지 컨버터는 http바디의 내용을 객체로 변환하거나 객체를 http메세지 바디에 입력하는데 사용되는 것이다.
스프링 같은 경우 json을 객체로 변환할 때 메세지 컨버터 내에서 Jackson같은 라이브러리를 사용하기 때문에 변환 결과는 Jackson라이브러리에 달려있다. 따라서 json 데이터의 포맷을 변경하고 싶다면 라이브러리에서 제공하는 기능을 통해 포맷을 지정해야 한다.
나도 타입 컨버터라는 것을 처음 들었을 때 메세지 컨버터랑 헷갈렸는데 자세한 역할을 알고 나니 확실히 서로 다른 역할을 한다는 것을 알게 되었다!
출처 : 김영한 스프링MVC2편