본문 바로가기
이슈와해결

@RequestBody 컨텐츠 유실 문제 - 컨트롤러에도 디버깅이 찍히지 않으면 어디를 봐야할까?

by Renechoi 2023. 12. 21.

개요

스프링 컨트롤러에서 @RequestBody가 붙은 Dto 매핑 에러를 다룹니다. 스프링에서 ArgumentResolving 하는 로직을 확실하게 이해하고 있지 못했을 때라서 해당 에러를 만났을 때 긴 시간 삽질을 했습니다. 본 글에서는 제가 해당 에러를 다루면서 겪었던 과정과 그와 관련해서 스프링 컨텍스트 공부한 내용을 소개합니다.

 

  • 실제 용어나 코드 중 일부분은 컨셉용으로 대체하였습니다.

 

에러 메시지와 코드 배경

09:37:13.054 [DEBUG] [XNIO-2 task-1] [.w.s.m.m.a.HttpEntityMethodProcessor] - Nothing to write: null body
09:37:13.055 [ WARN] [XNIO-2 task-1] [.a.ExceptionHandlerExceptionResolver] - Resolved [org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public kr.co....CommonResponseEntity<java.lang.Void> kr.co....UserAuthController.test(kr.co...dto.TestDto)]

 

주요 메시지는 다음과 같습니다. HttpMessageNotReadableException@RequestBody 어노테이션이 붙은 매개변수에 대해 HTTP 요청의 본문이 없거나 잘못된 형식으로 인식되었을 때 나타나는 에러입니다. 스프링 MVC가 요청의 본문을 해당 Dto 객체로 변환하는 과정에서 예상치 못한 문제가 발생했음을 나타냅니다.

 

테스트를 위해 간략화된 별도의 엔드포인트와 dto와 요청을 다음과 같이 작성했습니다.

 

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
@Tag(name = "")
public class TestController {

    @PostMapping("/test")
    public CommonResponseEntity<Void> test(@RequestBody TestDto testDto) {
        return OK();
    }
}

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TestDto {
    private String testId;
}

 

인텔리제이를 이용한 Http 요청

 

### test
POST <http://localhost:10002/ipms-user-managements/api/v1/test>
Content-Type: application/json

{
  "testId": "sampleId"
}

 

추적해 보니 예외가 발생한 클래스는 스프링 MVC 컨텍스트의 RequestResponseBodyMethodProcessor 였습니다.

 

RequestResponseBodyMethodProcessor 클래스는 스프링 MVC에서 @RequestBody@ResponseBody 어노테이션을 처리하는 주요 역할을 하는데, HTTP 요청의 본문을 자바 객체로 변환하는(@RequestBody) 작업 중 에러가 발생한 것입니다.

문제 추적하기

문제가 된 메서드는 다음과 같았습니다.

@Override
    protected <T> Object readWithMessageConverters(NativeWebRequest webRequest, MethodParameter parameter,
            Type paramType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {

        HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class);
        Assert.state(servletRequest != null, "No HttpServletRequest");
        ServletServerHttpRequest inputMessage = new ServletServerHttpRequest(servletRequest);

        Object arg = readWithMessageConverters(inputMessage, parameter, paramType);
        if (arg == null && checkRequired(parameter)) {
            throw new HttpMessageNotReadableException("Required request body is missing: " +
                    parameter.getExecutable().toGenericString(), inputMessage);
        }
        return arg;
    }

 

해당 메서드에서

Object arg = readWithMessageConverters(inputMessage, parameter, paramType);

 

메시지 파싱시 arg가 null이 되어 예외가 발생했습니다.

 

readWithMessageConverters를 따라가 보니 AbstractMessageConverterMethodArgumentResolver로 연결되었는데 해당 클래스는 HandlerMethodArgumentResolver의 구현체중 하나였습니다.

 

해당 Resolver의 readWithMessageConverters메서드 중 문제가 된 부분은 다음과 같습니다.

 

