타입 컨버터 - Converter
org.springframework.core.convert.converter.Converter 인터페이스를 구현
Converter<S, T> 와 Converter<T, S> 모두 구현
타입 컨버터 객체를 직접 생성하여 사용하면, 개발자가 직접 컨버팅 하는 것과 큰 차이가 없다. 타입 컨버터를 등록하고 관리하면서 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요하다. -> ConversionService
참고: 롬복의 @EqualsAndHashCode 를 넣으면 모든 필드를 사용해서 equals() , hashcode() 를 생성한다. 따라서 모든 필드의 값이 같다면 a.equals(b) 의 결과가 참이 된다.
참고: 스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.
- Converter:기본 타입 컨버터
- ConverterFactory: 전체 클래스 계층 구조가 필요할 때
- GenericConverter: 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
- ConditionalGenericConverter: 특정 조건이 참인 경우에만 실행
스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다. IDE에서 Converter , ConverterFactory , GenericConverter 의 구현체를 찾아보면 수많은 컨버터를 확인할 수 있다.
컨버전 서비스 - ConversionService
타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것은 매우 불편하다.
스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 이것이 바로 컨버전 서비스( ConversionService )이다.
ConversionService 인터페이스
package org.springframework.core.convert;
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);
}
DefaultConversionService 는 ConversionService 인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공한다.
등록과 사용 분리
타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다. 따라서 타입을 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다. 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.
컨버전 서비스 사용
Integer value = conversionService.convert("10", Integer.class)
스프링은 내부에서 ConversionService 를 사용해서 타입을 변환한다. 예를 들어서 앞서 살펴본 @RequestParam 같은 곳에서 이 기능을 사용해서 타입을 변환한다.
참고: 인터페이스 분리 원칙 - ISP(Interface Segregation Principle)
인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.
DefaultConversionService 는 다음 두 인터페이스를 구현했다.
- ConversionService : 컨버터 사용에 초점
- ConverterRegistry : 컨버터 등록에 초점
이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다. 특히 컨버터를 사용하는 클라이언트는 ConversionService 만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다.
ConversionService conversionService = new DefaultConversionService();
결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다. 이렇게 인터페이스를 분리하는 것을 ISP 라 한다.
스프링에 Converter 적용하기
컨버터 등록
@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 에 컨버터를 추가해준다.
컨버터 동작 ( @RequestParam)
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data){
log.info("RequestParam.data = {}", data);
return "ok";
}
== 실행 로그 ==
h.t.converter.StringToIntegerConverter : convert source=10 //직접 등록한 StringToIntegerConverter 가 작동
h.t.controller.HelloController : RequestParam.data = 10
스프링이 내부에서 수많은 기본 컨버터들을 제공하기 때문에, 직접 컨버터를 등록하기 전에도 이 코드는 잘 수행되었다.
컨버터를 추가하면 추가한 컨버터가 기본 컨버터 보다 높은 우선순위를 가진다.
기본 컨버터를 최대한 사용하는 한에서 직접 구현한 컨버터를 사용하는 경우는 문자를 사용자 정의 객체로 변환해야 할 때가 많다.
처리 과정
@RequestParam 은 @RequestParam 을 처리하는 ArgumentResolver 인 RequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다. 부모 클래스와 다양한 외부 클래스를 호출하는 등 복잡한 내부 과정을 거치기 때문에 대략 이렇게 처리되는 것으로 이해해도 충분하다. 만약 더 깊이있게 확인하고 싶으면 IpPortConverter 에 디버그 브레이크 포인트를 걸어서 확인해보자. (디버그 모드로 실행)
뷰 템플릿에 컨버터 적용하기
타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원한다.
이전까지는 문자를 객체로 변환했다면, 이번에는 그 반대로 객체를 문자로 변환하는 작업이 필요하다.
타임리프 ${{...}}
number = 10000 //Integer -> String 변환 필요
ipPort = new IpPort("127.0.0.1", 8080) //객체 -> String 변환 필요
<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
타임리프는 ${{...}} 를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다.
물론 스프링과 통합되어 스프링이 제공하는 컨버전 서비스를 사용하므로, 직접 등록한 컨버터를 사용할 수 있다.
- 변수 표현식 : ${...}
- 컨버전 서비스 적용 : ${{...}}
폼에 적용하기
타임리프의 th:field 는 id , name 를 출력하는 등 다양한 기능이 있는데, 여기에 컨버전 서비스도 함께 적용된다.
컨트롤러 코드
@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";
}
Form 객체를 데이터 전달을 위한 폼 객체로 사용한다.
GET /converter/edit : IpPort 를 뷰 템플릿 폼에 출력한다.
POST /converter/edit : 뷰 템플릿 폼의 IpPort 정보를 받아서 출력한다.
@ModelAttribute 를 사용해서 String -> 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 : 127.0.0.1:8080
th:value (보여주기 용도) : hello.typeconverter.type.IpPort@59cb0946
th:field 가 자동으로 컨버전 서비스를 적용. ${{ipPort}} 처럼 동작 (IpPort -> String 변환)
th:value는 객체의 toString() 을 출력