문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환해야 하는 것 처럼 애플리케이션을 개발하다 보면 타입을 변환해야 하는 경우가 상당히 많다.
@RequestParam
, @ModelAttribute
, @PathVariable
@Value
등으로 YML 정보 읽기String
-> Integer
로 변환해주었기 때문에 개발자는 편리하게 해당 타입을 바로 받을 수 있다. 문자를 숫자로, 숫자를 문자로, 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
를 사용하면 된다.@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
log.info("convert source={}", source);
return Integer.valueOf(source);
}
}
@Slf4j
public class IntegerToStringConverter implements Converter<Integer, String> {
@Override
public String convert(Integer source) {
log.info("convert source={}", source);
return String.valueOf(source);
}
}
@Test
void stringToInteger() {
StringToIntegerConverter converter = new StringToIntegerConverter();
Integer result = converter.convert("10");
assertThat(result).isEqualTo(10);
}
@Test
void IntegerToString() {
IntegerToStringConverter converter = new IntegerToStringConverter();
String result = converter.convert(10);
assertThat(result).isEqualTo("10");
}
@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private int port;
public IpPort(String ip, int port) {
this.ip = ip;
this.port = port;
}
}
127.0.0.1:8080
과 같은 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨터버를 만들어보자.@EqualsAndHashCode
를 넣으면 모든 필드를 사용해서 equals()
, hashCode()
를 생성한다. 따라서 모든 필드의 값이 같다면 a.equals(b)
의 결과가 참이 된다.@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);
}
}
@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();
}
}
@Test
void stringToIpPort() {
StringToIpPortConverter converter = new StringToIpPortConverter();
String source = "127.0.0.1:8080";
IpPort result = converter.convert(source);
assertThat(result).isEqualTo(new IpPort("127.0.0.1", 8080)); //@EqualsAndHashCode
}
@Test
void ipPortToString() {
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");
}
Converter
-> 기본 타입 컨버터ConverterFactory
-> 전체 클래스 계층 구조가 필요할 때GenericConverter
-> 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능ConditionalGenericConverter
-> 특정 조건이 참인 경우에만 실행Converter
, ConverterFactory
, GenericConverter
의 구현체를 찾아보면 수 많은 컨버터를 확인할 수 있다.타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것은 매우 불편하다. 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 바로 컨버전 서비스(
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);
}
public class ConversionServiceTest {
@Test
void conversionService() {
//등록
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter());
conversionService.addConverter(new IntegerToStringConverter());
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
//사용
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");
}
}
DefaultConversionService
는 ConversionService
인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공.StringToIntegerConverter
같은 타입 컨버터를 명확하게 알아야 한다. 반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다. 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다. 따라서 타입의 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다. 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.DefaultConversionService
는 다음 두 인터페이스를 구현했다.ConversionService
: 컨버터 사용에 초점ConverterRegistry
: 컨버터 등록에 초점ConversionService
만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다. 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다. 이렇게 인터페이스를 분리하는 것을 ISP
라 한다.@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("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
System.out.println("ipPort IP = " + ipPort.getIp());
System.out.println("ipPort PORT = " + ipPort.getPort());
return "ok";
}
//http://localhost:8080/ip-port?ipPort=127.0.0.1:8080 호출시 실행 로그
StringToIpPortConverter : convert source=127.0.0.1:8080
ipPort IP = 127.0.0.1
ipPort PORT = 8080
?ipPort=127.0.0.1:8080
쿼리 스트링이 @RequestParam IpPort ipPort
에서 객체 타입으로 잘@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";
}
}
<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>
//실행 결과
• ${number}: 10000
• ${{number}}: 10000
• ${ipPort}: hello.typeconverter.type.IpPort@59cb0946
• ${{ipPort}}: 127.0.0.1:8080
//실행 결과 로그
IntegerToStringConverter : convert source=10000
IpPortToStringConverter : convert
source=hello.typeconverter.type.IpPort@59cb0946
${{...}}
를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다. 물론 스프링과 통합 되어서 스프링이 제공하는 컨버전 서비스를 사용하므로, 우리가 등록한 컨버터들을 사용할 수 있다.${{number}}
: 뷰 템플릿은 데이터를 문자로 출력한다. 컨버터를 적용하게 되면 Integer
타입인 10000
을 String
타입으로 변환하는 컨버터인 IntegerToStringConverter
를 실행하게 된다. 이 부분은 컨버터를 실행하지 않아도 타임리프가 숫자를 문자로 자동으로 변환하기 때문에 컨버터를 적용할 때와 하지 않을 때가 같다.${{ipPort}}
: 뷰 템플릿은 데이터를 문자로 출력한다. 컨버터를 적용하게 되면 IpPort
타입을 String
타입으로 변환해야 하므로 IpPortToStringConveter
가 적용된다. 그 결과 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;
}
}
<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>
th:field
는 앞서 설명했듯이 id
, name
을 출력하는 등 다양한 기능이 있는데, 여기에 컨버전 서비스도 함께 적용된다.GET /converter/edit
th:field
가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}}
처럼 적용이 되었다. 따라서 IpPort
-> String
으로 변환된다.POST /converter/edit
@ModelAttribute
를 사용해서 String
-> IpPort
로 변환된다.
Conveter
는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다.
개발자 입장에서는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다. 앞서 살펴본 예제들을 떠올려 보면 문자를 다르 객체로 변환하거나 객체를 문자로 변환하는 일이 대부분이다.
Integer
-> String
출력 시점에 숫자 1000
-> 문자 "1,000"
이렇게 1000 단위에 쉼표를 넣어서 출력하거나, 또는 "1,000"
이라는 문자를 1000
이라는 숫자로 변경해야 한다."2023-07-22 19:45:05"
와 같이 출력하거나 또는 그 반대의 상황.Locale
현지화 정보가 사용될 수 있다.Formatter
)이다. 포맷터는 컨버터의 특별한 버전으로 이해하면 된다.Formatter
)는 객체를 문자로 변경하고, 문자를 객체로 변경하는 두 가지 기능을 모두 수행.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> {
}
String print(T object, Locale locale)
: 객체를 문자로 변경.T parse(String text, Locale locale)
: 문자를 객체로 변경.@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
정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.parse()
를 사용해서 문자를 숫자로 변환한다. 참고로 Number
타입은 Integer
, Long
과 같은 숫자 타입의 부모 클래스이다.print()
를 사용해서 객체를 문자로 변환한다.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");
}
}
//실행 결과 로그
MyNumberFormatter - text=1,000, locale=ko_KR
MyNumberFormatter - object=1000, locale=ko_KR
Formatter
: 포맷터AnnotationFormatterFactory
: 필드의 타입이나 애노테이션 정보를 활용할 수 있는 포맷터
- 컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수 는 없다. 그런데 생각해보면 포맷터는 객체 -> 문자, 문자 -> 객체로 변환하는 특별한 컨버터일 뿐이다.
- 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해서
Formatter
가Converter
처럼 동작하도록 지원한다.
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);
}
}
FormattingConversionService
는 포맷터를 지원하는 컨버전 서비스이다.DefaultFormattingConversionService
는 FormattingConversionService
에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공.DefaultFormattingConversionService
상속관계FormattingConversionService
는 ConversionService
관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다. 사용할 때는 ConversionService
가 제공하는 convert
를 사용하면 된다.DefaultFormattingConversionService
를 상속 받은 WebConversionService
를 내부에서 사용한다.@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());
}
}
//http://localhost:8080/converter-view
• ${number}: 10000
• ${{number}}: 10,000
//http://localhost:8080/hello-v2?data=10,000
//실행 로그
MyNumberFormatter : text=10,000, locale=ko_KR
data = 10000
StringToIntegerConverter
, IntegerToStringConverter
를 꼭 주석처리 하자. MyNumberFormatter
도 숫자 -> 문자, 문자 -> 숫자로 변경하기 때문에 둘의 기능이 겹친다. 우선순위는 컨버터가 우선하므로 포맷터가 적용되지 않고, 컨버터가 적용된다.
- 스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공.
- IDE에서
Formatter
인터페이스의 구현 클래스를 찾아보면 수 많은 날짜나 시간 관련 포맷터가 제공되는 것을 확인할 수 있다.- 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다. 스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 매우 유용한 포맷터 두 가지를 기본으로 제공한다. 공식 문서 참고.
@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";
}
// "10,000" -> 10000
// "2023-07-10 18:38:28" -> localDateTime
@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 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>
<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>
//실행 결과
• ${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
, 뷰 템플릿 등에서 사용할 수 있다.
좋은 글이네요. 공유해주셔서 감사합니다.