모르지 않다는 것은 아는것과 다르다.

Spring

SpringMVC를 이용해서 요청 Body값 Trim처리하기

채마스 2023. 3. 6. 11:17

 

개요

  • 우리가 개발한 프레임워크를 사용하는 팀이 @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