티스토리 뷰

API 접근 기록을 저장하는 기능을 맡으면서 Path, QueryString, Referrer, Body 값을 DB에 저장을 한다. HTTP의 주요 값을 갖고 있는 HttpServletRequest 클래스를 통해서 Path, QueryString, Referrer 조회하면 정상적으로 조회가 되지만, Body 조회를 하면 값이 없다고 나온다.  이유를 찾아보니 네트워크로부터 들어오는 데이터여서  번만 읽을  있도록 제한되어 있다고 한다. 

 

개발환경 : Java 11, SpringBoot 2.7.7

1. 문제 사례


public class TestController {

    @PostMapping("/test")
    public void test(@RequestBody String body, HttpServletRequest request) {
        // 요청의 path 가져오기
        String path = request.getRequestURI();
        
        // 요청의 body 가져오기
        String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

        System.out.println("Http Path = " + path);
        System.out.println("Http Body = " + body);
    }
}

위 코드는 HttpServletRequest 클래스를 통해서 path, body 값을 가져오고 있다. 실제로 값을 출력해보면 path는 정상적으로 출력이 되지만 body에 값은 비워져 있다.

비워져 있는 이유는 @RequestBody String body를 읽기 위해서 스프링 MVC가 요청 바디(InputStream) 소비했기 때문이다. 그래서 한번 더 getInputStream() 하게 되면 이미 소비된 스트림이어서 값이 비워있게 된다. getInputStream() 메소드 말고 다른 방법을 찾아보았다. getReader() 통해서 요청 바디를 읽을 수 있다고 해서 해보았지만 IllegalStateException 오류가 발생한다.

주석 내용은 HttpServletRequest의 getInputStream() 메서드와 getReader() 메서드에 대한 설명으로, 두 메서드 중 하나만 사용할 수 있으며, 이미 하나를 사용했다면 다른 하나를 호출할 수 없다고 한다. 이를 위반하면 IllegalStateException이 발생한다고 한다. 그래서 이미 @RequestBody에서 getInputStream() 사용했기 때문에 getReader() 사용하니깐 오류가 발생하였다.

 

 

2. 해결 방법


[ 방안 1.  스프링 제공하는 ContentCachingRequestWrapper 클래스로 캐싱 ]

ContentCachingRequestWrapper는 스프링에서 제공하는 유틸리티 클래스로, HttpServletRequest를 Wrapper하여 요청 바디를 캐싱할 수 있도록 해준다. 이를 통해 요청 바디를 여러 번 읽을 수 있다. 사용 예시는 다음과 같다.

@WebFilter(urlPatterns = {"/*"}, description = "wrapping request")
public class ContentCachingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
        filterChain.doFilter(requestWrapper, response);
    }
}

Filter에서 HttpServletRequest -> ContentCachingRequestWrapper Wrapper 맵핑하여 요청 바디를 캐싱한다. Filter 레벨에서 하는 이유는 서블릿 컨테이너에서 동작하기 때문에 DispatcherServlet이 처리하기 전에 요청을 가로채서 캐싱해야 한다. Interceptor 레벨에서 하게 되면 DispatcherServlet 이후에 동작하므로, 요청 바디가 캐시 하기에 이미 바디가 소모되어있을 수 있어 유의해야 한다.

 

사용 방법

public class TestController {

    @PostMapping("/test")
    public void test(@RequestBody String body, HttpServletRequest request) 
        // 요청의 path 가져오기
        String path = request.getRequestURI();
        
        // 요청의 body 가져오기
        ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request;
        String body = new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8);

        System.out.println("Http Path = " + path);
        System.out.println("Http Body = " + body);
    }
}

사용 방법은 간단하다. 필터에서 맵핑한 클래스를 Casting(타입 변환) 해서 요청 바디 캐싱한 값을 가져와서 사용하면 된다.
실제로 디버깅을 했을 때 HttpServletRequest request 클래스를 보면 맵핑된 ContentCachingRequestWrapper으로 나와있다.

요청 바디 값을 출력하면 정상적으로 값이 출력되는 것을 볼 수 있다.

 

 

문제점

