개요
- 우리가 개발한 프레임워크를 사용하는 팀이 @RequestBody를 통해 전달되는 데이터에 대해 trim 처리를 요청했다.
- 이 기능을 구현하면서 예전에 공부한 SpringMVC의 코드를 복습할 수 있어 좋았다.
- 그래서, 이번 기회에 공부한 내용을 간단하게 정리해 보려고 한다.
SpringMVC의 요청 처리 흐름
- Trim 처리를 위해 SpringMVC를 사용하려면 먼저 SpringMVC가 요청을 어떻게 처리하는지 이해해야 한다.
- 이해를 돕기 위해 아래의 클래스들을 살펴보자.
- 위의 그림과 같이, 먼저 DispatcherServlet이 요청을 받아서 doDispatch() 메소드에서 HandlerMapping을 하고, 그 결과에 따라서 HandlerAdapter를 찾는다.
- HandlerAdapter를 보면, support(handler) 메소드에서 컨트롤러를 처리할 수 있는지 판단한다. 여기서 handler는 컨트롤러를 뜻한다.
- 그 다음 support()에서 true를 반환하면, handle() 메소드를 통해서 컨트롤러를 호출한다.
- SpringMVC는 다양한 HandlerMapping과 HandlerAdapter를 제공하며, 이 중에서 @RequestMapping 애노테이션을 처리할 수 있는 RequestMappingHandlerMapping(HandlerMapping), RequestMappingHandlerAdapter(HandlerAdapter)를 선택한다.
- 그 다음에, RequestMappingHandlerAdapter는 컨트롤러에서 사용할 파라미터를 생성하여 전달하기 위해 HandlerMethodArgumentResolver를 호출한다.
- HandlerMethodArgumentResolver를 살펴보면, supportsParameter()에서 파라미터 처리 가능 여부를 판단한다.
- 이후 supportsParameter()에서 true를 반환하면 resolveArgument()를 호출하여 컨트롤러에서 사용할 객체를 생성한다.
- 컨트롤러에 @RequestBody, @ResponseBody 애노테이션이 있다면, 이를 처리할 수 있는 RequestResponseBodyMethodProcessor가 HandlerMethodArgumentResolver 중에서 호출된다.
- HandlerMethodArgumentResolver는 HttpMessageConverter의 canRead() 메소드를 통해 요청의 content-type과 컨트롤러의 파라미터를 검사하여 적절한 HttpMessageConverter를 선택한다.
- 그 다음, 선택된 HttpMessageConverter를 통해 read() 메소드를 호출하여 메시지를 읽는다.
- 위에서 설명한 내용을 정리하면 아래의 그림과 같이 표현할 수 있습니다.
- 요약하면, @RequestMapping을 처리하기 위해 RequestMappingHandlerAdapter가 선택되고, @RequestBody를 처리하기 위해 RequestResponseBodyMethodProcessor가 선택된다.
- 그 다음에, RequestResponseBodyMethodProcessor는 요청 형태에 따라 적절한 HttpMessageConverter를 선택하여 필요한 객체를 생성한다.
- 예를 들어, 요청의 content-type이 application/json이고 클래스 타입이 TrimDto(객체)인 경우 HttpMessageConverter 중에서 MappingJackson2HttpMessageConverter가 선택된다.
- 만약 컨트롤러의 파라미터가 TrimDto가 아닌 String이었다면, StringHttpMessageConverter가 선택된다.
위에서 SpringMVC에서 요청을 처리하는 방법을 알아봤다. 이제 본격적으로 SpringMVC를 이용해서 Trim 처리하는 방법을 알아보자.
SpringMVC HttpMessageConverter 사용 (첫 번째 시도)
- 첫 번째 시도는 HttpMessageConverter를 사용하는 것이었다.
- SpringMVC에서는 기본적으로 여러 가지 HttpMessageConverter를 제공해 준다.
- 먼저, AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters() 메소드를 보면, messageConverters에 담긴 HttpMessageConverter들이 기본적으로 제공되는 HttpMessageConverter이다.
- messageConverters에서 적절한 HttpMessageConverter을 선택한다.
- 그렇기 때문에 아래와 같이, HttpMessageConverter를 구현해서 messageConverters에 넣어주면 된다.
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final ObjectMapper objectMapper;
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
messageConverters.add(trimMappingJackson2HttpMessageConverter());
}
/**
* 커스텀 Trim 처리를 할 수 있는 HttpMessageConverter 구현.
* <P>
* JSON으로 데이터를 직렬화 및 역직렬화할 때 문자열의 앞뒤 공백을 제거합니다.
*
* @return 구성된 HttpMessageConverter
*/
@Bean
public HttpMessageConverter<?> trimMappingJackson2HttpMessageConverter() {
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
// 기존 ObjectMapper 인스턴스를 재사용
ObjectMapper mapper = objectMapper.copy();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
SimpleModule module = new SimpleModule();
module.addDeserializer(String.class, new TrimStringDeserializer());
mapper.registerModule(module);
converter.setObjectMapper(mapper);
return converter;
}
/**
* JSON 문자열을 역직렬화할 때 앞뒤 공백을 제거하는 커스텀 JsonDeserializer.
*/
public static class TrimStringDeserializer extends JsonDeserializer<String> {
@Override
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.getCodec().readTree(p);
String value = node.asText();
return value.trim();
}
}
}
- 위에처럼 컨버터를 추가해 주고, AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters()를 보면 아래와 같이 컨버터가 추가된 것을 확인할 수 있다.
- 위에서 보듯이 json타입의 파라미터를 처리하는 MappingJackson2HttpMessageConverter 타입중에서 조금전 커스텀해서 만든 컨버터가 가장 위에 위치한다.(우선순위가 가장 높다.)
- 그렇기 때문에, 우선적으로 조금전 만든 컨버터가 적용된다.
- 만약 MappingJackson2HttpMessageConverter 타입의 컨버터에 대해서 중복을 제거하고 싶다면, 아래와 같이 설정하면 된다.
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final ObjectMapper objectMapper;
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
//같은 타입의 Converter 제거
converters.removeIf(converter -> converter instanceof MappingJackson2HttpMessageConverter);
messageConverters.add(trimMappingJackson2HttpMessageConverter());
}
/**
* 커스텀 Trim 처리를 할 수 있는 HttpMessageConverter 구현.
* <p>
* JSON으로 데이터를 직렬화 및 역직렬화할 때 문자열의 앞뒤 공백을 제거합니다.
*
* @return 구성된 HttpMessageConverter
*/
@Bean
public HttpMessageConverter<?> trimMappingJackson2HttpMessageConverter() {
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
// 기존 ObjectMapper 인스턴스를 재사용
ObjectMapper mapper = objectMapper.copy();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
SimpleModule module = new SimpleModule();
module.addDeserializer(String.class, new TrimStringDeserializer());
mapper.registerModule(module);
converter.setObjectMapper(mapper);
return converter;
}
/**
* JSON 문자열을 역직렬화할 때 앞뒤 공백을 제거하는 커스텀 JsonDeserializer.
*/
public static class TrimStringDeserializer extends JsonDeserializer<String> {
@Override
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.getCodec().readTree(p);
String value = node.asText();
return value.trim();
}
}
}
- 그러면 아래와 같이 MappingJackson2HttpMessageConverter 타입이 같은 컨버터가 하나만 존재하는 것을 확인할 수 있다.
- 위와 같이 설정하면, @RequestBody로 넘어오는 요청에 대해서 trim 처리를 할 수 있다.
- 하지만, 적용되지 않았다... 그 이유는 @EnableWebMvc 때문이었다....
- 결론부터 말하자면 @EnableWebMvc를 주석처리하니 정상적으로 작동하였다.
이제부터는 @EnableWebMvc를 사용하지 않아야 하는 이유에 대해 알아보자.
Spring Boot를 사용하고 있다면, @EnableWebMvc가 붙어있는지 확인해라!
- spring-boot-starter-web를 사용하고 있다면 @EnableWebMvc가 붙어있는지 확인해야 한다.
- 나는 지금 까지 개발하면서 @Enable로 시작하는 애노테이션들을 많이 봤다.
- 예를 들어, @EnableAutoConfiguration, @EnableJpaRepositories, @EnableBatchProcessing, @EnableCaching, @EnableTransactionManagement 등이 있다.
- 그래서 나는 @EnableWebMvc 또한 대수롭지 않게 넘어갔다.
- 하지만 @EnableWebMvc 때문에 조금 전 추가한 컨버터가 작도하지 않는다는 걸 깨닫고, 그 이유를 알아보았다.
- 먼저 공식문서를 찾아보니 아래와 같은 내용을 확인할 수 있었다.
- 위의 내용을 정리하면, 스프링 부트가 제공하는 웹 MVC 관련 자동 설정을 유지하면서 커스터마이징하고 싶다면, @EnableWebMVC 없이 @Configuration + implements WebMvcConfigurer를 사용하면 된다고 나와있다.
- 스프링 부트가 제공하는 웹 MVC 기본 설정 없이 본인이 모두 설정을 하고 싶다면 @EnableWebMvc를 붙이면 된다는 의미다.
- 보통 Spring에서 @Enable로 시작하는 애노테이션들은 스프링에서 제공하는 기능을 활성화하는 데 사용된다.
- 그렇기 때문에 @EnableWebMvc 애노테이션도 @Import(DelegatingWebMvcConfiguration.class)을 포함하고 있다.
- 또한, DelegatingWebMvcConfiguration는 WebMvcConfigurationSupport를 상속받고 있다.
- SpringMVC의 자동구성은 WebMvcAutoConfiguration가 담당한다.
- 하지만 WebMvcAutoConfiguration는 @ConditionalOnMissingBean(WebMvcConfigurationSupport.class)가 붙어 있는 것을 확인할 수 있다.
- 그렇기 때문에 WebMvcConfigurationSupport를 상속받은 DelegatingWebMvcConfiguration를 @Import로 포함하는 @EnableWebMvc 애노테이션이 붙어있으면, SpringMVC의 자동구성은 동작하지 않는 것이다.
- 그렇다면 @EnableWebMvc를 활성화한 것과 비활성화한 것을 비교해 보자.
- 위 사진에서 MappingJackson2HttpMessageConverter타입의 컨버터 중에서 내가 추가한 컨버터가 몇 번째 위치에 있는지 체크해 봐야 한다.
- @EnableWebMvc를 비활성화했을 때에는 MappingJackson2HttpMessageConverter타입의 컨버터 중 내가 추가한 컨버터가 가장 위에 위치한다. (우선순위가 가장 높다.)
- 그렇기 때문에 내가 추가한 컨버터가 선택된다.
- 반면에 @EnableWebMvc를 활성화하면 MappingJackson2HttpMessageConverter타입의 컨버터 중 내가 추가한 컨버터가 가장 위에 위치하지 않는다. (우선순위가 낮다.)
- 그렇기 때문에 컨버터를 추가했다 하더라도 선택되지 않는 것이다.
@EnableWebMvc를 달면 안 되는 이유에 대해서 알아보았다. 하지만, 일부러 공백을 넣는 경우도 있을 것이다. 이런 경우에는 어떻게 하면 좋을까?
RequestBodyAdvice 사용 (두 번째 시도)
- RequestBodyAdvice는 요청으로 넘어온 RequestBody를 가공하거나 공통적인 로직을 구현할 수 있는 기능을 제공하는 인터페이스이다. (AOP 기술)
- RequestBodyAdvice를 구현하면 아래와 같은 4가지 메소드를 Override 할 수 있다.
- 나는 요청으로 넘어온 데이터가 객체로 파싱 된 다음에 Trim 처리를 할 것이기 때문에, afterBodyRead() 메서드를 구현했다.
- 코드는 아래와 같다. 먼저, Trim 처리를 할 수 있는 클래스를 정의하자.
public class Trimmer {
// 개행 문자를 제거하기 위한 정규 표현식 패턴
private static final Pattern TRAILING_NEWLINE_PATTERN = Pattern.compile("[\r\n]+$");
/**
* 주어진 객체의 문자열 필드에서 앞뒤 공백을 제거
*
* @param obj 트림을 적용할 객체
* @return 트림 처리된 객체
*/
@SuppressWarnings("unchecked")
public static <T> T trim(T obj) {
if (obj instanceof String) {
return (T) trimString((String) obj);
} else if (obj instanceof List) {
return (T) trimList((List<?>) obj);
} else if (obj instanceof Object[]) {
return (T) trimArray((Object[]) obj);
} else if (obj != null && obj.getClass().isArray()) {
// primitive 타입의 배열을 처리하는 경우
int length = Array.getLength(obj);
for (int i = 0; i < length; i++) {
Object element = Array.get(obj, i);
Array.set(obj, i, trim(element));
}
return obj;
} else {
return trimObject(obj);
}
}
/**
* 문자열에서 앞뒤 공백과 개행 문자 제거
*
* @param str 트림할 문자열
* @return 트림된 문자열
*/
private static String trimString(String str) {
return str == null ? null : TRAILING_NEWLINE_PATTERN.matcher(str.trim()).replaceAll("");
}
/**
* 리스트의 각 요소에 대해 트림 처리
*
* @param list 트림을 적용할 리스트
* @return 트림 처리된 리스트
*/
private static <T> List<T> trimList(List<?> list) {
List<T> result = new ArrayList<>();
for (Object element : list) {
result.add((T) trim(element));
}
return result;
}
/**
* 배열의 각 요소에 대해 트림 처리
*
* @param array 트림을 적용할 배열
* @return 트림 처리된 배열
*/
private static <T> T[] trimArray(T[] array) {
T[] result = array.clone();
for (int i = 0; i < array.length; i++) {
result[i] = (T) trim(array[i]);
}
return result;
}
/**
* 객체의 모든 필드에 대해 트림 처리
* <p>
* 이 메소드는 리플렉션을 사용하여 객체의 모든 필드에 접근합니다.
*
* @param obj 트림을 적용할 객체
* @return 트림 처리된 객체
*/
private static <T> T trimObject(T obj) {
try {
Constructor<T> constructor = (Constructor<T>) obj.getClass().getDeclaredConstructor();
T result = constructor.newInstance();
for (Field field : obj.getClass().getDeclaredFields()) {
field.setAccessible(true);
Object value = field.get(obj);
if (value != null) {
field.set(result, trim(value));
}
}
return result;
} catch (Exception e) {
throw new RuntimeException("트림 처리에 실패하였습니다.", e);
}
}
}
- 위와 같이 Trim 처리를 하는 Trimmer 클래스를 구현했다.
- Trim처리를 하는 김에 개행문자도 제거했다.
- trim() 메소드를 보면, String, List, Arrays 타입을 체크해서 재귀적으로 호출한다.
- 그렇게 되면 아래와 같은 형태의 Dto를 검사할 수 있다.
@RequestMapping("/trim")
@RestController
public class TrimTestController {
@PostMapping("/test")
public String trimTest(@Trim @RequestBody TrimDto trimDto) {
return trimDto.getParam() + trimDto.getInnerParam().getInnerParam1() + trimDto.getInnerParamList().get(0).getInnerParam2();
}
@Data
public static class TrimDto {
private String param;
private InnerParam innerParam;
private List<InnerParam> innerParamList;
}
@Data
public static class InnerParam {
private String innerParam1;
private String innerParam2;
}
}
- TrimDto는 내부적으로 InnerParam이라는 클래스를 가지고 있고, 리스트 형태의 데이터도 가지고 있다.
- 위에서 구현한 Trimmer클래스가 각각의 필드를 검사해서 Trim처리한다.
이제 RequestBodyAdvice를 구현해 보자
- 먼저, Trim이라는 애노테이션을 하나 만들었다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trim {
}
- 그다음 RequestBodyAdvice를 implements 하는 TrimRequestBodyControllerAdvice를 구현했다.
/**
* Trim 처리를 위한 ControllerAdvice
* <p>
* @RequestBody 와 @Trim 이 동시에 붙어있는 Resource 에 대해서 Trim 처리 + 마지막에 붙은 개행문자를 제거
*/
@Slf4j
@RestControllerAdvice
public class TrimRequestBodyControllerAdvice implements RequestBodyAdvice {
/**
* 이 메소드는 지정된 컨트롤러 메소드 파라미터에 대한 요청 본문 처리가 이 클래스에서 지원되는지 여부를 결정
*
* @param methodParameter 메소드 파라미터
* @param targetType 타겟 타입
* @param converterType 메시지 컨버터 타입
* @return @Trim 애너테이션이 있다면 true, 그렇지 않으면 false
*/
@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return methodParameter.hasParameterAnnotation(Trim.class);
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
return inputMessage;
}
/**
* 요청 본문이 읽힌 후, 해당 본문에 대해 트림 처리
*
* @param body 요청 본문 객체
* @param inputMessage 입력 메시지
* @param parameter 메소드 파라미터
* @param targetType 타겟 타입
* @param converterType 컨버터 타입
* @return 트림 처리된 요청 본문 객체
*/
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
body = Trimmer.trim(body);
return body;
}
@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
}
- 먼저 supports() 메소드에서 Trim 애노테이션이 붙어 있는지 검사한다.
- 그다음, afterBodyRead() 메소드에서 Trimmer를 통해서 요청의 Body로 넘어온 데이터를 Trim처리한다.
- Postman으로 요청을 보내면 아래와 같이 정상적으로 Trim처리된 것을 확인할 수 있다.
REFERENCES
'Spring' 카테고리의 다른 글
Spring AOP 2편 (With Spring Transaction) (0) | 2023.04.20 |
---|---|
Spring AOP 1편 (With Spring Transaction) (0) | 2023.04.20 |
AbstractRoutingDataSource를 통한 Multi-DataSource 구현 (0) | 2023.02.04 |
JDK 동적 프록시 (0) | 2022.08.06 |
ApplicationEventPublisher (0) | 2022.06.11 |