@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";
}
}
http://localhost:8080/hello-v1?data=1로 요청을 보내면 위 코드에서 HTTP 요청 파라미터는 모두 문자로 처리된다.@RestController
public class HelloController {
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
}
@ReqeustParam이 알아서 String으로 넘어왔지만 Integer로 변환해 준것이다.@ModelAttribute , @PathVariable 에서도 확인할 수 있었다.@ModelAttribute UserData data
class UserData {
Integer data;
}
// url = /users/{userId}
@PathVariable("userId") Integer data
@RequestParam , @ModelAttribute , @PathVariable@Value 등으로 YML 정보 읽기보면 알겠지만 굉장히 소요가 크고 개발자가 일일이 형변환을 로직에서 지정하기에 굉장히 귀찮다.
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 Converter 인터페이스를 구현해서 등록하면 된다.
이 Converter 인터페이스는 모든 타입에 적용할 수 있다. 필요하면 X -> Y 타입으로 변환하는 컨버터 인터페이스를 만들고, 또 Y -> X 타입으로 변환하는 컨버터 인터페이스를 만들어서 등록하면 된다.
예를 들어서 문자로 "true" 가 오면 Boolean 타입으로 받고 싶으면 String Boolean 타입으로 변환되도록 컨버터 인터페이스를 만들어서 등록하고, 반대로 적용하고 싶으면 Boolean String 타입으로 변환되도록 컨버터를 추가로 만들어서 등록하면 된다.
127.0.0.0:8080 처럼 바꾸고 싶다고 할 때 아래와 같은 객체 타입을 지정해주고 @EqualsAndHashCode 는 field값들이 같은 값이라면 true를 반환해주는 롬복 어노테이션이다.@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private int port;
public IpPort(String ip, int port) {
this.ip = ip;
this.port = port;
}
}

