스프링에서 타입 컨버터(Type Converter)는 다양한 데이터 타입 간의 변환을 처리하기 위한 메커니즘이다. 주로 사용자 입력 데이터를 컨트롤러로 전달하거나, 데이터베이스에서 조회한 데이터를 객체로 매핑할 때 유용하게 사용된다.
@GetMapping("/hello-v2")
public String helloV2(@RequestParam("data") Integer data){
System.out.println("data = "+data);
return "ok";
}
해당 컨트롤러로 URL에 Http 쿼리 스트링으로 data에 10을 넣어 전송하면 문자가 전송되지만 중간에 스프링이 Int 타입으로 변환한다. (@ModelAttribute,@Pathvariable 도 마찬가지이다.)
만약 개발자가 새로운 타입을 만들어서 변환하고 싶으면 어떻게 하면 될까?
컨버터 인터페이스
public interface Converter<S, T> {
T convert(S source);
}
스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다. (org.springframework.core.convert.converter.Converter)
변환 예시
@Slf4j
public class StringToIntegerConverter implements Converter<String,Integer> {
//S는 파라미터,T는 반환값으로 재정의된다.
@Override
public Integer convert(String source) {
log.info("convert source={}",source);
return Integer.valueOf(source);
}
}
String에서 Integer로 변환하기 때문에 source가 String이 된다. 이 문자를 Integer.valueOf(source)를 사용해서 숫자로 변경한 다음에 변경된 숫자를 반환하면 된다.
127.0.0.1:8080과 같은 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨버터를 만들어보자.
IpPort 클래스
@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private int port;
public IpPort(String ip, int port) {
this.ip = ip;
this.port = port;
}
}
@EqualsAndHashCode: 객체의 특정 필드를 기반으로 두 객체가 같은지 비교하고 클래스의 모든 필드를 기준으로 해시 코드를 생성한다.
입력시 IpPort 클래스로 변환
@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);
}
}
입력시 IpPort 클래스를 String으로 변환
@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
void IpPortToString(){
IpPortToStringConverter converter = new IpPortToStringConverter();
String result = converter.convert(new IpPort("127.0.0.1", 8080));
assertThat(result).isEqualTo("127.0.0.1:8080");
}
@Test
void StringToIpPort(){
StringToIpPortConverter converter = new StringToIpPortConverter();
IpPort result = converter.convert("127.0.0.1:8080");
assertThat(result).isEqualTo(new IpPort("127.0.0.1",8080));
}
기존에는 Converter<String, Integer>와 같은 인터페이스를 구현하여 타입 변환기를 하나하나 직접 작성하고 이를 찾아 사용하는 방식이었다. 그러나 이 방법은 많은 타입 변환이 필요한 경우 관리와 사용이 매우 번거롭다.
이를 개선하기 위해 스프링은 개별 변환기를 모아 하나의 단위로 묶어 관리하고, 이를 통해 다양한 타입 변환을 보다 편리하게 처리할 수 있는 컨버전 서비스(ConversionService)를 제공한다.
Conversion Service는 다양한 변환 로직을 일관되게 관리하고 사용할 수 있도록 설계된 스프링의 강력한 타입 변환 프레임워크이다.
Conversion Service 테스트
@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 result=conversionService.convert("127.0.0.1:8080", IpPort.class);
assertThat(result).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를 통해서 Converter들을 등록해놓고 데이터와 반환 타입만 넣으면 된다.
인터페이스 분리 원칙 - ISP(Interface Segregation Principle)
자신에게 불필요한 메서드를 강제로 사용하거나 의존하지 않도록 인터페이스를 나누는 것이다. 즉, 필요한 기능만 담긴 작은 인터페이스를 여러 개로 나누는 것이다.
DefaultConversionService는 다음 두가지 인터페이스를 구현했다.
컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다. 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알면된다. 이렇게 인터페이스를 분리하는 것을 ISP라 한다.
Converter 등록 - WebConfig
@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());
}
}
addFormatters() 를 사용해서 추가하고 싶은 컨버터를 등록하면 된다. 이렇게 하면 스프링은 내부에서 사용하는 ConversionService에 컨버터를 추가해준다.
StringToIntegerConverter를 등록하기 전에 수많은 기본 컨버터가 스프링 내부에서 제공한다. 하지만 사용자 정의 컨버터를 추가하면 우선순위를 높게 가진다.
IpPort 변환 컨트롤러
@GetMapping("/ip-port")
public String helloV3(@RequestParam IpPort ipPort){
System.out.println("ipPort IP = " + ipPort.getIp());
System.out.println("ipPort Port = " + ipPort.getPort());
return "ok";
}
실행 결과
Thymeleaf와 같은 뷰 템플릿 엔진은 데이터를 렌더링할 때 컨버터를 간편하게 사용할 수 있도록 지원한다.
뷰 컨버터 적용 컨트롤러
@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";
}
}
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>${{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>
괄호 두개는 컨버터 적용,하나는 적용하지 않는것이다.
결과 화면
뷰 템플릿은 데이터를 문자로 출력한다.
결과를 보면 컨버터를 적용하지 않으면 ipPort.toString()이 그대로 출력되고 적용시 등록한 컨버터로 인해 변환된 String("127.0.0.1:8080")이 출력된것을 볼수 있다.
${...}${{...}}또한 th:field는 받은 객체를 문자열로,@ModelAttribute는 받은 문자열을 필드에 객체가 있다면 객체로 Converter를 자동으로 적용해준다.
1000을 1,000 으로 변환해야 하는 경우2024-12-03 10:50:11와 같이 출력하거나 이 형태를 객체로 변환해야 하는 경우이렇게 객체를 특정한 포멧에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터(Formatter)이다. 포맷터는 컨버터의 특별한 버전으로 이해하면 된다.
포맷터(Formatter)는 문자에 특화된 Converter의 특별한 버전이다.
String print(T object, Locale locale): 객체를 문자로 변경한다. T parse(String text, Locale locale): 문자를 객체로 변경한다.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> {
}
숫자 객체를 포맷팅 해보자
MyNumberFormatter
@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);
}
}
1.parse: 포맷팅된 문자를 string 으로 변환 ( 1,000 -> 1000 )
2.print: 숫자 객체 -> 고객에게 포맷팅된 문자열로 출력 (1000->1,000)
DefaultFormattingConversionService는 기본적으로 통화, 숫자 등과 관련된 몇 가지 기본 포맷터를 추가로 제공하는 클래스이다. 이 클래스는 포맷터와 컨버터의 기능을 모두 지원하며, 데이터의 변환과 포맷팅을 쉽게 처리할 수 있도록 설계되었다.
FormattingConversionService는 ConversionService의 기능을 상속받기 때문에 컨버터(Converter)와 포맷터(Formatter)를 모두 등록하고 사용할 수 있다. 결과적으로 데이터를 변환하거나 포맷팅하는 모든 작업을 이 서비스에서 처리할 수 있다.
DefaultFormattingConversionService 테스트
public class FormattingConversionServiceTest {
@Test
void formattingConversionService(){
DefaultFormattingConversionService conversionService=new DefaultFormattingConversionService();
//컨버터 등록
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
//포맷터 등록
conversionService.addFormatter(new MyNumberFormatter());
//컨버터 사용
assertThat(conversionService.convert("127.0.0.1:8080", IpPort.class)).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(1000);
}
}
직접 만든 포맷터를 스프링에 등록해보자.
Webconfig
@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());
}
}
같은 타입으로 변환하는 컨버터,포맷터 동시 등록시 컨버터 우선으로StringToIntegerConverter(),IntegerToStringConverter()에 대한 등록은 주석처리 하였다. 이후 기존 converter-view를 호출하면 number가 다음과 같이 변환된다.
스프링은 자바에서 기본으로 제공하는 타입들에 대해 다양한 포맷터를 기본으로 제공한다. 특히 날짜, 시간, 숫자와 같은 데이터 타입에 대해 Formatter 인터페이스를 구현한 여러 클래스가 제공되며, 이를 통해 데이터를 원하는 형식으로 변환하거나 포맷팅할 수 있다.
한계점
객체의 각 필드마다 다른 형식으로 포맷팅이 필요한 경우에는 유연성이 떨어진다.
스프링은 이 문제를 해결하기 위해 애노테이션 기반 포맷터를 제공한다. 이 방식은 객체의 필드마다 다른 형식을 지정할 수 있도록 하여, 더욱 세밀하고 유연한 포맷팅을 가능하게 한다.
@Data
static class Form{
@NumberFormat(pattern = "###,###")
private Integer Number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDataTime;
}
위와 같이 애노테이션 괄호 안에 원하는 패턴을 지정하고 객체 생성과 필드를 setting 하면 자동으로 포맷팅이 적용된다.
포맷팅 컨트롤러
@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";
}
GET 실행 결과
성공적으로 객체의 값이 포맷팅되어 출력이 된것을 볼수있다.
POST 실행 결과
제출된 String타입의 필드들이 애노테이션에 의해 객체 필드로 포맷팅되어 뷰에 출력된다.
주의! : 메시지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다. JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용하기 때문에 포맷 지정은 해당 라이브러리에서 해줘야한다.
결론: 컨버젼 서비스는 @RequestParam, @ModelAttribute, @PathVariable , 뷰 템플릿 등에서 사용할 수 있다.