티스토리 뷰

ps. gist에서 특정 라인만 가져올 수 있는 스크립트가 있어 글 중간중간에 필요한 코드만 보이도록 적용해놨었는데, github에서 뭔가 구조가 변경되었는지 작동이 안되네요... 결국 전체소스를 하단에 가져다 넣는 것으로 변경해두었습니다.

POST 방식으로 전달된 "application/json" 타입의 데이터를 Servlet의 Filter나 Spring의 Interceptor에서 모종의 처리를 하기 위해서는 HttpServletRequest의 InputStream을 읽어들여야 합니다. 예를 들면 인증 작업 등과 같은 상황에서 필요할 수 있겠습니다. 그러나 HttpServletRequest의 InputStream은 한 번 읽으면 다시 읽을 수 없습니다. 톰캣 개발자님들께서 친히 막아두셨기 때문이죠! 만약 Interceptor나 Filter에서 InputStream을 읽게되면, 이후 Spring이 Converter를 이용해 Json 데이터를 바인딩 처리할 때 아래와 같은 에러를 만날 수 있습니다. 불쌍한 Spring이 이미 읽어버린 InputStream을 다시 읽으려고 시도하다가 슬픈 에러를 뱉어내는거죠.

java.lang.IllegalStateException: getReader() has already been called for this request org.springframework.http.converter.HttpMessageNotReadableException: Could not read JSON: Stream closed; nested exception is java.io.IOException: Stream closed

하지만 문제가 있다면 해결책이 있겠죠. 우선 wrapper 객체를 하나 만들어서 일단 InputStream을 읽어서 내 맘대로 이것저것 작업한 뒤, 다른 곳에서 InputStream을 다시 읽으려고 시도하는 경우에는 이미 읽었던 데이터로 다시 InputStream을 생성해 돌려주도록 만드는 방법이 있겠습니다.

당연히 앞서 설명한 방법으로 코드를 구현한 선구자님의 글을 구글신님께서 알려주셨구요, 글을 보면서 getInputStream() 메서드를 override한 wrapper를 만들었습니다. javax.servlet.http 패키지에는 HttpServletRequest을 래핑할때 쓰라고 미리 준비해둔 HttpServletRequestWrapper 라는 클래스가 있습니다. 이를 확장해서 wrapper 클래스를 만들어보았습니다.

그런데 만약 Servlet Filter가 아닌 Spring Interceptor에서 이 wrapper 클래스를 사용할 예정이라면, 약간의 고려할 사항이 있습니다. Spring Interceptor를 만들면 context.xml에서 <mvc:interceptors>에서 등록을 해주게 될텐데요. 이는 곧 Spring의 DispatcherServlet에서 Interceptor를 핸들링 한다는 의미와 같겠습니다. 만약 Interceptor 내에서 wrapper를 만들어서 preHandle()에 넘겨주게되면 이후 Spring이 데이터를 바인딩할 때 결국 Stream이 닫혔다는 메시지를 다시 만나게됩니다!! 그 원인은 Interceptor가 DispatcherServlet의 doDispatch메서드 내에서 열심히 for loop를 돌면서 실행된 뒤에 다음 구문에서 데이터 바인딩을 하러 가기 때문입니다. 다시 말하면, Interceptor 내에서 preHandle()으로 넘겨준 request 객체가 데이터 바인딩 작업을 하러 갈때는 call by value에 따라 이미 사라지고 없다는 의미겠죠.

안타깝게도 HttpServletRequest는 InputStream이나 Parameter에 대한 setter 메서드를 제공하지 않습니다. 따라서 DispatcherServlet으로 가기 전인 Servlet Filter에서부터 wrapper 클래스로 전환해주어야 정상동작하게 됩니다. Entry point가 되는 적절한 Filter 속에서 wrapper로 전환하는 작업을 해주고 doFilter()메서드에는 래핑한 request를 넘겨주시면 Interceptor에서도 래핑된 request 객체를 받아와서 잘 사용하는 걸 목격하실 수 있겠습니다. (DispacherServlet에서 handler interceptor들을 처리할 때나 Tomcat에서 filter를 처리할 때 intercepting filter pattern을 사용합니다. 특히 Tomcat의 filter 처리 부분이 상당히 재미있는 구현방식인데, 이 방식이 Netty의 event driven programming을 구현하기 위한 근간이 되는 방식입니다. 자세한 내용은 오라클의 포스팅을 읽어보면 좋습니다.)

할일이 모두 끝난 것 같지만 아직 끝나지 않았습니다.

