Spring Boot

[Spring] @RequestBody를 Controller에서 받기 전 변환하기 - Interceptor(HttpServletRequestWrapper), RequestBodyAdvice 사용하기

수수한개발자 2024. 5. 18.
728x90

이번 글은  컨트롤러에서 @RequestBody에 붙은 Dto를 받기 전에 컨트롤러에 타입을 변환해서 넘겨줘야 되는 문제를 해결하기 위해 작성하게 되었습니다.

 

 

1. 문제 상황

  1. 프론트에서는 타임 관련된 필드를 long 타입의 Unix Timestamp를 요청하고 응답받아 클라이언트의 타임존에 맞게 시간을 보여주고 있다.
  2. 기존 DB의 데이터 타입은 Unix Timestamp으로 저장 -> 가독성을 위해 백엔드에서는 DB에 Date 타입으로 조회 및 저장, 수정을 해야 된다.

위와 같은 상황에서 이미 개발 된 코드를 일일이 찾아 Unix Timestamp값으로 넘어오는 필드를 수정하는 것보다는 컨트롤러가 받기 전에 변환을 해서 처리하면 괜찮겠다는 생각을 했습니다.

 

 

그래서 첫번째 ArgumentResolver(아큐먼트 리졸버)를 사용하는 방법으로 했지만 생각과 다르게 동작하지 않았습니다.

 

아큐먼트 리졸버

스프링의 디스패처 서블릿은 컨트롤러로 요청을 전달한다. 그때 컨트롤러에서 필요로 하는 객체를 만들고 값을 바인딩하여 전달하기 위해 사용되는 것이 ArgumentResolver이다. 스프링이 제공하는 다음과 같은 어노테이션들은 모두 ArgumentResolver로 동작한다.

  • @RequestParam: 쿼리 파라미터 값 바인딩
  • @ModelAttribute: 쿼리 파라미터 및 폼 데이터 바인딩
  • @CookieValue: 쿠키값 바인딩
  • @RequestHeader: 헤더값 바인딩
  • @RequestBody: 바디값 바인딩

 

@RestController
@RequestMapping("/times")
public class RequestBodyController {

    @PostMapping
    public RequestBodyDto getTime(@RequestBody @TypeConverter RequestBodyDto requestBodyDto) {
        System.out.println("RequestBodyController = " + requestBodyDto);
        return requestBodyDto;
    }
}

 

위와 같이 추가한 ArgumentResolver는 객체를 만드는 동작을 하는데 @RequestBody가 우선순위를 갖고 먼저 동작하여

컨트롤러로 전달할 객체가 만들어져 무시됩니다.

 

 

 

 

 

2. Interceptor에서 변환하기

Interceptor에서 변환하려면 HttpServletRequest의 body는 InputStrem으로 받을 수 있는데 이 InputStrem은 한번 읽게 되면 재활용이 불가능하다.

인터셉터에서 RequestBody의 값을 읽으면 컨트롤러에서는 값을 못 받게 된다.

 

그래서 HttpServletRequestWrapper를 상속받아 사용해야 한다.

 

2 - 1. HttpServletRequestWrapper

public class CacheBodyHttpServletRequestWrapper extends HttpServletRequestWrapper {
    
    private byte[] requestBody;

    public CacheBodyHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        InputStream is = request.getInputStream();
        requestBody = is.readAllBytes();
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        return new ServletInputStream() {
            private final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);

            @Override
            public int read() throws IOException {
                return bais.read();
            }

            @Override
            public boolean isFinished() {
                return bais.available() == 0;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }
        };
    }

    public String getRequestBody() {
        return new String(requestBody, StandardCharsets.UTF_8);
    }

    public void setRequestBody(String body) {
        this.requestBody = body.getBytes(StandardCharsets.UTF_8);
    }
}

 

위의 코드를 보면 생성자에서 InputStream을 호출할 때 ByteArayInputStream에 requestBody를 캐싱해놓고 그 값을 다음 getInputStream을 할 때 읽게 하는 방식으로 구현했습니다. 

 

2 - 2. CacheBodyFilter

filter는 Interceptor앞에서 작동하므로 필터에서 HttpServletRequest를 위의 구현한  CacheBodyHttpServletRequestWrapper 로 바꿔주는 작업을 해 줍니다.

 

@Component
public class CacheBodyFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        CacheBodyHttpServletRequestWrapper cachedBodyHttpServletRequest = new CacheBodyHttpServletRequestWrapper(request);
        filterChain.doFilter(cachedBodyHttpServletRequest, response);
    }
}

 

 

2 - 3. RequestBodyInterceptor

@Component
public class RequestBodyInterceptor implements HandlerInterceptor {
    private final ObjectMapper objectMapper;

