[인프런/스프링 MVC 2편] 10. 스프링 타입 컨버터
1. 스프링 타입 컨버터
1-1. 스프링의 자동 타입 변환
1) 스프링의 타입 변환 적용 예시
- 스프링 MVC 요청 파라미터 (@RequestParam, @ModelAttribute, @PathVariable)
- @Value 등으로 YML 정보 읽기
- XML에 넣은 스프링 빈 정보 변환
- 뷰 렌더링 할 때
2) 스프링 MVC 요청 파라미터 타입 변환 예시
- HTTP 요청 파라미터는 모두 문자로 들어옴
(1) @RequestParam
원래
-> 문자로 받아서 -> Integer로 타입 변환 (수동)
@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";
}
스프링 MVC가 제공하는 @RequestParam 사용
-> 문자를 Integer로 변환 (자동)
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
(2) @ModelAttribute
@ModelAttribute UserData data
class UserData {
Integer data;
}
(3) @PathVariable
/users/{userId}
@PathVariable("userId") Integer data
1-2. 타입 컨버터 - Converter 직접 사용하기
타입 컨버터 사용법 : 컨버터 인터페이스 구현 (org.springframework.core.convert.converter.Converter)
1) 컨버터 인터페이스
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
2) 문자 -> 숫자 변환 (StringToIntegerConverter)
- String을 source로 받아서
- Integer.valueOf(source) -> 숫자로 변환
package hello.typeconverter.converter;
import lombok.extern.slf4j.Slf4j;
import 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);
}
}
3) 숫자 -> 문자 변환 (IntegerToStringConverter)
- Integer를 source로 받아서
- String.valueOf(source) -> 문자로 변환
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("converter source={}", source);
return String.valueOf(source);
}
}
--------- 사용자 정의 타입 컨버터 ---------
4) (사용자 정의 컨버터) IpPort 객체 생성 - Ip, port 입력하면 IpPort 객체로 변환
- @EqualsAndHashCode
- 모든 필드를 사용해서 equals(), hashcode() 생성
- 모든 필드의 값이 같으면 a.equals(b)의 결과가 참
- 객체의 참조값이 달라도 값이 같으면 같은 객체로 판단 (new IpPort 객체를 equals로 비교할 때)
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;
}
}
5) (사용자 정의 컨버터) 문자 -> IpPort 객체 변환
- "127.0.0.1:8080" 문자 -> IpPort("127.0.0.1", 8080) 객체 반환
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);
// source = "127.0.0.1:8080" -> IpPort 객체
String[] split = source.split(":");
// ip = 127.0.0.1
String ip = split[0];
// port = 8080
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
6) (사용자 정의 컨버터) IpPort 객체 -> 문자 변환
- IpPort 객체 -> "127.0.0.1:8080" 반환
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);
// IpPort 객체 -> "127.0.0.1:8080"
return source.getIp() + ":" + source.getPort();
}
}
7) 타입 컨버터 동작 테스트
package hello.typeconverter.converter;
import hello.typeconverter.type.IpPort;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
public class ConverterTest {
@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");
}
@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));
}
@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");
}
}
- -> 타입 컨버터 직접 new로 생성하고, conver()로 하나하나 직접 사용
📌 참고
valueOf() : Integer 타입으로 반환
parseInt() : int 타입으로 반환
1-3. 컨버전 서비스 - ConversionService
ConversionService
: 타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용 X
개별 컨버터를 한 곳에 모아두고 편리하게 사용
1) ConversionService 인터페이스
- canConvert() : 컨버팅이 가능한지 확인
- convert() : 컨버팅 기능
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);
}
2) 컨버전 서비스 테스트 (사용방법 테스트)
package hello.typeconverter.converter;
import hello.typeconverter.type.IpPort;
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");
assertThat(conversionService.convert("127.0.0.1:8080", IpPort.class)).isEqualTo(new IpPort("127.0.0.1", 8080));
assertThat(conversionService.convert(new IpPort("127.0.0.1", 8080), String.class)).isEqualTo("127.0.0.1:8080");
}
}
(1) DefaultConversionService
- ConversionService 인터페이스 구현
- 컨버터 등록
- addConverter(new 컨버터)
- 등록할 때는 타입 컨버터를 명확하게 알아야 함
- 컨버터 사용
- Integer value = conversionService.convert("10", Integer.class)
- 타입 컨버터를 몰라도 그냥 사용 O
📌 인터페이스 분리 원칙 - ISP (Interface Segregation Principle)
DefaultConversionService는 두 인터페이스를 구현함
- ConversionService : 컨버터 사용에 초점
- ConvertRegistry : 컨버터 등록에 초점
-> 인터페이스 분리 -> 이용하지 않는 메소드에는 의존 X
1-4. 스프링에 Converter 적용하기
스프링은 내부에서 ConversionService 제공 -> WebConfig에 컨버터 등록해서 사용
1) 컨버터 등록 - WebConfig
- WebMvcConfigurer가 제공하는 addFormatter() 사용해서 컨버터 등록
-> 스프링은 내부에서 사용하는 ConversionService에 컨버터 추가
package hello.typeconverter;
import hello.typeconverter.converter.IntegerToStringConverter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIntegerConverter;
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 StringToIntegerConverter());
registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
}
}
2) 컨버터 동작 확인
(1) String -> Integer 컨버터 동작 확인
@RestController
public class HelloController {
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
-> StringToIntegerConverter 작동하는 로그 확인
-> 이런 컨버터는 스프링이 내부에서 수많은 기본 컨버터 제공하기 때문에 컨버터 추가하지 않아도 기본 수행
-> 컨버터를 추가하면 추가한 컨버터가 기본 컨버터보다 높은 우선순위
(2) String -> IpPort 컨버터 동작 확인 (사용자 정의 타입 컨버터)
@RestController
public class HelloController {
@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
System.out.println("ipPort = " + ipPort.getIp());
System.out.println("ipPort = " + ipPort.getPort());
return "ok";
}
-> StringToIpPortConverter 작동 로그 확인
3) 처리 과정
- @RequestParam
- @RequestParam을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResolver
-> 내부에서 ConversionService를 사용해서 타입 반환
- @RequestParam을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResolver
📌 ArgumentResolver 의 타입 변환
1) 컨트롤러 파라미터가 @RequestBody, HttpEntity (직접 적는 데이터) 인 경우
-> HttpMessageConverter 사용 -> 타입 변환
* HttpMessageConverter의 역할 : HTTP 메시지 바디의 내용 <-> 객체 변환
( JSON <-> 객체 변환할 때 숫자나 날짜 포맷 변경 : 해당 라이브러리(Jackson)가 제공하는 설정으로 포맷 지정
2) 컨트롤러 파라미터가 @ModelAttribute, @PathVariable, @RequestParam / 뷰 템플릿 인 경우
-> ConversionService 사용 -> 타입 변환
1-5. 뷰 템플릿에 컨버터 적용하기
* 뷰 템플릿은 데이터를 문자로 출력함
1) 숫자, 객체 -> 문자
(1) 컨트롤러 - ConverterController
@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";
}
(2) 뷰 - 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>
- 변수 표현식 : ${ ... }
- 컨버전 서비스 적용 : ${{ ... }}
(3) 실행
- ${{number}}
- IntegerToStringConverter 적용 : 숫자 -> 문자
- 이건 컨버터를 실행하지 않아도 타임리프가 자동으로 숫자 -> 문자로 변환해줌
- ${{ipPort}}
- IpPortToStringConverter 적용 : IpPort 객체 -> 문자
- 컨버터 적용 X : 객체를 그대로 출력 (toString())
2) Form에 적용 - 문자 <-> 객체
(1) 컨트롤러 - ConverterController
@Controller
public class ConverterController {
@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 객체 : ipPort 객체 자체를 담아서 뷰 템플릿으로 전달하는 용도
- GET /convert/edit : IpPort를 Form에 담아서 뷰 템플릿 폼에 출력
- POST /convert/edit : 뷰 템플릿 폼의 IpPort 정보를 받아서 출력
(2) 뷰 템플릿 - converter-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">
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 : 컨버전 서비스 기능도 있음 ;;
- th:value : 컨버전 적용 X
(3) 실행
- GET /converter/edit
- th:field : 자동으로 컨버전 서비스 적용 - 객체 -> 문자 ( IpPortToStringConverter )
- IpPort 객체 -> String 변환
- POST /converter/edit
- @ModelAttribute -> 뷰에 출력된 데이터(String)를 객체(IpPort)로 변환 ( StringToPiPortConverter )
- String -> IpPort 변환
- ${{ipPort}} -> 객체 정보를 다시 뷰 템플릿에 출력 - 객체 -> 문자 ( IpPortToStringConverter )
2. 포맷터 - Formatter
2-1. 포맷터
- Converter : 범용 (아무 객체 <-> 아무 객체)
- Formatter : 문자에 특화 ( 객체 <-> 문자 ) + 현지화(Locale)
- Converter의 특별한 버전
- 숫자 1000 <-> 문자 "1,000"
- 날짜 객체 <-> 문자 "2021-01-01 10:50:11"
1) Formatter 인터페이스
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> {
}
2) 포맷터 - 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={}, locle={}", 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={}, local={}", object, locale);
// 1000 -> "1,000"
NumberFormat instance = NumberFormat.getInstance(locale);
return instance.format(object);
}
}
- 자바가 기본으로 제공하는 NumberFormat 객체 : Locale 정보 활용해서 나라별로 다른 숫자 포맷 제공
- Number : Integer, Long 등의 숫자타입의 부모 클래스
- parse() : 문자 -> 숫자
- print() : 숫자 객체 -> 문자
3) 동작 테스트
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.*;
import static org.junit.jupiter.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); // Long 타입 주의 (parse의 결과가 Long)
}
@Test
void print() {
String result = formatter.print(1000, Locale.KOREA);
assertThat(result).isEqualTo("1,000");
}
}
2-2. 포맷터를 지원하는 컨버전 서비스
원래 컨버전 서비스에는 컨버터만 등록 O, 포맷터 등록 X
근데 포맷터도 컨버터의 일종임
FormattingConversionService (포맷터를 지원하는 컨버전 서비스) -> 컨버전 서비스에 포맷터 추가 가능
-> 내부에서 어댑터 패턴을 사용해서 Formatter가 Converter처럼 동작하도록 지원
* DefaultFormattingConversionService : FormattingConversionService에 몇가지 기본 포맷터 추가 제공
1) 동작 테스트
package hello.typeconverter.formatter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import hello.typeconverter.type.IpPort;
import org.assertj.core.api.Assertions;
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));
// 포맷터 사용
String string = conversionService.convert(1000, String.class);
assertThat(string).isEqualTo("1,000");
Long number = conversionService.convert("1,000", Long.class);
assertThat(number).isEqualTo(1000L);
}
}
- DefaultFormattiongConversionService
- 컨버전 서비슨데 포맷터도 등록 가능
- 컨버터처럼 똑같이 사용 ( convert() ) -> 알아서 적절한 parse, print 메소드 호출
2-3. 포맷터 적용
1) 포맷터 등록 - WebConfig
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());
}
}
- StringToIntegerConverter(), IntegerToStringConverter() 와 MyNumberFormatter()는 모두 숫자 <-> 문자 변환
-> 기능 겹침 -> 컨버터가 우선순위 -> 포맷터 적용 X, 컨버터 적용 O --> 주석처리
2) 실행
(1) 객체 -> 문자
- ${number} : MyNumberFormatter 적용 X -> Integer 객체 출력
- ${{number}} : MyNumberFormatter 적용 O -> 문자 출력
(2) 문자 -> 객체
- 문자 "10,000" -> Integer 객체 10000 로 변환
2-4. 스프링이 제공하는 기본 포맷터
포맷터 -> 기본 형식 지정 -> 다른 형식으로 포맷 지정하기 어려움
- 스프링이 제공하는 기본 포맷터
- @NumberFormat : 숫자 관련 형식 지정 포맷터 사용 (NumberFormatAnnotationFormatterFactory)
- @DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용 (Jsr310DateTimeFormatAnnotationFormatterFactory)
1) 컨트롤러
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;
import java.util.Date;
@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;
}
}
2) 뷰 템플릿
* 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>
3) 실행
(1) GET /formatter/edit
- th:field : Integer, LocaleDateTIme 객체 -> 문자 변환해서 뷰에 출력 ( @NumberFormat(pattern = "") 에서 지정한 형식 )
(2) POST /formatter/edit
- @ModelAttribute : 뷰의 문자 -> Integer, LocaleDateTime 객체로 변환해서 객체에 담음
- ${{ ... }} : Integer, LocaleDateTiem 객체 -> 문자 변환해서 뷰에 출력
📌 컨버터, 포맷터 사용법
1. 기본 제공 컨버터 말고 사용자 정의 컨버터나 포맷터 : WebConfig에 등록
2. 요청 파라미터에 적용 : @RequestParam, @ModelAttribute, @PathVariable
3. 뷰 템플릿에 적용
- 일반 : ${{...}}
- form에서 적용 : th:field