바인딩한 데이터가 일치하지 않는 경우 실행에 필요한 컨트롤러 메서드의 인자 타입을 맞추기 위해 바인딩한 데이터 타입을 변경하는 용도로 사용한다.
우선 Converter<S, T>의 구현체를 구현한다.
public class StringToFoodConverter implements Converter<String, Food> {
@Override
public Food convert(String source) {
return Food.valueOf(source.trim().toUpperCase());
}
}
위와 같이 정의한 후에는 등록작업을 수행한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToFoodConverter());
}
}
@Configuration은 선언 후 WebMvcConfigurer를 선언 후 addFormatters를 override하여 registry에 converter를 등록한다
좀 더 여러 타입에 적용하고 싶은 경우 ConverterFactory를 사용할 수 있다.
예를 들면 Enum을 예로 들 수 있는데, 기존의 Enum은 valueOf(source)지만 우리는 source의 영소문자, 대문자, 좌우 빈 공백에 상관없이 적용하고 싶다. 그러나 매번 Enum타입에 적용하기에는 굉장히 피곤한 작업이므로 이런 경우 ConverterFactory를 활용할 수 있다.
@SuppressWarnings({"rawtypes"})
public class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {
@SuppressWarnings("unchecked")
@Override
public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
return new StringToEnumConverter(targetType);
}
private static class StringToEnumConverter<T extends Enum<T>> implements Converter<String ,T> {
private final Class<T> enumType;
public StringToEnumConverter(Class<T> enumType) {
this.enumType = enumType;
}
@Override
public T convert(String source) {
return Enum.valueOf(this.enumType, source.trim().toUpperCase());
}
}
해당 ConverterFactory를 String -> Enum 으로 인자를 변경하는 작업을 수행한다. StringToEnumConverter를 내부 정적 클래스를 활용해 정의하는데 enumType 인자를 받도록 했다. 이는 enum의 모든 타입에 대해서 Converter를 생성하기 위함이다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverterFactory(new StringToEnumConverterFactory());
}
}
그 이후 간단하게 addFormatters에 addConverterFactory를 이용해 converterFactory를 등록해주면 된다.
ArgumentResolver는 HandlerMethod의 컨트롤러 메서드에 필요한 인자를 바인딩할 때 사용한다.
@RequestParam을 예로 들면 RequestParamMethodArgumentResolver를 이용해서 HttpServletMethod의 쿼리 스트링 정보를 String type으로 가져와서 method 파라미터에 바인딩한다.
기존에 제공하는 ArgumentResolver 외에 필요한 데이터 바인딩을 구현해야 한다면 직접 구현해서 등록하면 된다.
@Header라는 어노테이션을 만들어서 header key에 관련된 값을 String인자로 받아서 Controller 메서드에 활용하고 싶다고 가정하고 코드를 작성해보겠다
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Header {
String name();
}
어노테이션을 정의하자
public class HeaderHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterAnnotation(Header.class) != null;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
HttpServletRequest httpServletRequest = (HttpServletRequest)webRequest.getNativeRequest();
Header annotation = parameter.getParameterAnnotation(Header.class);
assert annotation != null;
String headerName = annotation.name();
return httpServletRequest.getHeader(headerName);
}
}
supportsParamter에는 어노테이션을 인식하기 위한 로직을 짜주고 resolveArgument에는 HttpServletRequest에서 header의 value값을 가져와 String 타입으로 받아온다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverterFactory(new StringToEnumConverterFactory());
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new HeaderHandlerMethodArgumentResolver());
}
}
Configuration에 등록해준다.
ArgumentResolver를 구현해서 등록하면 ArgumentResolver는 잘 동작한다. 그러나 Converter는 제대로 동작하지 않는다.
그러나 만약 enum 타입으로 인자를 받고 @Header 어노테이션을 사용하면 인자가 일치하지 않는다는 에러를 보게 된다.
@RestController
@RequestMapping("/api")
public class HelloController {
@GetMapping("/hello")
public String hello(@Header(name = "food") Food food) {
return food.name();
}
}
내부적으로 ArgumentResolver가 리턴한 타입이 맞지 않는 경우 Converter를 호출하는 로직은 AbstractNamedValueMethodArgumentResolver의 resolveArgument에 구현되어 있다. 그래서 Converter가 적용가능한 @PathVariable이나 @RequestParam 모두 AbstractNamedValueMethodArgumentResolver를 상속받아 설계되었다. ArgumentResolver가 해당 타입을 상속하지 않은 경우 Converter를 활용할 수 없다.
안전하게 ArgumentResolver를 사용하려면 Spring에서 잘 구현한 템플릿인 AbstractNamedValueMethodArgumentResolver를 상속해서 구현하면 된다.
public class HeaderHandlerMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver {
@Override
protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
Header annotation = parameter.getParameterAnnotation(Header.class);
return annotation != null ? new HeaderNamedValueInfo(annotation) : new HeaderNamedValueInfo();
}
@Override
protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) {
HttpServletRequest httpServletRequest = (HttpServletRequest)request.getNativeRequest();
Header annotation = parameter.getParameterAnnotation(Header.class);
assert annotation != null;
String headerName = annotation.name();
return httpServletRequest.getHeader(headerName);
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterAnnotation(Header.class) != null;
}
private static class HeaderNamedValueInfo extends NamedValueInfo {
public HeaderNamedValueInfo() {
super("", false, ValueConstants.DEFAULT_NONE);
}
public HeaderNamedValueInfo(Header annotation) {
super(annotation.name(), annotation.required(), ValueConstants.DEFAULT_NONE);
}
}
}
첫번 째는 createNamedValueInfo이다. 이는 AbstractNamedValueInfo에서 사용하며 annotation 속성에서 name , required , defaultvalue 속성의 값을 추출해 저장한다. 이는 resolveArgument에서 활용한다. 이를 활용해 내가 정의한 어노테이션의 속성 입력에 따라 Spring에서 구현한 로직의 도움을 받을 수 있다.
두 번쨰 resolveName은 핵심 로직을 구현하는 부분이다.
세 번째는 supportsParameter로 해당 ArgumentResolver가 지원하는 MethodParameter인가 확인하는 메서드이다. 해당 메서드의 구현은 @Header라는 이름이 붙은 어노테이션 파라미터에 적용하고 싶기 때문에 내가 정의한 어노테이션이 parameter에 있는지 확인한다.