    public RequestBodyInterceptor(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (request instanceof CacheBodyHttpServletRequestWrapper) {
            CacheBodyHttpServletRequestWrapper wrappedRequest = (CacheBodyHttpServletRequestWrapper) request;

            // 요청 본문 읽기
            String body = wrappedRequest.getRequestBody();
            System.out.println("Original body = " + body);

            // JSON 파싱
            RequestBodyDto requestBodyDto = objectMapper.readValue(body, RequestBodyDto.class);

            // Unix Timestamp를 ZonedDateTime으로 변환
             if (requestBodyDto.getFiled() instanceof Number) {
                long timestamp = ((Number) requestBodyDto.getFiled()).longValue();
                ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp * 1000), ZoneId.of("UTC"));
                requestBodyDto.setFiled(zonedDateTime);
            }

            // 변환된 ZonedDateTime을 JSON 형식으로 직렬화
            String formattedBody = objectMapper.writeValueAsString(requestBodyDto);
            System.out.println("Formatted Request Body: " + formattedBody);

            // 변환된 본문으로 CacheBodyHttpServletRequestWrapper 설정
            wrappedRequest.setRequestBody(formattedBody);
        } else {
            System.out.println("Request is not an instance of CacheBodyHttpServletRequestWrapper");
        }
        return true;
    }
}

 

long 타입의 시간을 ZonedDateTime으로 변환한 뒤 기존 Request에 다시 세팅해주면 컨트롤러에서는 변환된 값으로 받게 됩니다. 

 

근데 요청에 대해서 캐싱을 해는 작업 및 타입 변환의 귀찮음이 있습니다. 이때 사용할 수 있는게 RequestBodyAdvice입니다.

 

 

3. RequestBodyAdvice로 @RequestBody 값 핸들링하기

 

RequestBodyAdvice란?

RequestBodyAdvice는 요청온 body값을 개발자가 커스터마이징을 할 수 있는 기능을 제공하는 인터페이스이다.

이 인터페이스를 사용하면 Http 메시지 컨버터에서 객체로 변환하기 전과 후 또는 body가 비었을 때 등을 처리하는 메소드를 제공해준다. 이 인터페이스의 구현체를 빈으로 등록하기 위해서는 RequestMappingHandlerAdapter에 직접 등록하거나 @ControllerAdvice 어노테이션을 붙여 빈으로 등록해주면 된다.

 

RequestBodyAdvice의 메소드

  1. supports: 해당 RequestBodyAdvice를 적용할지 결정하는데 true를 반환해주면 커스터마이징한 기능을 사용할 수 있다.
  2. beforeBodyRead: body를 Dto 객체로 변환되기 전에 호출된다.
  3. afterBodyRead: body가 Dto 객체로 변환된 후 호출된다.
  4. handleEmptyBody : body가 비어있을 때 호출된다.

위의 Interceptor에서 설정한 부분을 바꾸면 다음과 같이 된다.

 

@RestControllerAdvice
public class RequestBodyReadAdvice implements RequestBodyAdvice {
    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return targetType.getTypeName().equals(RequestBodyDto.class.getTypeName());
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        return inputMessage;
    }

    @Override
    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        RequestBodyDto requestBodyDto = (RequestBodyDto) body;
        Object filed = requestBodyDto.getFiled();
        long timestamp = ((Number) filed).longValue();
        ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp * 1000), ZonedDateTime.now().getZone());
        requestBodyDto.setFiled(zdt);
        System.out.println("RequestBodyReadAdvice: " + requestBodyDto);
        return body;
    }

    @Override
    public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return null;
    }
}

 

 

4. 테스트 코드를 작성하여 동작 검증 및 확인하기

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RequestBodyControllerTest {

    @LocalServerPort
    int port;

    @Test
    void getTime() {
        // given : 선행조건 기술
        RequestBodyDto requestBodyDto = new RequestBodyDto();
        requestBodyDto.setFiled(System.currentTimeMillis());
        TestRestTemplate testRestTemplate = new TestRestTemplate();
        String uri = "http://localhost:" + port + "/times";

        // when : 기능 수행
        RequestBodyDto response = testRestTemplate.postForObject(uri, requestBodyDto, RequestBodyDto.class);

        // then : 결과 확인
        System.out.println("response = " + response);
        assertThat(response.getFiled()).isNotNull();
    }
}

 

 

테스트를 하면 위와 같이 잘 변환되는것을 확인할 수 있다.

 

이렇게 @RequestBody 값을 컨트롤러에서 받기전에 부가기능을 추가하고 싶으면 RequestBodyAdvice를 사용하면 될 것 같다. 

실제 코드는 깃허브 참고해주시면 감사하겠습니다!

 

728x90

댓글