본문 바로가기

TIL

TIL) Requset Wrapping, ContentCachingRequestWrapper , RequestBodyAdviceAdapter (Header/Body 의 차이와 성능이슈)

1. RequestWrapping

 

인터셉터에서 request/response 로 어떠한 처리를 할 때, HttpServletRequest/HttpServletResponse 를 사용한다.

 

HttpServletRequest 를 대표로 최상위 인터페이스를 따라가 보자. 

 

이름은 ServletRequest 이다.

이 인터페이스는 아래와 같은 메서드를 가진다.

 

    /**
     * Retrieves the body of the request as binary data using a
     * {@link ServletInputStream}. Either this method or {@link #getReader} may
     * be called to read the body, not both.
     *
     * @return a {@link ServletInputStream} object containing the body of the
     *         request
     * @exception IllegalStateException
     *                if the {@link #getReader} method has already been called
     *                for this request
     * @exception IOException
     *                if an input or output exception occurred
     */
    public ServletInputStream getInputStream() throws IOException;

 

위의 주석을 잘 읽어보면, 이 메서드로 얻을 수 있는 (반환형인) ServletInputStream 를 사용해서 request 의 body 를 binary 데이터로 검색 즉, 읽는다고 한다.

 

그리고 또, getRead() 와 이 메서드 둘 중 하나만 읽을 수 있다고 한다.

 

그 아래 중요한 것이, 이미 요청에서 읽혔다면, IllegalStateException 이 발생한다고 한다.

 

즉, request 의 body 를 두 번 읽을 수 없는 것이다.

 

ServletResponse // 응답의 최상위 인터페이스

    /**
     * Returns a {@link ServletOutputStream} suitable for writing binary data in
     * the response. The servlet container does not encode the binary data.
     * <p>
     * Calling flush() on the ServletOutputStream commits the response. Either
     * this method or {@link #getWriter} may be called to write the body, not
     * both.
     *
     * @return a {@link ServletOutputStream} for writing binary data
     * @exception IllegalStateException
     *                if the <code>getWriter</code> method has been called on
     *                this response
     * @exception IOException
     *                if an input or output exception occurred
     * @see #getWriter
     */
    public ServletOutputStream getOutputStream() throws IOException;
    
    // 마찬가지로 OutputStream 이 이미 사용되었다면, IllegalStateException 발생.

 

 

 

이번 작업중에, 모든 요청에서 바디에 공통으로 들어오는 값들을 인터셉터단에서 받아서 Validation 처리를 하려고 했다가 발견한 내용이다.

이를 무시하고 인터셉터에서 request 바디값을 꺼내 사용한 후, 이후 컨트롤러단에서 @RequestBody 로 바디를 다시 읽으려 한다면, getInputStream()/getReader() 를 두번 사용하려 해서 HttpMessageNotReadableException 을 만나게 될 것이다.

 

 

해결 방법의 시나리오는 간단하다.

값을 꺼내기 전, request 를 감싼 Wrapper 객체를 하나 만든다. (HttpServletRequestWrapper 상속)

그리고 getInputStream/getReader 를 기존 객체를 사용하는게 아니라 새로운 객체를 반환받도록 오버라이딩한다.

이후 이 객체를 넘겨주게 되면 이 Wrapper 객체는 위의 제약이 걸리지 않는다.

하지만 이 Wrapper 를 인터셉터단에서 만들어 쓰기에는 같은 문제가 발생한다.

왜냐하면, 이 인터셉터와 컨트롤러의 데이터를 바인딩하는 레벨은 같은 DispatcherServlet 의 레벨이기 때문이다.

 

public class DispatcherServlet extends FrameworkServlet {

    ...
    
    protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    
    ...
    
        // preHandler 실행
        if (!mappedHandler.applyPreHandle(processedRequest, response)) {
            return;
        }

        // 실제 핸들러로 컨트롤러 로직 실행
        // Actually invoke the handler.
        mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
        
