[Spring] 서블릿 분석 - Spring은 어떻게 multipart/form-data를 처리할까

부제) 필터에서 multipart/form-data 읽기

 

개요

 갑자기 뜬금없는 주제로 글을 쓰게 됐는데, 최근에 진행했던 프로젝트에서 겪었던 문제를 해결하는 과정에서 알게 된 내용이라 까먹기 전에 정리해두려고 한다.

 

첫 번째 문제 - request body

참고했던 타 동아리 레퍼런스

 

(사실 피할 수 있던 문제였기는 했지만 내가 해보고싶어서 시작해버림)

 

시작은 Spring AOP를 사용한 로깅 시스템의 도입이었다. 프로젝트에 타 동아리에서 봤던 Admin페이지에 요청 정보를 로깅하는 시스템을 그대로 도입해보려고 했는데, 나는 요청을 인터셉터하는 위치를 필터로 정했다.

 

 

 

이를 위해서 Filter에서 HttpServletRequest를 인터셉트하여 정보를 Mdc에 저장할 계획이었다.

 

 

OncePerRequestFilter를 통해서 매 요청이 들어올때마다 Mdc에 request의 관련 정보를 로깅하고, 컨트롤러 메서드에 Advice 범위를 걸어주어 상황에 맞게 로깅해주면 될 것이라고 생각했다.

 

하지만 Request의 Body를 읽기 위해서는 request의 getInputStream() 함수를 사용해야 한다는 문제가 있었다. 파싱이 귀찮다는 문제는 둘째치고, InputStream 자체에 문제가 있었다.

 

 

Java의 Inputstream은 '한 방향으로만' 데이터를 읽도록 되어 있다. 따라서 현재의 경우처럼 중간에서 요청 body를 읽고, 컨트롤러에서도 body를 읽게 하려면 다른 방법이 필요했다.

 

그래서 나는 Request를 Wrapping하는 방법을 사용했다. 찾아보니 이러한 목적으로 제공하는 'HttpServletRequestWrapper'라는 래퍼 클래스가 존재했고, 나는 기존 inputsream을 읽어서 다른 outputstream에 저장한다음, getInputStream()을 해당 outputstream에서 읽도록 하는 방법을 사용했다.

 

이런 식으로

 

이렇게 해주면 필터에서 inputstream을 읽어서 Mdc에 저장해둘 수 있을 뿐만 아니라, Controller에서 body를 읽을 수도 있었다.

 

해치웠나?

 

두 번째 문제 - multipart/form-data

 나는 위 방법으로 모든 문제가 해결되었을 것이라고 생각했다. 서블릿 내부에서도 getInputStream() 메서드를 사용해서 request body를 읽을 것이라고 확신했고, 얼마 전에 순수 자바로만 WAS를 구현했을때 내 코드와 비슷하게 스프링에서도 getInputStream으로 body를 쭉 읽은 다음 delimiter(Content-Type 헤더의 boundary 정보)로 split하여 문자열 or byte 파싱했을 것이라고 예측했기 때문이었다.

 

 

하지만 시간이 좀 지나서, 이미지 파일과 json dto를 동시에 받아야 할 일이 생기면서 일이 시작되었다. 나는 예전에 해왔듯이 @RequestPart 어노테이션을 통해서 form-data 형태로 데이터를 읽으려고 시도했다.

 

 

'Required part 'dto' is not present'

 

 하지만 form-data 정보를 읽을 수 없다는 예외가 발생했다. 사실 나는 처음에 도입한 필터의 문제라고 전혀 생각하지 못했다. Postman에서 dto명을 잘못 넣었던지, form-data가 아닌 form-urlencoded로 넣었든지.. 그러한 유형의 사소한 실수라고만 생각했다.

 

하지만 디버깅을 통해 예외가 터지는 위치를 살펴보니 Filter를 통과한 이후, Controller로 들어오기 이전이었고, 이제서야 필터에서 모종의 이유로 인해서 처리가 제대로 되지 않고 있음을 눈치챘다. 그리고 디버깅 툴을 통해서 오버라이딩한 getInputStream이 전혀 호출되지 않고 있음을 알 수 있었다.

 

 

서블릿 분석하기

좀 많이 복잡한데 기념으로 남겨두기

 

 이 문제를 해결하기 위해서는 결국 서블릿이 multipart/form-data를 어떠한 방식으로 처리하는지를 알아야 했다. 오버라이딩해준 getInputstream 메서드가 왜 동작하지 않는지, 만약 inputStream을 사용하지 않는다면 inputStream을 읽는 다른 함수가 있는건지 등 원인을 확신할 수조차 없었기 때문이다.

 

 

 

 서블릿에서 multipart/form-data를 어떻게 읽는지를 알아본 결과는 다음과 같았다.

 

 

 

