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 단에서 먼저 래핑을 해서 내려주면, 정상적으로 작동하게 된다.
결국 요약은 이렇다.
- HttpServletRequestWrapper 를 상속한 커스텀 request 래퍼 클래스를 만든다.
- filter 단에서 request 를 받아 해당 wrapper 로 request 를 래핑해서 내린다.
- 인터셉터에서는 이를 받아서 원래 하기로 했던 바디 처리를 한다. (로깅을 한다던지, 읽어서 validation 검사를 한다던지..)
https://meetup.toast.com/posts/44
위 내용에 잘 설명되어 있고, 래퍼 클래스를 커스텀하게 만들어서 사용하게 되면 발생하는 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
4. RequestBodyAdviceAdapter (Header/Body 의 차이와 성능이슈)
이전에 모든 응답에 공통 헤더를 넣어주기 위해 ResponseBodyAdviceAdapter 를 사용한 적이 있다.
사실 위의 모든 내용은 대응되는 RequestBodyAdviceAdapter 를 사용해서 처리할 수 있다.
통상적으로 이야기하면, Header 의 값들은 크기가 작지만, Body 에는 데이터의 크기가 무제한일 수 있다.
동영상이 오거나 사진같은게 뭉탱이로 온다면, body 의 크기는 엄청 클 것이다.
이를 가정하면, 위에서 filter - wrapper - interceptor 패턴으로 body 값을 캐싱하고 있다는 것은 어떻게보면 성능상의 이슈가 있을 수 있다. (그 큰걸 캐싱해서 들고있으니..)
그래서 이 방법이 좀 더 유리할 수 있다.
https://stuffdrawers.tistory.com/10?category=791193
지애님 블로그
https://hirlawldo.tistory.com/44
'TIL' 카테고리의 다른 글
TIL) JWT 와 보안, CORS, 카카오에서 CORS (0) | 2021.11.07 |
---|---|
TIL) 필드vs프로퍼티, backing field, backing property (0) | 2021.11.05 |
TIL) Kotlin : runCatching, DTO/Entity 작성 팁 (0) | 2021.10.21 |
TIL) JPA 페이징, Json 응답시 Null 필드 제외, Envers (0) | 2021.10.18 |
TIL) 깃 충돌, 예외 처리 전략, @CreatedDate vs @CreationTimeStamp (0) | 2021.10.17 |