try {
            message = new EmptyBodyCheckingHttpInputMessage(inputMessage);

            for (HttpMessageConverter<?> converter : this.messageConverters) {
                Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
                GenericHttpMessageConverter<?> genericConverter =
                        (converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
                if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
                        (targetClass != null && converter.canRead(targetClass, contentType))) {
                    if (message.hasBody()) {
                        HttpInputMessage msgToUse =
                                getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
                        body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
                                ((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
                        body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
                    }
                    else {
                        body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
                    }
                    break;
                }
            }
        }

 

이 부분에서

message.hasBody()

 

에서 메시지가 컨텐츠를 갖고 있지 못해 제대로 된 값을 처리를 하지 못했고 null 값을 리턴하게 되는 문제였습니다.

 

그렇다면 살펴볼 일은 다음과 같습니다.

 

분명 요청을 제대로 보냈는데 뭐가 문제였을까?

 

사실 처음 에러를 접할 때 요청이 잘못되었나 싶어서 스웨거에서도 보내보고 포스트맨에서도 보내 보았었는데요.

 

문제는 Content-Type의 누락도 아니었고, url실수도 아니었고 (당연히), json 포맷도 아니었습니다.

 

삽질의 삽질 끝에 알고 보니... 문제는 커스텀으로 구현한 필터에 있었습니다.

 

해당 프로젝트가 기존의 레거시 코드에서부터 파생되다 보니 커스텀으로 구현된 필터가 있었던 것입니다. Info 레벨에서 필터 로직이 표현이 안되었고 당시 관련 지식이 미비했던 터라 긴 시간을 다른 데서 찾았는데 지금 생각 해보면 참 간단한 문제였던 것 같습니다.

 

Parameter를 선처리 하는 커스텀 필터가 구현이 되어 있는 것을 확인했는데 해당 필터에서는 요청 본문의 데이터를 미리 읽어들여 처리하는 로직을 포함하고 있었는데, 이로 인해 스프링의 @RequestBody 처리 과정에서 요청 본문이 이미 소비되어 더 이상 사용할 수 없게 되었습니다.

스프링에서 HTTP를 다루는 방식

여기서 중요한 사실 중 하나는 스프링에서 HTTP 컨텐츠를 다룰 때, 요청 본문은 스트림 형식으로 처리된다는 것입니다. 스트림의 특성상 데이터가 한 번 읽혀지면 다시 읽을 수 없게 되어, 커스텀 필터가 요청 본문을 미리 읽어버리면 나중에 @RequestBody를 통해 해당 데이터에 접근하려 할 때 이미 소비된 상태라 데이터가 존재하지 않게 됩니다. 이러한 스트림의 일회성 때문에 데이터 소실 문제가 발생하고, 결과적으로 HttpMessageNotReadableException 같은 예외가 발생하는 것입니다.

 

코드가 오래되다 보니 기존의 의도와는 다르게 모든 요청에서도 HttpServletRequestWrapper를 구현한 다른 구현체에게 요청을 보냈고 그 Wrapper에서는 다음과 같이 본문을 읽고 있었습니다.

 

public wrapper(HttpServletRequest request) throws Exception {
        super(request);
        // ...
        InputStream is = super.getInputStream();
        inputStreamByteBuffer = IOUtils.toByteArray(is);
        // ...
    }

 

이처럼 InputStream을 사용하여 요청 본문의 데이터를 읽은 후, 바이트 배열로 변환하여 저장하는 과정이 있었습니다. 이 과정에서 HTTP 요청 본문이 소비되어, 나중에 @RequestBody를 사용하여 요청 본문을 다시 읽으려 할 때 이미 데이터가 없어서 null이 되고, 결국 HttpMessageNotReadableException 예외가 발생한 것입니다.

 

즉, 입력 스트림을 필터에서 이미 읽어서 @RequestBody 어노테이션이 붙은 test 메소드의 TestDto 파라미터로 넘어갈 데이터가 없기 때문에 AbstractMessageConverterMethodArgumentResolver 에서 읽을 수 있는 body가 사라졌고, 컨트롤러로 인입되기 전에 예외가 발생하게 되었습니다.

 

AbstractMessageConverterMethodArgumentResolver

 

RequestResponseBodyMethodProcessor

 

 

해결 방안

해결 방법은 두가지가 있었습니다.

1. 읽은 후 다시 원상복구 해 두기

이 문제를 해결하기 위해서는 필터에서 요청 본문을 읽은 후에 다시 복사하여 요청 객체에 저장해야 합니다.

 

ParameterRequestWrapper 클래스에서 inputStreamByteBuffer를 통해 요청 본문을 복사하고 있으니, 이 데이터를 다시 HttpServletRequest에 설정하여 컨트롤러에서 @RequestBody로 접근할 수 있게 해야 합니다.

 

이를 위해 getParameter 메소드와 같은 방식으로 HttpServletRequestgetInputStream 또는 getReader 메소드를 오버라이딩하여 저장해 둔 본문 데이터를 반환하도록 구현하면 문제를 해결할 수 있습니다.

 

예를 들면 다음과 같을 것입니다.

 

public class ParameterRequestWrapper extends HttpServletRequestWrapper {
    private byte[] inputStreamByteBuffer;

    public ParameterRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        InputStream is = request.getInputStream();
        this.inputStreamByteBuffer = IOUtils.toByteArray(is);
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(inputStreamByteBuffer);
        ServletInputStream servletInputStream = new ServletInputStream() {
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }

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

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

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

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

 

2. ParameterFilter를 전체 주석 처리 하기 → 사용 안하기 !

사실 간단한 것은 해당 필터에서 inputstream을 읽는 일을 안하는 것입니다.

 

현재 프로젝트에서 해당 필터가 하는 일은 딱히 없었기 때문에 해당 필터를 사용하지 않는 방식으로 간단히 해결했습니다.

 

좀 더 리서치를 해보니 HttpServletRequest를 Wrapping해서 조작하는 일은 스프링에서 권장되고 있지 않는 것 같았습니다. 당연한 거겠죠. 필터 조작이 필요하다면 Request를 직접 건드는 것이 아니라 먼저 header에 필요 내용을 삽입하면서 해결하는 방식을 생각해볼 수 있겠고, body 자체에 조작이 필요하다면 컨트롤러로 인입되기 전에 body write하는 로직에서 다루는 것이 논리적이고 좀 더 안정적인 접근일 것 같습니다.

 

Spring MVC 동작 원리

위의 내용과 관련 있는 Spring MVC에서 HTTP 메시지 컨버터 동작원리에 대해서 다시 살펴봤습니다.

 

스프링 MVC에서 @RequestBody와 @ResponseBody 처리

Spring MVC에서 @RequestBody@ResponseBody를 사용하는 과정에서 HTTP 메시지 컨버터가 핵심적인 역할을 합니다. 이들 어노테이션은 HTTP 요청의 본문을 자바 객체로 변환하거나 자바 객체를 HTTP 응답의 본문으로 변환하는 데 사용됩니다.

 

HttpMessageConverter 인터페이스는 HTTP 요청과 응답의 본문을 처리하는데 중요한 역할을 합니다. 이 인터페이스는 다양한 미디어 타입을 지원하며, 클래스 타입과 HTTP 본문의 미디어 타입에 따라 적절한 컨버팅 과정을 수행합니다.

 

기본적으로 몇 가지 HttpMessageConverters를 제공합니다. 예를 들어, ByteArrayHttpMessageConverter, StringHttpMessageConverter, ResourceHttpMessageConverter 등이 있으며, 이들은 각각 바이트 배열, 문자열, 리소스 등을 처리합니다. JSON 형식의 데이터를 자바 객체로 변환하거나 자바 객체를 JSON 형식으로 변환하는 데에 사용되는 컨버터는 MappingJackson2HttpMessageConverter 입니다.

 

https://lordofkangs.tistory.com/497

 

 

커스텀 HttpMessageConverter 를 구성하는 것도 물론 가능합니다. 새로운 HttpMessageConverter 빈을 생성해서 기본 메시지 컨버터를 오버라이딩 하는데, 예를 들어 ObjectMapper에 커스텀 옵션을 가미하기 위해 MappingJackson2HttpMessageConverter를 커스텀으로 자주 구현해서 사용하기도 하는 것 같습니다.

 

HttpMessageConverter의 호출 시점과 워크플로우

그렇다면 해당 컨버터가 언제 호출되는지를 살펴보았습니다.

 

공식 문서에 따르면 다음과 같이 DispatcherServlet이 스프링 컨텍스트 최앞단에서 클라이언트 요청 HttpServletRequest를 인입시킵니다.

 

 

그리고 디스패처 서블릿으로 들어온 요청은 핸들러 어댑터에 따라 컨트롤러 실행을 준비하는데, 이 과정에서 메서드 파라미터를 처리하기 위해 Argument Resolver가 필요합니다.

 

출처: 인프런 김영한의 스프링 강의 노트

 

 

그리고 Argument Resolver 단계에서 @RequestBody@ResponseBody와 같은 어노테이션이 붙은 메소드 파라미터나 반환 타입을 처리하기 위해 HttpMessageConverter가 호출됩니다. HttpMessageConverter는 HTTP 요청 본문을 자바 객체로 변환하거나 자바 객체를 HTTP 응답 본문으로 변환하는 역할을 합니다.

 

따라서, HttpMessageConverter는 Dispatcher Servlet과 Argument Resolver 사이의 플로우에서 특히 Argument Resolver 단계에서 중요한 역할을 합니다. 요청이 들어올 때는 @RequestBody를 처리하기 위해, 응답을 보낼 때는 @ResponseBody를 처리하기 위해 호출됩니다.

 

HandlerMappingArgumentResolver와 HttpMessageConverter의 차이

또 하나 헷갈렸던 부분은 HandlerMappingArgumentResolver와 HttpMessageConverter 둘의 차이에 대한 것이였습니다. 비슷한 것 같지만 나름(?!) 큰 차이가 있었습니다.

 

HandlerMappingArgumentResolver

  • HandlerMappingArgumentResolver는 컨트롤러 메소드의 파라미터를 해석하고 바인딩하는 데 중점을 둡니다.
  • 즉, @ModelAttribute, @RequestParam 어노테이션 혹은 어노테이션이 아무것도 없는 경우 파라미터를 처리합니다.

 

HttpMessageConverter

  • HttpMessageConverter는 HTTP 요청 본문을 자바 객체로 변환하거나, 자바 객체를 HTTP 응답 본문으로 변환하는 역할을 합니다.
  • @RequestBody@ResponseBody 어노테이션 처리에 사용되며, 다양한 형식의 데이터(예: JSON, XML)를 적절한 자바 객체로 변환합니다.
  • HttpMessageConverterHandlerMappingArgumentResolver와는 별도로 작동하여, 요청 본문의 내용을 직접적으로 처리합니다.

 

요약하자면 @RequestBody의 경우 -> HttpMessageConverter,

아닌 경우 -> handlerMappingArgumentResolver

 

입니다.

 

결론

이번에 겪은 문제에서 핵심은 요청 본문이 스트림을 통해 사전에 읽혀져 @RequestBody에 의한 처리가 불가능했다는 것입니다. 커스텀 필터에서 요청 본문을 미리 읽음으로써, RequestResponseBodyMethodProcessorAbstractMessageConverterMethodArgumentResolver가 올바르게 작동하지 못했습니다.

 

보통 디버깅 포인트를 찍으면 스프링 컨텍스트 내에서 직접 구현한 클래스들을 순회하는데, 이렇게 컨트롤러에 찍힌 디버깅 포인트에 도달하기도 전에 에러가 발생하면 조금 당황하는 것 같습니다. 스프링 MVC 워크플로우를 잘 이해하고 있으면 디버깅 시간이 단축되고 어렵지 않게 접근할 수 있을 문제였습니다.

 

 

https://www.baeldung.com/spring-mvc-handlerinterceptor-vs-filter

반응형