문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환해야 하는 것 처럼 애플리케이션을 개발하다보면 타입을 변환해야 하는 경우가 상당히 많다.
1
2
3
4
5
6
7
8
9
10
11
@RestController
public class HelloController {
@GetMapping("/hello-v1")
public String helloV1(HttpServletRequest request) {
String data = request.getParameter("data");
Integer intValue = Integer.valueOf(data);
return "ok";
}
}
위와 같이 HTTP 요청 파라미터는 모두 String으로 처리 되기때문에 요청 파라미터를 자바에서 다른 타입으로 변환해 사용하고 싶다면 변환하는 과정을 거쳐야 한다.
@ReqeustParam의 경우를 보자.
1
2
3
4
5
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println(data);
return "ok";
}
@ReqeustParam (요청 파라미터 값, 쿼리 파라미터 가져오기) 을 이용하면 바로 Integer로 받을 수 있다.
이유는 스프링이 중간에서 타입을 변환해주었기 때문이다.
이러한 부분은 @ModelAttribute, @PathVariable (URL 경로 일부 가져오기) 에도 당연히 적용되는 얘기이다.
- 스프링 MVC 요청 파라미터
- @RequestParam, @ModelAttribute, @PathVariable
- @Value 등으로 YML 정보 읽기
- XML에 넣은 스프링 빈 정보를 변환
- 뷰를 렌더링.
위의 모든 것이 타입 변환이 적용되는 예시인 것이다.
핵심
그래서 이렇게 자동으로 해주는데 우리는 어떤 부분에 집중해야 할까.
우리가 만약 새로운 타입을 만들어서 변환하고 싶을때 어떻게 해야하는가가 중요하다.
스프링은 확장 가능한 컨버터 인터페이스를 제공한다.
1
2
3
public interface Converter<S, T> {\
T convert(S source);
}
개발자는 추가적인 타입 변환이 필요하다면 컨버터 인터페이스를 구현해 등록하면 된다.
컨버터 인터페이스는 모든 타입에 적용할 수 있다. 필요하면 X -> Y 타입으로 변환하는 컨버터 인터페이스를 만들고 또 Y->X 타입으로 변환하는 컨버터 인터페이스를 하나 더 만들어 등록하면 된다.
참고
과거에는 PropertyEditor 라는 것으로 타입을 변환했는데 동시성 문제가 있어 타입을 변환할 때 마다 객체를 계속 생성해야하는 단점이 있었다. 지금은 이런 문제가 Converter를 통해 해결되었고, 이를 사용하면 된다.
타입 컨버터 - Converter
타입 컨버터를 사용하려면 org.springframework.core.convert.converter.Converter 인터페이스를 구현하면 된다.
Converter라는 이름의 인터페이스가 많아 조심해야 한다.
StringToIntegerConverter 예시
1
2
3
4
5
6
7
8
@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
log.info("convert source={}", source);
return Integer.valueOf(source);
}
}
아주 간단하다. String -> Integer로 변환하기 때문에 source가 String이 되는 것이고 이 문자를 Integer.valueOf(source)를 사용해 숫자로 변경 후 반환하면 된다.
반대의 경우에는 source가 Integer이 되고 반환값이 String이 될 뿐이다.
사용자 정의 타입
1
2
3
4
5
6
7
8
9
10
11
@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private int port;
public IpPort(String ip, int port) {
this.ip = ip;
this.port = port;
}
}
IpPort라는 타입을 하나 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@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);
}
}
String으로 들어오는 IP주소를 “127.0.0.1” 의 ip와 8080 (int) 의 포트를 가지고 있는 객체를 반환시키는 것이다.
1
2
3
4
5
6
7
@Test
void ipPortToString() {
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를 IpPort를 만들 때 붙여주었는데, 넣게되면 모든 필드를 사용해 equals(), hashcode()를 생성한다.
따라서 모든 필드의 값이 같다면 a.equals(b)의 결과가 참이 되어 위의 Test 코드에서도 저렇게 사용할 수 있던 것이다.
참고 2
스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.
- Converter : 기본 타입 컨버터
- ConverterFactory : 전체 클래스 계층 구조가 필요할 때
- GenericConverter : 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
- ConditionalGenericConverter : 특정 조건이 참인 경우 실행
그리고 스프링은 문자, 숫자, boolean, Enum 등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다.
ConversionService
위에서처럼 타입 컨버터를 하나하나 직접 찾아 타입 변환에 사용하는 것은 매우매우 불편할 것이다.
그래서 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편하게 사용할 수 있도록 기능을 제공한다. 이것이 ConversionService 이다.
- 인터페이스
1
2
3
4
5
6
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
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void conversionService() {
//등록
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter());
conversionService.addConverter(new IpPortToStringConverter());
conversionService.addConverter(new IntegerToStringConverter());
//사용
Integer result = conversionService.convert("10", Integer.class);
IpPort ipPort = conversionService.convert("127.0.0.1:8080, IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
}
ConversionService에 Converter를 등록하고 사용하고 싶다면 필요한 부분에서 ConversionService를 주입받아 사용하면 되는 것이다.
등록과 사용의 분리
컨버터를 등록할 때는 StringToIntegerConverter같은 타입 컨버터를 명확하게 알아야 한다.
하지만 사용하는 입장에서는 타입 컨버터가 뭔지는 몰라도 되고 컨버전 서비스 인터페이스에만 의존하여 사용하기만 하면 된다.
ISP - 인터페이스 분리 원칙
클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.
DefaultConversionService는 아래의 두 가지 인터페이스를 구현했다.
- ConversionService : 컨버터 사용에 초점.
- ConversionRegistry : 컨버터 등록에 초점.
이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다.
특히 컨버터를 사용하는 클라이언트는 ConversionService에만 의존하면 되어 컨버터를 어떻게 등록하고 관리하는지 몰라도 되는 것이다.
스프링은 내부에서 이 ConversionService를 사용해 타입을 변환한다. (@RequestParam 등등)
스프링에서의 Converter
웹 애플리케이션에 위에서 만들었던 Converter를 적용해본다.
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new IpPortToStringConverter());
registry.addConverter(new StringToIpPortConverter());
}
}
등록하고 @RequestParam을 통해 숫자를 받아보면 직접 만든 StringToIntegerConverter 가 사용되면서 정상작동 된다. 기본 컨버터보다 만들어 등록한 컨버터가 우선순위에 있음을 알 수 있다.
하지만 이 부분은 원래도 지원되던 부분이므로 IpPort를 사용해보자.
1
2
3
4
5
6
@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를 사용해 타입을 변환한다.
WebConfig에서의 등록은 FormatterRegistry 에서 따로 해주고 있다.
뷰 템플릿에 컨버터 적용
타임리프는 렌더링 시 컨버터를 적용해 렌더링 하는 방법을 제공한다.
이전까지는 문자를 객체로 변환했다면, 이제는 그 반대로 객체를 문자로 변환하는 작업을 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
@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";
}
}
위와 같이 일반 숫자와 IpPort 객체를 모델에 넣어주고 뷰를 반환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!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>$: <span th:text="$" ></span></li>
<li>${ipPort}: <span th:text="${ipPort}" ></span></li>
<li>$: <span th:text="$" ></span></li>
</ul>
</body>
</html>
보면 중괄호를 두 번 쓴 부분이 있고 아닌 부분이 있다.
두 번 쓰면 컨버터를 이용하는 부분인 것이다. 결과를 보자.
우선 숫자를 보면 중괄호를 한 번 사용한 부분과 두 번 쓴 부분이 결과가 같다.
실제로 둘 다 String이다. 뷰 템플릿은 원래도 데이터를 문자로 출력해 Integer 타입을 String으로 변환하는게 맞지만, 중괄호를 두 번 사용한 부분은 사실 만들어둔 IntegerToStringConverter가 사용된 것이다.
그리고 ipPort 부분을 보자. 중괄호를 한 번 사용한 부분은 객체를 String으로 변환한 것이 나타나고, 중괄호를 두 번 사용한 부분은 컨버터가 작동해 우리가 원하는 String 형태로 바뀌어 나타났다.
예시 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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";
}
@Data
static class Form {
private IpPort ipPort;
public Form(IpPort ipPort) {
this.ipPort = ipPort;
}
}
Form이라는 객체에 IpPort를 담아 보내는 메서드를 컨트롤러에 하나 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!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 가 자동으로 컨버전 서비스를 적용해주어 중괄호를 두 번 쓴 것처럼 적용됐다. (IpPort -> String)
- th:value 를 사용하면 컨버전 서비스 적용 X
포맷터 - Formatter
Converter는 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다.
일반적인 웹 애플리케이션 환경에서는 불린 타입을 숫자로 바꾸는 것과 같은 범용 기능 보다는 개발자 입장에서 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.
웹 애플리케이션에서 객체를 문자로, 문자를 객체로 변환하는 예시
- 화면에 숫자를 출력해야 할 때 Integer -> String 출력 시점에 숫자 1000을 문자 “1,000” 이렇게 쉼표를 넣어 출력하거나 또는 “1,000” 이라는 문자를 1000이라는 숫자로 변경해야 한다.
- 날짜 객체를 문자인 “2023-10-03 10:10:11” 과 같이 출력하거나 또는 그 반대의 상황
여기에 추가로 날짜 숫자 표현 방법은 ‘Locale’ 현지화 정보가 사용될 수 있다.
이렇게 객체를 특정한 포맷에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 것이 바로 포맷터이다.
Formatter 만들기
- 인터페이스
1
2
3
4
5
6
7
8
9
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을 문자 “1,000”으로 그리고 그 반대도 처리하는 포맷터를 만들어 본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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);
}
}
중간 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용하면 된다.\
그리고 Number 타입은 Integer, Long과 같은 숫자 타입의 부모 클래스이다.
- parse() : 문자를 숫자로 변환하는 메서드.
- print() : 객체를 문자로 변환.
포맷터를 지원하는 컨버전 서비스
포맷터도 컨버터처럼 컨버전 서비스에 모아두고 관리하면 편할 것이다.
하지만 컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수 없다. 생각해보면 포맷터는 객체 -> 문자, 문자 -> 객체를 변환해주는 특별한 컨버터 일 뿐인데 불합리하다.
그래서 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해 Formatter가 Converter 처럼 동작하도록 지원한다.
FormattingConversionService가 그것이다. DefaultFormattingConversionService는 기본적인 통화, 숫자 관련 몇 가지 기본 포맷터를 추가해 제공한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void conversionService() {
DefaultFormattingConversionService conversionService2 = new DefaultFormattingConversionService();
conversionService2.addConverter(new StringToIpPortConverter());
conversionService2.addConverter(new IpPortToStringConverter());
conversionService2.addFormatter(new MyNumberFormatter());
//사용
IpPort ipPort = conversionService2.convert("127.0.0.1:8080", IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
assertThat(conversionService2.convert(1000, String.class)).isEqualTo("1,000");
}
FormattingConversionService는 ConversionService의 기능을 상속받기 때문에 컨버터도 포맷터도 모두 등록하고 사용할 수 있다.
사용할 때는 ConversionService가 제공하는 convert를 사용하면 된다.
추가로 스프링부트는 DefaultFormattingConversionService를 상속받은 WebConversionService 를 내부에서 사용한다.
스프링에서의 포맷터
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// registry.addConverter(new StringToIntegerConverter());
// registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new IpPortToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addFormatter(new MyNumberFormatter());
}
}
주석처리하는 이유는 MyNumberFormatter도 숫자 -> 문자, 문자 -> 숫자 이기 때문에 둘의 기능이 겹치는데, 우선순위가 포맷터보다 컨버터가 우선순위이기 때문이다.
위와 같은 결과를 볼 수 있다.
스프링이 제공하는 기본 포맷터
스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다.
그런데 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.
스프링은 이러한 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해 사용할 수 있는 매우 유용한 포맷터 두 가지를 제공한다.
- @NumberFormat: 숫자 관련 형식 지정 포맷터 사용
- NumberFormatAnnotationFormatterFactory
- @DateTimeFormat: 날짜 관련 형식 지정 포맷터 사용
- Jsr310DateTimeFormatAnnotationFormatterFactory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@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;
}
}
1
2
3
4
5
<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>
1
2
3
4
5
6
<ul>
<li>${form.number}: <span th:text="${form.number}" ></span></li>
<li>$: <span th:text="$" ></span></li>
<li>${form.localDateTime}: <span th:text="${form.localDateTime}" ></span></ li>
<li>$: <span th:text="$" ></span></li>
</ul>
당연히 이러한 부분은 WebConfig에 Formatter나 Converter를 등록 안해주어도 된다.
10000이 아닌 10,000을 form으로 제출할 때 10000으로 변환이 되는 것도 지원이 되는 것을 볼 수 있다. (@ModelAttribute 덕분)
@NumberFormat, @DateTimeFormat의 자세한 사용법
주의
메시지 컨버터에는 컨버전 서비스가 적용되지 않는다.
특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 오해하게 되는데 HttpMessageConverter의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다.
JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶다면 해당 라이브러리가 제공하는 설정을 통해 포맷을 지정해야 한다. (ex - Jackson)
결과적으로는 메시지 컨버터는 컨버전 서비스와 전혀 관계가 없다.
컨버전 서비스는 @RequestParam, @ModelAttribute, @PathVariable, 뷰 템플릿 등에서 사용할 수 있다.