@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";
}
위의 코드를 보면 쿼리파라미터 data를 받아 Integer형으로 타입변환을 해주었다. 쿼리파라미터로 들어오는 값들은 모두 문자형이기 때문에 다른 타입으로 변환하기 위해선 위의 과정이 꼭 필요하다.
@RequestParam의 예제를 살펴보자.
@GetMapping("hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
위의 코드에서도 쿼리파라미터를 통해 data의 값을 받지만 타입변환이 필요없이 바로 Integer 형으로 받을 수 있었다. 이는 스프링이 중간에서 타입 변환을 해주었기 때문이다.
이러한 타입 변환은 @ModelAttribute와 @PathVariable에서도 나타난다.
스프링의 타입 변환 적용 예
확연히 코드로만 봐도 그냥 쿼리파라미터를 받아 타입 변환을 하는 것과 @RequestParam등 스프링이 타입변환을 해주는 것은 차이가 크다. 지금은 파라미터 값이 data하나 뿐이지만 만약에 파라미터의 개수도 더 늘어난다면 그만큼 타입변환도 해주어야하기 때문에 여간 불편한게 아니다.
이러한 스프링의 타입변환은 문자에서 숫자로만 가능한것이 아니다. 숫자를 문자로 변경하는 것도 가능하고, Boolean 타입을 숫자로 변경하는 것도 가능하다. 그렇다면 개발자가 원하는 새로운 타입을 만들어서 변환하고 싶다면 어떻게 하면 될까.
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
스프링은 개발자가 원하는 타입을 만들어서 변환할 수 있게 확장가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요한경우 이 인터페이스를 구현해서 등록하면 되며, 모든 타입에 적용 가능하다. 예를 들어 문자로 "true"가 오면 Boolean 타입으로 받고 싶다면 String -> Boolean 타입으로 변환되도록 컨버터 인터페이스를 만들어서 등록하고 반대로 적용하고 싶으면 Boolean -> String 으로 변환되도록 구현해서 등록하면 된다.
참고
과거에는 PropertyEditor 라는 것으로 타입을 변환했다. PropertyEditor 는 동시성 문제가 있어서 타입을 변환할 때 마다 객체를 계속 생성해야 하는 단점이 있다. 지금은 Converter 의 등장으로 해당 문제들이 해결되었고, 기능 확장이 필요하면 Converter 를 사용하면 된다.
타입 컨버터를 사용하려면 org.springframework.core.convert.converter.Converter
인터페이스를 구현하면 된다.
문자를 숫자로 변환하는 타입 컨버터를 먼저 만들어보자.
@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
log.info("convert source = {}", source);
return Integer.valueOf(source);
}
}
String 인 source를 Integer로 바꾸었다.
@Slf4j
public class IntegerToStringConverter implements Converter<Integer, String> {
@Override
public String convert(Integer source) {
log.info("convert source = {}", source);
return String.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;
}
}
위의 코드가 있다고 할 때 String에서 IpPort로, IpPort에서 String으로 변환하는 Converter를 아래에서 만들어보자.
IpPort -> String
@Slf4j
public class IpPortToStringConverter implements Converter<IpPort, String> {
@Override
public String convert(IpPort source) {
log.info("convert source = {}", source);
//IpPort 객체 -> "127.0.0.1:8080"
return source.getIp() + ":" + source.getPort();
}
}
String -> IpPort
":"을 기준으로 잘라서 각각 Ip와 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" -> IpPort 객체
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
이렇게 타입 컨버터를 하나하나 직접 사용하면, 개발자가 직접 컨버팅 하는 것과 큰 차이가 없다.
타입 컨버터를 등록하고 관리하면서 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요하다.
참고
스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.
참고
스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다. IDE에서 Converter , ConverterFactory , GenericConverter 의 구현체를 찾아보면 수 많은 컨버터를 확인할 수 있다.
스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 이것이 바로 컨버전 서비스( ConversionService )이다.
package org.springframework.core.convert;
import org.springframework.lang.Nullable;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);
}
컨버전 서비스 인터페이스에는 크게 두가지 기능이 있다.
1.canConvert
: 변환이 가능한지 확인하는 기능
2. convert
: 변환하는 기능
그렇다면 테스트 코드로 이 컨버전 서비스를 확인해보자.
public class ConversionServiceTest {
@Test
void conversionService(){
//등록
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter());
conversionService.addConverter(new IntegerToStringConverter());
conversionService.addConverter(new IpPortToStringConverter());
conversionService.addConverter(new StringToIpPortConverter());
//사용
Integer result = conversionService.convert("10", Integer.class);
System.out.println("result = " + result);
}
}
00:44:53.806 [main] INFO hello.typeconverter.converter.StringToIntegerConverter - convert source = 10
result = 10
위의 로그를 보면 StringToIntegerConverter가 실행된 것을 알 수 있다. 문자형인 "10"이 Integer.class로 변환을 시도했기 때문이다.
그리고 위의 코드를 보면 DefaultConversionService를 사용함으로써 conversion을 등록하고 사용하는 기능의 구분이 생겼다.
코드를 보면, 등록을 할 때 우리는 등록하는 컨버터가 어떤 역할을 하는지, 무슨 컨버터인지 알고 있다. 하지만 컨버터를 사용할 때는 실제 사용되는 컨버터가 무엇인지 숨겨져 있어 사용자는 타입 컨버터에 대해 전혀 몰라도된다.
따라서 타입을 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다. 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.
인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.
DefaultConversionService 는 다음 두 인터페이스를 구현했다.
ConversionService는 위에서도 봤듯이 canConvert
: 변환을 할 수 있는지 확인하는 기능과 convert
: 변환을 하는 기능으로 컨버터 사용에 초점을 두었다.
반면 ConverterRegistry는 addConverter
: 타입 컨버터 등록으로 컨버터 등록에 초점을 둔 것을 알 수 있다.
이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다. 특히 컨버터를 사용하는 클라이언트는 ConversionService 만의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다. 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다. 이렇게 인터페이스를 분리하는 것을 ISP 라 한다.
스프링은 내부에서 ConversionService 를 사용해서 타입을 변환한다. 예를 들어서 앞서 살펴본
@RequestParam 같은 곳에서 이 기능을 사용해서 타입을 변환한다.
먼저 등록을 해보자. 스프링은 ConversionService를 제공하기 때문에 WebMvcConfigurer를 implements해서 addFormatters를 사용하면 쉽게 등록할 수 있다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
//Formatter : 컨버터의 조금 더 확장된 기능
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
}
}
위와 같이 등록하면 스프링은 내부에서 사용하는 ConversionService에 컨버터를 추가한다.
@GetMapping("hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
위의 컨트롤러가 있다고 가정할 때 "/hello-v2?data=10"로 들어가게 된다면 10은 String형으로 전달된다. 컨트롤러가 호출되기 전에 data가 converter에 의해 String으로 변환된다.
로그를 확인하면 StringToIntegerConverter가 사용된 것을 확인할 수 있다. 하지만, 우리는 @RequestParam을 사용하면 스프링이 알아서 타입을 변환해준다는 사실을 알고 있다. 어떻게 된걸까. 스프링은 내부에 수만은 기본 컨버터들을 제공하는데, 컨버터를 추가하면 추가한 컨버터가 기본 컨버터 보다 높은 우선순위를 가지기 때문에 위에서는 우리가 추가한 컨버터인 StringToIntegerConverter가 사용된 것이다. 우리가 추가하지 않았다면 기본 컨버터가 실행되었을 것이다.
@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";
}
우리가 만든 ipPort도 실행해보자.
StringToIpPortConverter : convert source=127.0.0.1:8080
ipPort IP = 127.0.0.1
ipPort PORT = 8080
객체 타입으로 잘 변환된것을 확인할 수 있다.
@RequestParam 은 @RequestParam 을 처리하는 ArgumentResolver 인 RequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다.
@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";
}
model에 number와 ipPort 객체를 넣어 converter-view로 넘겨주자.
converter-view
<!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>${{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>
위의 코드를 보면 {{}}와 같이 괄호가 두번 씌어져있는 것이 보인다. 이렇게 괄호를 두번 사용하면 자동으로 conversion service를 사용해 변환된 결과를 출력해준다.
${number}: 10000
${{number}}: 10000
${ipPort}: hello.typeconverter.type.IpPort@59cb0946
${{ipPort}}: 127.0.0.1:8080
뷰는 위와 같이 보이는데, 로그는 아래와 같다.
2022-08-20 02:21:37.883 INFO 13032 --- [nio-8080-exec-1] h.t.converter.IntegerToStringConverter : convert source = 10000
2022-08-20 02:21:37.885 INFO 13032 --- [nio-8080-exec-1] h.t.converter.IpPortToStringConverter : convert source = hello.typeconverter.type.IpPort@59cb0946
우리가 추가한 컨버터가 두번 호출 된것을 확인할 수 있다.
위의 타임리프는 text로 출력하고 있기 때문에 number와 ipPort모두 string 형으로 변환되어야한다.
따라서 number는 IntegerToStringConverter가 실행되었다. number는 컨버터를 실행하지 않아도 타임 리프가 숫자를 문자로 자동 변환하기 때문에 ${number}와 ${{number}}의 값이 같게 나왔다.
ipPort 타입 또한 String형으로 변환되기 때문에 IpPortToStringConverter가 적용되었다. 그 결과로 127.0.0.1:8080가 출력된 것을 볼 수 있다.
@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;
}
}
컨트롤러가 위와 같이 작성되었다고 가정하자.
converter-form
<!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/>
<input type="submit"/>
</form>
</body>
</html>
폼은 위와 같다. 실행해보자.
http://localhost:8080/converter/edit
를 요청하면 위의 그림 처럼 실행 된다.
제출을 누르게 되면 아래와 같다.
${number}:
${{number}}:
${ipPort}: hello.typeconverter.type.IpPort@59cb0946
${{ipPort}}: 127.0.0.1:8080
여기서 주목해야할 점이 두가지 있는데, 먼저 form html에서 ${{ipPort}}를 사용하지 않았는데도 ${{ipPort}}를 사용한 것 처럼 converter가 적용되었다. 로그를 확인해보면 아래와 같다.
2022-08-20 02:42:00.038 INFO 5196 --- [nio-8080-exec-6] h.t.converter.IpPortToStringConverter : convert source = hello.typeconverter.type.IpPort@59cb0946
hello.typeconverter.type.IpPort@59cb0946
이 넘어와 IpPortToStringConverter
가 적용된 것이다. th:field 가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}} 처럼 적용이 되었다. 따라서 IpPort String 으로 변환된다.
다음 주목해야할 점은 submit을 눌렀을 때의 결과이다. 우리는 지금 form에서 converter가 적용되어 ipPort가 문자가 되었음을 확인했다. 아래의 로그를 확인해보면 source는 127.0.0.1:8080으로 문자로 넘어왔다. 하지만 컨트롤러의 @ModelAttribute 때문에 다시 IpPortConverter가 적용되어 ipPort 객체로 바뀐것을 확인할 수 있다. 또한 converter-view에서 타임리프는 text로 출력하기 때문에 String으로 다시 형변환되었다.
2022-08-20 02:44:05.188 INFO 5196 --- [nio-8080-exec-9] h.t.converter.StringToIpPortConverter : convert source = 127.0.0.1:8080
2022-08-20 02:44:05.188 INFO 5196 --- [nio-8080-exec-9] h.t.converter.StringToIpPortConverter : convert source = 127.0.0.1:8080
2022-08-20 02:44:05.191 INFO 5196 --- [nio-8080-exec-9] h.t.converter.IpPortToStringConverter : convert source = hello.typeconverter.type.IpPort@59cb0946
Converter 는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다.
이번에는 일반적인 웹 애플리케이션 환경을 생각해보자. 불린 타입을 숫자로 바꾸는 것 같은 범용 기능 보다는 개발자 입장에서는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.
앞서 살펴본 예제들을 떠올려 보면 문자를 다른 객체로 변환하거나 객체를 문자로 변환하는 일이
대부분이다.
예시로 화면에 숫자를 출력해야 하는데, Integer String 출력 시점에 숫자 1000 문자 "1,000" 이렇게 1000 단위에 쉼표를 넣어서 출력하거나, 또는 "1,000" 라는 문자를 1000 이라는 숫자로 변경해야 한다.
또는 날짜 객체를 문자인 "2021-01-01 10:50:11" 와 같이 출력하거나 또는 그 반대의 상황이 있다. 또한 날짜 숫자의 표현 방법은 나라마다 다를 수 있으므로 Locale 현지화 정보가 사용될 수 있다.
이렇게 객체를 특정한 포멧에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터( Formatter )이다. 포맷터는 컨버터의 특별한 버전으로 이해하면 된다.
포맷터( Formatter )는 객체를 문자로 변경하고, 문자를 객체로 변경하는 두 가지 기능을 모두 수행한다.
String print(T object, Locale locale)
: 객체를 문자로 변경한다. 객체를 String으로 변환해 출력한다.
T parse(String text, Locale locale)
: 문자를 객체로 변경한다. 문자를 parsing해서 객체로 변경한다.
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> {}
1000 단위로 쉼표가 들어가는 포맷을 적용해보자.
@Slf4j
public class MyNumberFormatter implements Formatter<Number> {
@Override
public Number parse(String text, Locale locale) throws ParseException {
log.info("text={}, locale={}", text, locale);
//"1,000" -> 1000
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 정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.
MyNumberFormatter - text=1,000, locale=ko_KR
MyNumberFormatter - object=1000, locale=ko_KR
컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수 는 없다. 그런데 생각해보면 포맷터는 객체 -> 문자, 문자 -> 객체로 변환하는 특별한 컨버터일 뿐이다.
포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해서 Formatter 가 Converter 처럼 동작하도록 지원한다.
FormattingConversionService 는 포맷터를 지원하는 컨버전 서비스이다.
DefaultFormattingConversionService 는 FormattingConversionService 에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다.
public class FormattingConversionServiceTest {
@Test
void formattingConversionService(){
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
//컨버터 등록
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
//formatter 등록
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));
//formatter 사용
assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
assertThat(conversionService.convert("1000", Long.class)).isEqualTo(1000L);
}
}
FormattingConversionService 는 ConversionService 관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다. 그리고 사용할 때는 ConversionService 가 제공하는 convert 를 사용하면 된다.
추가로 스프링 부트는 DefaultFormattingConversionService 를 상속 받은 WebConversionService 를 내부에서 사용한다.
가장 먼저 Formatter를 사용하기 위해 WebConfig에 등록한다.
registry.addFormatter(new MyNumberFormatter());
• ${number}: 10000
• ${{number}}: 10,000
컨버전 서비스를 적용한 결과 MyNumberFormatter 가 적용되어서 10,000 문자가 출력된 것을 확인할 수 있다.
실행 로그
MyNumberFormatter : text=10,000, locale=ko_KR
data = 10000
"10,000" 이라는 포맷팅 된 문자가 Integer 타입의 숫자 10000으로 정상 변환 된 것을 확인할 수 있다.
스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다.
IDE에서 Formatter 인터페이스의 구현 클래스를 찾아보면 수 많은 날짜나 시간 관련 포맷터가 제공되는 것을 확인할 수 있다.
그런데 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.
스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 매우 유용한 포맷터 두 가지를 기본으로 제공한다.
@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;
}
}
실행 결과
• ${form.number}: 10000
• ${{form.number}}: 10,000
• ${form.localDateTime}: 2021-01-01T00:00:00
• ${{form.localDateTime}}: 2021-01-01 00:00:00
주의!
메시지 컨버터( HttpMessageConverter )에는 컨버전 서비스가 적용되지 않는다.
특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 많이 오해하는데,
HttpMessageConverter 의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다. 예를 들어서 JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용한다. 객체를 JSON으로 변환한다면 그 결과는 이 라이브러리에 달린 것이다. 따라서 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다. 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없다.
컨버전 서비스는 @RequestParam , @ModelAttribute , @PathVariable , 뷰 템플릿 등에서 사용할 수 있다.