        ...

 

위 클래스는 Spring-webmvc.org.springframework.web.servlet.DispatcherServlet 이다.

 

인터셉터와 컨트롤러는 같은 레벨이기 때문에, 아무리 인터셉터에서 request 를 래핑해서 사용한다 한들 반환해서 아래에서 사용하는 것이 아니기 때문에,

아래의 handle 메서드에서는 래핑한 객체가 아닌 그냥 request 를 사용하게 된다.

Call By Value 로 의해 래핑한 객체는 사라지고, 그냥 원래 객체를 사용하는 것이다.

 

 

결국 그럼 DispatcherServlet 에 들어오기 전에 래핑을 해야 한다는 것인데, 이에 적절한게 filter 이다.

filter 단에서 먼저 래핑을 해서 내려주면, 정상적으로 작동하게 된다.

 

결국 요약은 이렇다.

  1. HttpServletRequestWrapper 를 상속한 커스텀 request 래퍼 클래스를 만든다.
  2. filter 단에서 request 를 받아 해당 wrapper 로 request 를 래핑해서 내린다.
  3. 인터셉터에서는 이를 받아서 원래 하기로 했던 바디 처리를 한다. (로깅을 한다던지, 읽어서 validation 검사를 한다던지..)

 

https://meetup.toast.com/posts/44

 

Spring Interceptor(혹은 Servlet Filter)에서 POST 방식으로 전달된 JSON 데이터 처리하기 : NHN Cloud Meetup

Spring Interceptor(혹은 Servlet Filter)에서 POST 방식으로 전달된 JSON 데이터 처리하기

meetup.toast.com

 

위 내용에 잘 설명되어 있고, 래퍼 클래스를 커스텀하게 만들어서 사용하게 되면 발생하는 GET 요청의 문제점과 해결 방안에 대해서도 서술되어 있다.

 

 

 

 

 

2. ContentCachingRequestWrapper

 

사실 우리는 body 값을 사용하기 위해 HttpServletRequestWrapper 를 상속한 클래스를 직접 작성하지 않아도 된다.

스프링에서 이미 ContentCachingRequestWrapper 라는 클래스를 만들어 두었기 때문이다.

 

이 클래스는 body 데이터를 캐싱해놓을 수 있다.

 

위에서 필터에서 래핑을 해야 한다고 서술했으니 그에 따라 필터에 request 를 래핑하는 것을 만들어 보자.

 

@Component
class CustomServletWrappingFilter : Filter {

    override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
        val wrappingRequest = ContentCachingRequestWrapper(request as HttpServletRequest)
        val wrappingResponse = ContentCachingResponseWrapper(response as HttpServletResponse)
        chain.doFilter(wrappingRequest, wrappingResponse)
        wrappingResponse.copyBodyToResponse()
    }
}

 

wrappingResponse.copyBodyToResponse() 


request 와 마찬가지로 response 도 body를 한번만 읽을 수 있다. 만약에 인터셉터에서 꺼내 읽게 되면 이후 받는 사람 즉, 클라이언트가 Body 값을 꺼내지 못 할 수도 있다.
ContentCachingRequestWrapper 는 객체 생성과 동시에 바디값을 저장하지만 ContentCachingResponseWrapper 는 객체 생성을 하면, 내부적으로 원래 response 를 super(response) 로 셋팅할 뿐이다. 이 메서드를 통해 이 ContentCachingResponseWrapper 의 content 라는 필드에 복사를 해놓는 과정이 따로 필요하다.

 

 

 

이제 래핑된 request 인터셉터에서 사용하여 body를 꺼내서 로그를 찍어보자.

 

@Component
class LogInterceptor(
    val objectMapper: ObjectMapper
) : HandlerInterceptor {

    override fun afterCompletion(request: HttpServletRequest, response: HttpServletResponse, handler: Any, ex: Exception?) {
        val contentCachingRequestWrapper = request as ContentCachingResponseWrapper
        val contentCachingResponseWrapper = response as ContentCachingResponseWrapper

        val body = objectMapper.readTree(contentCachingRequestWrapper.contentAsByteArray)
        print("body : $body")
        super.afterCompletion(request, response, handler, ex)
    }
}

 

