스프링 타입 컨버터
문자를 숫자로 변환하거나, 숫자를 문자로 변환해야하는 것처럼 애플리케이션에서 개발하다보면 타입을 변환해야 하는 경우가 상당히 많다. 이 때 스프링 타입 컨버터를 사용한다.
프로젝트 생성
- 다음과 같이 spring.io 에서 프로젝트를 생성하자.
스프링 타입 컨버터 소개
package hello.typeconverter.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
public class HelloController {
@GetMapping("/hello-v1")
public String helloV1(HttpServletRequest request) {
String data = request.getParameter("data"); // 문자 타입
int intValue = Integer.parseInt(data); // 숫자 타입으로 변경
System.out.println("intValue = " + intValue);
return "ok";
}
}
- HTTP 요청 파라미터는 모두 문자로 처리된다. 따라서 다음과 같이 다른 타입으로 변환하고 싶으면 숫자 타입으로 변환하는 과정을 거쳐야 한다.
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
- 스프링 MVC가 제공하는
@RequestParam
을 사용하면 오류 없이 정상 출력되는 것을 볼 수 있다. 이것은 스프링이 중간에서 타입을 변환해주었기 때문이다. 이러한 예는@ModelAttribute
,@PathVariable
에서도 볼 수 있다.
컨버터 인터페이스
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
- 스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 즉, 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다.
타입 컨버터 - Converter
타입 컨버터를 사용하려면 반드시 org.springframework.core.convert.converter.Converter
인터페이스를 구현해야 한다.
package hello.typeconverter.converter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
@Slf4j
public class IntegerToStringConverter implements Converter<Integer, String> {
@Override
public String convert(Integer source) {
log.info("convert source={}", source);
return String.valueOf(source);
}
}
- 다음과 같이 원하는 유형의 컨버터를 구현체로 만들 수 있다.
package hello.typeconverter.type;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private int port;
public IpPort(String ip, int port) {
this.ip = ip;
this.port = port;
}
}
- 다음 객체를 생성해서 조금 더 실무에 와닿는 예제를 진행해보자.
package hello.typeconverter.converter;
import hello.typeconverter.type.IpPort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
@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);
}
}
127.0.0.1:8080
과 같은 문자를 입력하면IpPort
객체를 반환하는 컨버터를 생성해보자.
package hello.typeconverter.converter;
import hello.typeconverter.type.IpPort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
@Slf4j
public class IpPortToStringConverter implements Converter<IpPort, String> {
@Override
public String convert(IpPort source) {
log.info("convert source={}", source);
return source.getIp() + ":" + source.getPort();
}
}
IpPort
를 반대로 변환하는 컨버터를 생성해보자.
@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));
}
@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");
}
- 테스트 코드를 실행해보면 정상 동작하는 것을 볼 수 있다.
@EqualsAndHashCode
를 엔티티에서 적용시켰기 때문에 객체 참조값은 달라도 그 안의 값이 일치하는 지 비교할 수 있다.
ConversionService
타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것은 매우 불편하다. 그래서 컨버전서비스를 통해 개별 컨버터를 모아두고 그것들을 묶어서 사용할 수 있는 기능을 제공한다.
package hello.typeconverter.converter;
import hello.typeconverter.type.IpPort;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.core.convert.support.DefaultConversionService;
import static org.assertj.core.api.Assertions.*;
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
인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공한다.
등록과 사용 분리
컨버터를 사용하는 입장에서 타입 컨버터를 전혀 몰라도 된다. 따라서 타입 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다. 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.
DefaultConversionService
는 컨버터 등록 용인 ConverterRegistry
, 컨버터 사용 용인 ConversionService
두 인터페이스를 구현체로 갖고 있다. 즉. 이렇게 인터페이스를 분리하는 것을 SOLID 5원칙 중 ISP(인터페이스 분리 원칙) 라고 한다.
스프링은 내부에서 ConversionService를 사용해서 타입을 변환한다. 즉, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다. @RequestParam같은 곳에서 이 기능을 사용해서 타입을 변환한다.
스프링에 Converter 적용하기
package hello.typeconverter;
import hello.typeconverter.converter.IntegerToStringConverter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
}
}
- 스프링 내부에서
ConversionService
를 제공하기 때문에WebMvcConfigurer
가 제공하는addFormatters()
에 컨버터를 추가하여 등록하면 된다.
뷰 템플릿에 컨버터 적용하기
package hello.typeconverter.controller;
import hello.typeconverter.type.IpPort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@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>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>
${{...}}
은 타임리프에서 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다.
- 다음과 같이 결과가 반환된 것을 볼 수 있다. 이 때 객체는 자동으로 문자로 출력되는 것을 확인할 수 있다.
폼에 적용하기
package hello.typeconverter.controller;
import hello.typeconverter.type.IpPort;
import lombok.Data;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@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.html";
}
@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;
}
}
}
- 다음과 같이 폼 적용을 위한 메서드들을 생성하자.
@ModelAttribute
는 뒤에 객체 타입대로 매핑해서 만들어주는 어노테이션이다.
<!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>
- 폼 생성 화면이다. 여기서
th:field
는${{...}}
와 같이 컨버터 기능을 제공해준다.
포맷터 - Formatter
Converter
는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다. 웹 애플리케이션 환경을 생각해보자.
객체를 특별한 포맷에 맞추어 문자로 출력하거나, 그 반대의 역할을 하는 기능이 필요할 것이다. 그 기능이 바로 Formatter
이다.
Converter
는 범용(객체-객체), Formatter
는 문자에 특화(객체-문자, 문자-객체) + 현지화(Locale)
Formmater 인터페이스
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
package hello.typeconverter.formatter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.Formatter;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;
@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,000
처럼NumberFormat
객체를 사용하면 적용시킬 수 있다. 이 객체는Locale
정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.parse()
를 사용해서 문자를 숫자로 변환한다.print()
를 사용해서 객체를 문자로 변환한다.
package hello.typeconverter.formatter;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import java.text.ParseException;
import java.util.Locale;
import static org.assertj.core.api.Assertions.*;
class MyNumberFormatterTest {
MyNumberFormatter formatter = new MyNumberFormatter();
@Test
void parse() throws ParseException {
Number result = formatter.parse("1,000", Locale.KOREA);
assertThat(result).isEqualTo(1000L);
}
@Test
void print() {
String result = formatter.print(1000, Locale.KOREA);
assertThat(result).isEqualTo("1,000");
}
}
- 다음과 같이
parse()
와print()
가 정상 동작하는 것을 테스트 코드로 확인할 수 있다.
포맷터를 지원하는 컨버전 서비스
컨버전 서비스에는 컨버터만 등록할 수 있기 때문에 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해서 Formatter
가 Converter
처럼 동작하도록 지원한다.
package hello.typeconverter.formatter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import hello.typeconverter.type.IpPort;
import org.junit.jupiter.api.Test;
import org.springframework.format.support.DefaultFormattingConversionService;
import static org.assertj.core.api.Assertions.*;
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
는ConversionService
관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다. 그리고 사용할 때는ConversionService
가 제공하는convert
를 사용하면 된다.- 추가로 스프링 부트는
DefaultFormattingConversionService
를 상속 받은WebConversionService
를 내부에서 사용한다.
포맷터 등록
package hello.typeconverter;
import hello.typeconverter.converter.IntegerToStringConverter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIntegerConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import hello.typeconverter.formatter.MyNumberFormatter;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@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());
}
}
- 컨버터와 포맷터의 기능이 겹치는 경우 컨버터가 높은 우선 순위를 갖고 있기 때문에 주석처리가 필요하다.
스프링이 제공하는 기본 포맷터
포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기 어렵다.
스프링은 이 문제를 해결하기 위해 어노테이션 기반으로 @NumberFormat
, @DateTimeFormat
를 제공한다.
FormatterController
package hello.typeconverter.controller;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.NumberFormat;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import java.time.LocalDateTime;
@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;
}
}
- 다음과 같이 컨트롤러를 생성하자.
formatter-form.html
<!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>
formatter-view.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의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나, 객체를 HTTP 메시지 바디에 입력하는 것이다. 즉, 메시지 컨버터는 Jackson같은 라이브러리를 사용하는 것이기 때문에 컨버전 서비스와 관계가 없다.
컨버전 서비스는
@RequestParam
,@ModelAttribute
,@PathVariable
, 뷰 템플릿 등에서 사용할 수 있다.
본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.