만약 우리의 서버에서 "application/json" 타입만 받고 있었다면 문제없이 조용히 잘 돌아가겠지만... "application/x-www-form-urlencoded" 타입도 함께 받고 있었다면! 신기하게도 Spring은 우리의 소중한 데이터를 Controller에서 @ModelAttribute 어노테이션을 달아주었던 model 객체에 바인딩해주지 못합니다. 사실 서버가 "application/json" 타입만 처리하겠다고 하면 별 문제는 없습니다. 아무튼 원인은, POST방식이지만 form타입을 통해 전달된 데이터는 Spring이 request에서 getParameterXX() 메서드를 통해 바인딩을 시도하기 때문입니다. 앞서 wrapper 클래스에서 InputStream을 읽어갈 때의 대비는 해두었지만, 이걸 getParameterXX() 메서드로 가져가려는 쪽을 위한 처리는 아직 해두지 않았습니다. Tomcat이 전달해준 HttpServletRequest의 getParameterXX() 메서드는, 최초 호출될 때 들고 있던 raw data를 파싱해서 돌려줍니다.(링크의 코드를 보면, Tomcat의 Request 클래스 내부적으로 lazy loading pattern을 사용해서 getParameterXX()가 최초 호출되기 전까지는 body로 온 InputStream을 그저 들고만 있는걸 알 수 있습니다.) 하지만 wrapper 클래스를 만들 때는 getParameterXX() 메서드가 아직 한 번도 호출당한 적이 없는 request 객체를 받아와서 생성했죠. wrapper 클래스에서 getParameterXX() 메서드들을 override해준 적이 없으니, 이후에 Spring이 getParameterXX() 메서드를 호출하면 기존의 Request 객체가 raw data 파싱작업을 시도한 뒤 만들어진 parameter를 돌려줍니다. 그러나 이미 InputStream을 읽어버렸기 때문에 Request 객체는 파싱작업을 할 게 없고, 비어있는 parameter를 돌려주게됩니다. 결국 @ModelAttribute 어노테이션이 달려있는 객체에는 아무런 값이 바인딩되지 않습니다. @Valid 와 같은 어노테이션을 달아주었다면 "Field error in object 'XXX' on field 'xxx': rejected value [null];”과 같은 전혀 쌩뚱맞은 에러를 만나볼 수 있게 됩니다. 

그럼 이제 만들어두었던 wrapper 클래스를 업그레이드할 차례입니다.

아래와 같이 getParameterXX()메서드들을 요청했을 때 적절하게 반환해줄 수 있도록 InputStream으로 들어왔던 raw data를 적절하게 처리해주는 부분을 좀 더 업그레이드하고, parameter와 관련된 HttpServletRequest 내부의 메서드들을 열심히 override해줍니다.

이제 서버에서 POST방식으로 전달되는 "application/x-www-form-urlencoded" 타입 데이터도 잘 처리하는 걸 확인할 수 있습니다.

물론... 아직 할일이 남았습니다.

이번에는 GET방식으로 호출하는 서버들로부터 왜 API가 안되냐는 이야기를 들을 수 있습니다. GET 방식으로 요청이 오는 경우에 @RequestParam 어노테이션으로 데이터를 가져오도록 구현해두었다면, form 데이터를 처리했던 것처럼 Spring이 HttpServletRequest객체의 getParameterXX()메서드를 활용해 바인딩 처리를 합니다. 그러나 GET방식으로 넘겨준 데이터는 InputStream으로 들어오지 않습니다. Request 객체 내에서 URL 방식으로 넘어온 parameter들은 별도로 보관하고 처리하기 때문입니다. 앞서 getParameterXX() 메서드들을 override하지 않았다면 알아서 잘 처리되고 있었겠지만, 안타깝게도 override를 하는 바람에 더 이상 URL로 전달된 파라미터는 wrapper에서 반환해줄 수 없습니다. 결국 GET 방식으로 호출하는 서버들을 위해서 땀을 삐질삐질 흘리며 wrapper 클래스에서 파라미터 파싱하는 부분을 좀 더 손을 봐주며 한 번 더 업그레이드 합니다. Content-Type이 form인 경우는 이미 구현해주었으므로 form이 아닌 경우에만 원래의 HttpServletRequest 객체를 통해 URL로 전달된 파라미터들을 구해오도록 합니다. 여기까지 모두 구현한 코드는 아래와 같습니다.

드디어 서버로 들어오는 모든 요청에 대해 정상적인 응답이 가는 것 같습니다. 하지만 불안한 마음을 감출 수 없습니다. 아무래도 에러가 나는 상황에서 제대로 된 에러 메시지를 내려주는지 확인해봐야할 것 같습니다. 강제로 Filter에서 Exception을 던져보니 Tomcat의 기본 에러 페이지가 반환됩니다. Spring Controller에서 에러가 발생한 경우에는 @ControllerAdvice 어노테이션을 통해 에러를 핸들링 할 수 있지만, Filter나 Interceptor의 경우는 Servlet을 통해 핸들링 해야합니다. web.xml에 error-page 설정을 추가합니다.

우선 모든 Exception을 핸들링 할 수 있도록 설정해주고 에러를 적절히 핸들링해줄 Controller를 추가합니다. Spring Controller에 들어가기 전에 오류가 발생하는 경우에도 Json으로 응답을 해줄 수 있도록 처리를 해줍니다. Filter나 Interceptor에서 Exception이 발생하는 경우, Request의 Attribute에 적절한 오류메시지를 보여줄 수 있는 Response 객체를 넣어둔다면 금상첨화입니다.

우선 서버에 적용하고 정상적으로 동작하는지는 좀 더 지켜봐야겠지만, 이제 적당히 마무리해도 될 것 같습니다. API를 제공하는 서버가 한 가지 Method & Content-Type만 허용하고 있다면 좋겠지만, 그렇지 않은 경우가 많을 것 같습니다. GET 방식으로 URL을 통해 데이터를 전달하거나, POST 방식으로 Form 데이터를 전달하는 경우에는 이와 같은 wrapper 클래스를 사용하지 않아도 문제가 없습니다. 하지만, POST 방식으로 Json 데이터를 전달하는 경우를 가정하고 Servlet Filter나 Spring Interceptor에서 데이터를 조작하기 위해서는 URL과 Form으로 전달되는 데이터에 대해서도 사전에 준비해두어야 합니다.

댓글