1. DispatcherServlet이 Multipart 데이터를 받을 때, MultipartResolver를 호출하고, 그 구현체를 거쳐 request에 있는 getParts() 메서드를 호출하게 된다.

 

 

위와 같이 MultipartResolver 인터페이스의 resolvemultipart를 호출하는데, 디버거로 확인해본 결과 이 해당 인터페이스에는 StandardServletMultipartResolver 클래스가 들어온다.

 

흐름을 따라가다보면 StandeardMultipartHttpServletRequest 클래스의 parseRequest 함수를 호출하게 되고, 여기서는 요청 객체의 'getParts' 메서드를 호출한다.

 

 

 

 

2. 하지만 필터에서 래핑한 객체(RequestWrapper)는 getInputStream()를 사용하는 메서드만을 오버라이딩했으므로, getParts()는 오버라이딩 되어있지 않다.

 

따라서 상위 클래스인 HttpServletRequestWrapper의 getParts()를 호출하는데, 여기서는 더 상위 클래스인 ServletRequestWrapper가 갖는 request 필드의 getParts() 함수를 호출한다. 

 

HttpServletRequestWrapper.getParts()

 

ServletRequestWrapperd의 request필드는 ServletRequest 타입으로 되어있는데, 어떤 구현체가 들어오는지 디버거를 돌려보면

 

 

이 녀석이다. 주석에는 'wraps a Coyote request object' - coyote 패키지의 Request 객체를 갖는 래퍼 클래스라고 되어 있는데, 정확히 말하면 catalina.connector 클래스의 Request 객체를 갖고, catalina.connector 클래스의 Request 클래스 내의 request 객체가 coyote 패키지의 Request 객체를 갖는 이중 래핑이 되어있다.

 

(코드만 봤을 때, coyote 패키지의 Request 객체는 http header, method와 같은 좀 더 원시적인 데이터를 가지고, connector 클래스의 Request 객체는 이러한 원시 데이터를 통해서 고급화된 정보를 제공하는 것 같았다.)

 

어쨋든 중요한 것은 결론적으로 catalina.connector 패키지의 Request 객체에 있는 parseParts()가 호출된다는 것이다.

 

 

3. multipart 데이터를 파일로 임시 저장한 후 읽어들이는데, 임시 저장을 위임하는 FiltItemIteratorImpl 클래스 쪽에 파라미터로 Request객체 자신을 컨텍스트에 감싸서 넘겨주게 된다.

 

FiltItemIteratorImpl.init()

 

ctx가 Request객체를 감싼 ServletRequestContext인데, 결국 다시 말하면 Request클래스의 getInputStream을 호출한다는 것이다. getInputStream() 메서드를 보자

 

 

당연하겠지만, 우리가 캐싱해놓은 OutputStream에서 읽는 것이 아니라, 원래 inputStream에서 읽고 있다. 순수 inputstream은 이미 Mdc에 저장해주느라 다 읽어버렸으니 아무것도 읽힐 리가 없고, 따라서 오류가 발생하게 된다.

 

 

4. 이제 getParts()에서 필요한 부분을 오버라이딩해주면 문제가 해결된다.

 

 문제 해결을 위해서는 Request 객체의 getParts() 메서드가 호출되면 안된다. 다시 RequestWrapper로 돌아가서, getInputStream 외에 getParts(), getPart()와 같이 getParts()를 사용하는 함수를 모두 오버라이딩하여 request객체의 getParts()가 호출되지 않도록 제어를 가져오면 된다.

 

 

파일을 임시로 저장하고, 결과물인 FileItem 컬렉션을 통해 ApplicationPart의 컬렉션을 생성하여 반환하도록 만들어 주었다. FileUpload의 parseRequest() 메서드에서는 RequestWrapper를 감싼 ServletRequestContext를 인자로 넘겨주고 있으므로, inputStream을 호출하더라도 Request객체의 그것이 아니라 RequestWrapper에서 오버라이딩해준 getInputStream()이 호출될 것이다.

 

 

결론

 HTTP request body에 담겨서 들어온다는 점은 동일하지만, json과 다르게 multipart/form-data의 경우 임시로 파일로 저장했다가, 다시 읽어들인다는 점이 상당히 특이했다. 이것은 파일 자체를 업로드할 수 있는 multipart/form-data 특성상, 극단적인 경우 파일의 총합이 메모리보다 커지면 메모리가 터질 수 있는 문제가 있기에 파일로 읽어서 디스크에 저장하는 방식을 채택했을 확률이 높아 보인다.