Multipart/form-data 형식의 요청에서는 HttpServletRequest에서 ContentCachingRequestWrapper로 캐스팅이 안되는 문제점이 생긴다. 

원인 분석

MultipartFilter에서 MultipartResolver 인터페이스를 사용할 때, CommonsMultipartResolver와 StandardServletMultipartResolver 두 가지 구현체로 나뉜다. 

두 구현체의 차이점은 아래와 같다.
- StandardServletMultipartResolver : 서블릿 3.0부터는 파일 업로드 처리를 위한 표준 API
- CommonsMultipartResolver : 많은 설정 옵션을 제공하며, 대용량 파일 업로드나 특정 설정이 필요한 경우 유용

보통 추가적인 설정이 없다면 StandardServletMultipartResolver 사용하게 되는데 요청 바디를 가져오는 부분에서 문제가 없다.

 

 

@Bean
public CommonsMultipartResolver multipartResolver() {
    CommonsMultipartResolver resolver = new CommonsMultipartResolver();
    resolver.setMaxUploadSize(5242880);              // 파일 최대 크기 설정 (bytes)
    resolver.setUploadTempDir("/path/to/temp/dir");  // 임시 저장소 위치 설정
    resolver.setDefaultEncoding("UTF-8");            // 인코딩 설정
    return resolver;
}

하지만 위 코드와 같이 파일 최대 크기 설정, 인코딩 설정, 임시 저장소 위치 설정 등 추가 옵션을 설정하게 되면  CommonsMultipartResolver 구현체가 사용하게 된다. 내 프로젝트에서는 추가 옵션을 설정하고 있었다. 그러므로 내부적으로 CommonsMultipartResolver 타게 되는데 MultipartHttpServletRequest를 반환할 때 내부 익명 클래스로 처리하게 된다. 이 경우 내부 클래스에서 ContentCachingRequestWrapper로의 캐스팅이 제대로 되지 않아 요청 바디를 가져올 수 없는 문제가 발생한다. 

// 캐스팅 오류 발생
ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request;

Multipart/form-data 형식 요청인 경우

내부 클래스

Multipart/form-data 형식 요청 아닌 경우

일반 클래스

 

 

 


[ 방안 2.  HttpServletRequestWrapper를 상속받는 커스텀 클래스 생성 ]

사용 방법

CachingBodyRequestWrapper 클래스 생성:

  • HttpServletRequestWrapper를 상속받는 커스텀 클래스를 만듭니다. 이 클래스를 통해서 요청의 바디를 캐싱하여 여러 번 접근할 수 있도록 합니다.
  • getInputStream() 메서드를 오버라이드하여 요청의 바디를 복사하고 캐싱된 데이터를 반환하는 CachedInputStream() 생성한다.
public class CachingBodyRequestWrapper extends HttpServletRequestWrapper {

    private byte[] cachedBody;

    public CachingBodyRequestWrapper(HttpServletRequest request) {
        super(request);
        cacheInputStream(request);
    }

    private void cacheInputStream(HttpServletRequest request) {
        try {
            cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
        } catch (IOException e) {
            cachedBody = new byte[0];
        }
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        return new CachedServletInputStream(cachedBody);
    }

    private static class CachedServletInputStream extends ServletInputStream {

        private final ByteArrayInputStream inputStream;

        public CachedServletInputStream(byte[] cachedBody) {
            this.inputStream = new ByteArrayInputStream(cachedBody);
        }

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

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

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

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

 

 

ContentCachingFilter 필터 정의:

  • Filter에서 HttpServletRequest -> CachingBodyRequestWrapper를 Wrapper 맵핑하여 요청 바디를 캐싱한다. 
@WebFilter(urlPatterns = {"/*"}, description = "wrapping request")
public class ContentCachingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        CachingBodyRequestWrapper requestWrapper = new CachingBodyRequestWrapper(request);
        filterChain.doFilter(requestWrapper, response);
    }
}

 

 