org.springframework.core.convert.converter; 를 구현해야한다. 여러 converter가 있으니 주의하여 구현하자.@Slf4j
public class StringToPortConverter 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);
}
}
@Slf4j
public class PortToStringConverter implements Converter<IpPort, String> {
@Override
public String convert(IpPort source) {
log.info("convert source={}", source);
return source.getIp()+":"+source.getPort();
}
}
public class ConverterTest {
@Test
void stringToInteger() {
StringToIntegerConverter converter = new StringToIntegerConverter();
Integer result = converter.convert("19");
assertThat(result).isEqualTo(19);
}
@Test
void integerToString() {
IntegerToStringConverter converter = new IntegerToStringConverter();
String result = converter.convert(19);
assertThat(result).isEqualTo("19");
}
@Test
void stringToIpPort() {
StringToPortConverter converter = new StringToPortConverter();
String source = "127.0.0.1:8080";
IpPort result = converter.convert(source);
assertThat(result).isEqualTo(new IpPort("127.0.0.1", 8080));
}
@Test
void ipPortToString() {
PortToStringConverter converter = new PortToStringConverter();
IpPort source = new IpPort("127.0.0.1", 8080);
String result = converter.convert(source);
assertThat(result).isEqualTo("127.0.0.1:8080");
}
}
holy shit 너무 불편하다. 참고로 여기서 .isEqualTo()가 주소값이 다른데 true가 나오는 것은 앞서 언급했던 롬복의 @EqualsAndHashCode 어노테이션 덕분이다.
무튼 이제는 타입 컨버터를 등록하고 관리하면서 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요하다.
그 기능을 제공하는 것이 바로 ConversionService 이다.
참고
스프링(공식 문서)은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.
Converter: 기본 타입 컨버터
ConverterFactory: 전체 클래스 계층 구조가 필요할 때
GenericConverter: 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
ConditionalGenericConverter: 특정 조건이 참인 경우에만 실행
참고
스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다. IDE에서
Converter , ConverterFactory , GenericConverter 의 구현체를 찾아보면 수 많은 컨버터를 확인할
수 있다.
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);
}
public class conversionServiceTest {
@Test
void conversionService() {
// 등록
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToPortConverter());
conversionService.addConverter(new PortToStringConverter());
conversionService.addConverter(new IntegerToStringConverter());
conversionService.addConverter(new StringToIntegerConverter());
// 사용
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");
}
}
opt + cmd + n: 으로 인라인으로 합쳐주면 코드가 조금 짧아진다.보면 그냥 테스트 용으로 DefaultConversionService에 변환 서비스를 등록해두고 .convert() 메서드를 통하여 변환하고 싶은 값, 대상 타입을 지정해주면 값이 알아서 튀어 나온다.
인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.
DefaultConversionService 는 다음 두 인터페이스를 구현했다.
1. ConversionService : 컨버터 사용에 초점
2. ConverterRegistry : 컨버터 등록에 초점
이렇게 하면 추후 등록 로직이나 이런게 바뀌어도 사용쪽 로직은 건들지 않아도 되서 유지 보수 소요가 굉장히 줄어든다.
WebMvcConfigurer를 구현한 WebConfig class를 만들어서 똑같이 등록해주면 된다.public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToPortConverter());
registry.addConverter(new PortToStringConverter());
}
}
스프링은 내부에서 ConversionService 를 제공한다. 우리는 WebMvcConfigurer 가 제공하는 addFormatters() 를 사용해서 추가하고 싶은 컨버터를 등록하면 된다. 이렇게 하면 스프링은 내부에서 사용하는 ConversionService 에 컨버터를 추가해준다.
@RestController
public class HelloController {
@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";
}
...
}
@RequestParam 은 @RequestParam 을 처리하는 ArgumentResolver 인 RequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다. 부모 클래스와 다양한 외부 클래스를 호출하는 등 복잡한 내부 과정을 거치기 때문에 대략 이렇게 처리되는 것으로 이해해도 충분하다. 만약 더 깊이있게 확인하고 싶으면 IpPortConverter 에 디버그 브레이크 포인트를 걸어서 확인해보자.
@Controller
public class ConverterController {
@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";
}
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Converter</title>
</head>
<body>
<ul>
<li>${number}: <span th:text="${number}" ></span></li>
<li>${{number}}: <span th:text="${{number}}" ></span></li>
<li>${ipPort}: <span th:text="${ipPort}" ></span></li>
<li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li>
</ul>
</body>
</html>
타임리프는 ${{...}} 를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다. 물론 스프링과 통합 되어서 스프링이 제공하는 컨버전 서비스를 사용하므로, 우리가 등록한 컨버터들을 사용할 수 있다.
${...}${{...}}
${{number}} : 뷰 템플릿은 데이터를 문자로 출력한다. 따라서 컨버터를 적용하게 되면 Integer 타입인 10000 을 String 타입으로 변환하는 컨버터인 IntegerToStringConverter 를 실행하게 된다. 이 부분은 컨버터를 실행하지 않아도 타임리프가 숫자를 문자로 자동으로 변환히기 때문에 컨버터를 적용할 때와 하지 않
을 때가 같다.
${{ipPort}} : 뷰 템플릿은 데이터를 문자로 출력한다. 따라서 컨버터를 적용하게 되면 IpPort 타입을 String 타입으로 변환해야 하므로 IpPortToStringConverter 가 적용된다. 그 결과 127.0.0.1:8080 가 출력된다.
@Controller
public class ConverterController {
@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";
}
@PostMapping("/converter/edit")
public String converterEdit(@ModelAttribute Form form, Model model) {
IpPort ipPort = form.getIpPort();
model.addAttribute("ipPort", ipPort);
return "converter-view";
}
@Data
static class Form {
private IpPort ipPort;
public Form(IpPort ipPort) {
this.ipPort = ipPort;
}
}
}
표출하기 위한 매우 간단한 컨트롤러를 작성한 후
<!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">
th:field <input type="text" th:field="*{ipPort}"><br/>
th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/>
th:{value}<input type="text" th:value="*{{ipPort}}">(보여주기 용도)<br/>
<input type="submit"/>
</form>
</body>
</html>