ContentCachingRequestWrapper 에는 getContentAsByteArray() 메서드가 있는데, 이름을 보면 알 수  있듯이, byteArray 로 바디값을 반환하기 때문에, 적절히 찾아서 사용하면 된다.

 

 

 

 

3. OncePerRequestFilter vs Filter

 

GenericFilter : Filter 인터페이스를 구현한 추상 클래스. Spring 의 설정정보 (Environment 등) 을 얻어올 수 있는 추가 정보를 제공.

 

서블릿의 기본 동작 구조는, 요청을 받으면 서블릿을 만들어 메모리에 저장한다.

같은 클라이언트에 대한 요청은 그 서블릿을 계속 사용하게 된다.

 

만약 한 요청에서 필터를 거치고 컨트롤러에서의 로직 처리가 다른 URL 로의 리다이렉트라면, 필터를 거쳐 컨트롤러에서 다시 처음으로 돌아가 필터를 거칠 것이다.

예를 들어 인가가 필요한 로직에서 필터를 거치고, 인가를 처리하고 원래 요청건의 리다이렉트를 할 때 필터가 또 동작하는 것이다.

그냥 FIlter, GenericFilter 는 이렇게 동작할 수 있다.

 

이런 상황에서 사용자의 요청 한번에 한번만 동작할 수 있도록 만들어 진 필터가 OncePerRequestFilter 이다.

 

 

[그림]

https://dev-racoon.tistory.com/34

 

2) Springboot OncePerRequestFilter 와 GenericFilterBean의 차이

앞에 1부에서 간단히 springboot에서 Filter 등록하고 사용하는 방법에 대해 알아 봤다. 앞의 예제에서 보면 아래와 같은 흐름으로 동작을 하는것을 알 수있다. 1) api 1 호출 request 2) first , second filter..

dev-racoon.tistory.com

 

 

 

 

4. RequestBodyAdviceAdapter (Header/Body 의 차이와 성능이슈)

이전에 모든 응답에 공통 헤더를 넣어주기 위해 ResponseBodyAdviceAdapter 를 사용한 적이 있다.

 

사실 위의 모든 내용은 대응되는 RequestBodyAdviceAdapter 를 사용해서 처리할 수 있다.

 

통상적으로 이야기하면, Header 의 값들은 크기가 작지만, Body 에는 데이터의 크기가 무제한일 수 있다.

동영상이 오거나 사진같은게 뭉탱이로 온다면, body 의 크기는 엄청 클 것이다.

 

이를 가정하면, 위에서 filter - wrapper - interceptor 패턴으로 body 값을 캐싱하고 있다는 것은 어떻게보면 성능상의 이슈가 있을 수 있다. (그 큰걸 캐싱해서 들고있으니..)

 

그래서 이 방법이 좀 더 유리할 수 있다.

 

https://stuffdrawers.tistory.com/10?category=791193 

 

Request Body를 따로 읽고 싶을 때

Request Body를 인터셉터에서 읽고 싶을 때 2편 주의 꼭 인터셉터가 아니라도 body 값은 한번 읽히면 날아간다는 건 동일하다. 저번엔 필터에서 래핑하여 인터셉터에서 읽었지만 그렇게 하면 성능

stuffdrawers.tistory.com

 

 

 

 

지애님 블로그

https://hirlawldo.tistory.com/44

 

[Spring 프로젝트] Interceptor로 request, response body json 값 로깅하기

Spring Logging (Interceptor로 Request, Response body json 값 로깅하기) 스프링 프로젝트를 하면서 기존에는 LoggingAspect를 만들어서 Aspect파일에서 parameter값과 body값을 찍어주고 있었다. response 값도..

hirlawldo.tistory.com