요청 바디 가져오기:

  • 이 방법을 사용하면 HttpServletRequest에서 ContentCachingRequestWrapper로 캐스팅할 필요 없이 요청의 바디를 계속해서 접근할 수 있습니다.
  • 방안 1의 문제점인 Multipart/form-data 형식의 요청의 캐스팅이 안되는 문제점을 해결할 수 있다.
public class TestController {

    @PostMapping("/test")
    public void test(@RequestBody String body, HttpServletRequest request) 
        // 요청의 path 가져오기
        String path = request.getRequestURI();
        
        // 요청의 body 가져오기
        String body = this.getRequestBody(request);
        
        System.out.println("Http Path = " + path);
        System.out.println("Http Body = " + body);
    }
    
    private String getRequestBody(HttpServletRequest request) {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(request.getInputStream()))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }

            return sb.toString();
        } catch (IOException e) {
            throw new RuntimeException("Failed to read request body.", e);
        }
    }
}

 

 

문제점

application/x-www-form-urlencoded 형식의 요청에서 요청 파라미터를 읽을 때, 필수 요청 파라미터가 존재하지 않을 경우 MissingServletRequestParameterException이 발생한다. 

MissingServletRequestParameterException 에러

원인 분석

Spring MVC에서는 요청 파라미터를 바인딩할 때, HttpServletRequest의 바디에서 직접 읽어와야 합니다. ContentCachingRequestWrapper는 바디를 캐싱하여 여러 번 접근할 수 있게 하지만, 이를 사용하면서 바인딩 메커니즘이 예상과 다르게 동작할 수 있다. 

실제 내부적으로 Http의 content-type에 따라서 바인딩 메커니즘이 다르다.

  • application/json 요청 시 
 DispatcherServlet -> getParameter() -> EmptyBodyCheckingHttpInputMessage -> ContentCachingRequestWrapper -> ContentCachingInputStream의 read() 바인딩

 

  • application/x-www-form-urlencoded 요청 시
DispatcherServlet -> getParameter( ) -> ContentCachingRequestWrapper -> writeRequestParametersToCachedContent() 바인딩

 

  • Multipart/form-data 요청 시 
DispatcherServlet -> getParameter( ) -> DefaultMultipartHttpServletRequest -> initializeMultipart() ->  CommonsMultipartResolver -> parseRequest() -> RequestParamMethodArgumentResolver -> CachingBodyRequestWrapper 바인딩

 

 

 


[ 결론 ]

방안 1과 방안 2의 문제점을 정리하면

  • 방안 1 :  Multipart/form-data 형식 요청에서 내부 클래스로 인해 캐스팅 문제가 발생
  • 방안 2 : application/x-www-form-urlencoded 형식 요청에서 Spring MVC의 바인딩 메커니즘의 문제가 발생

 

해결 방법

방안 1 + 방안 2 합치는 방안으로, 일반적인 HTTP 요청은 방안 1이 처리를 하고 Multipart/form-data 형식 요청에서는 방안 2가 처리하는 방향으로 진행한다.

@WebFilter(urlPatterns = {"/*"}, description = "wrapping request")
public class ContentCachingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (isMultipartRequest(request)) {
            CachingBodyRequestWrapper requestWrapper = new CachingBodyRequestWrapper(request);
            filterChain.doFilter(requestWrapper, response);
        } else {
            ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
            filterChain.doFilter(requestWrapper, response);
        }
    }

    private boolean isMultipartRequest(HttpServletRequest request) {
        return request.getContentType() != null && request.getContentType().startsWith("multipart/");
    }
}

먼저, HttpServletRequest 객체를 인자로 받아서 해당 요청이 Multipart/form-data 요청인지를 확인을 하고 멀티파트 요청일 경우 방안 2의 CachingBodyRequestWrapper를 사용하여 요청 바디를 캐싱하고, 그 외의 경우에는 방안 1의 ContentCachingRequestWrapper를 사용한다. 이렇게 하면 어떤 형식의 요청이 오더라도 정상적으로 요청 바디를 캐싱할 수 있다..!

 

 

 

[출처]
Reading Request Body Multiple Times in Java/Spring Boot      
Package org.spring framework.web.util Class ContentCachingRequestWrapper 
Spring POST multipart/form-data, request parts always empty