GET /converter/editPOST /converter/edit사실 우리가 배운 Converter는 굉장히 범용 타입의 변환 기능을 제공한다.
즉, custom한 값에서 custom한 값으로 변환할 때 굉장히 유용하게 쓰인다.
하지만 현실에서는 대부분 문자 "1000" -> 1,000 로 혹은 문자"2024-01-03 21:40:00" 을 날짜 객체 변환하거나 그 반대의 상황이 대부분이다.
여기에 추가로 locale 기능으로 현지 시간을 보고 싶을 때가 있다.
Converter 는 범용(객체 객체)
Formatter 는 문자에 특화(객체 문자, 문자 객체) + 현지화(Locale) Converter 의 특별한 버전
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> {}
@Slf4j
public class MyNumberFormatter implements Formatter<Number> {
@Override
public Number parse(String text, Locale locale) throws ParseException {
log.info("text={}, locale={}", text, locale);
// "1000" -> 1,000
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);
}
}
public class MyNumberFormatterTest {
MyNumberFormatter formatter = new MyNumberFormatter();
@Test
void parse() throws ParseException {
Number result = formatter.parse("1,000", Locale.KOREA);
assertThat(result).isEqualTo(1000L); //Long 타입 주의
}
@Test
void print() {
String result = formatter.print(1000, Locale.KOREA);
assertThat(result).isEqualTo("1,000");
}
}
Formatter: 포맷터AnnotationFormatterFactory: 필드의 타입이나 애노테이션 정보를 활용할 수 있는 포맷터그럼데 코드를 보면 알 수 있듯 Formatter 역시 이렇게 사용하지 않고 한 곳에 모아서 관리할 수 있으면 좋을 것 같다.
Conversion Service에는 Converter만 등록할 수 있고, Formatter를 등록할 수 는 없다. 하지만 따지고 보면 Formatter는 사실 특별한 Converter이다.
그래서 Formatter를 지원하는 Conversion Service를 사용하면 Conversion Service에 Formatter를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해서 Formatter가 Converter 처럼 동작하도록 지원한다.
FormattingConversionService 는 포맷터를 지원하는 Conversion Service이다.
DefaultFormattingConversionService 는 FormattingConversionService 에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다.
public class FormattingConversionServiceTest {
@Test
void formattingConversionService() {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
//컨버터 등록
conversionService.addConverter(new StringToPortConverter());
conversionService.addConverter(new PortToStringConverter());
//포맷터 등록
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 상속 관계FormattingConversionService 는 ConversionService 관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다.
그리고 사용할 때는 ConversionService 가 제공하는 convert 를 사용하면 된다.
DefaultFormattingConversionService 를 상속 받은 WebConversionService 를 내부에서 사용한다.@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// 우선 순위에 밀리기 때문에 Formatter가 적용되었는지 확안하기위해 Converter는 주석.
// registry.addConverter(new StringToIntegerConverter());
// registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToPortConverter());
registry.addConverter(new PortToStringConverter());
registry.addFormatter(new MyNumberFormatter());
}
}
WebMvcConfigurer를 상속받은 WebConfig에 등록해주면 된다./converter-view 경로가 잡혀있는 컨트롤러를 타고 아래와 같은 결과가 보인다.
@NumberFormat : 숫자 관련 형식 지정 포맷터 사용, NumberFormatAnnotationFormatterFactory
@DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용, Jsr310DateTimeFormatAnnotationFormatterFactory
@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;
@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>

<!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>
메시지 컨버터(
HttpMessageConverter)에는Conversion Service가 적용되지 않는다.
특히 객체를 JSON으로 변환할 때Message Converter를 사용하면서 이 부분을 많이 오해하는데,
HttpMessageConverter의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다. 예를 들어서 JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용한다.
객체를 JSON으로 변환한다면 그 결과는 이 라이브러리에 달린 것이다. 따라서 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다. 결과적으로 이것은Conversion Service와 전혀 관계가 없다.
Conversion Service는@RequestParam,@ModelAttribute,@PathVariable,뷰 템플릿등에서 사용할 수 있다.