이전 포스트에서 언급했던 것처럼 스프링에서는 사용자가 전송하는 파일의 크기나 요청 자체의 크기가 설정을 초과하면 예외를 발생시킨다. 업로드된 파일이 크기 제한을 초과할 경우 FileSizeLimitExceededException가 발생하며 이는 ExceptionHandler로 직접 잡을 수 있다.
하지만 요청의 크기가 제한을 초과할 때 발생하는 SizeLimitExceededException의 경우 ExceptionHandler를 구현해도 처리되지 않고 계속 연결이 중단된다.
@ExceptionHandler(SizeLimitExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public String requestSizeLimitExceeded(Model model) { ... }
왜 이런 것일까? 이에 대해서 조금 조사해 보았다.
먼저 해결 방법을 적어두자면 이 SizeLimitExceededException을 ExceptionHandler에서 처리하려면 application.properties에서 server.tomcat.max-swallow-size
속성을 설정해 주면 된다. 이 경우 다음처럼 예외가 잡히는 것을 볼 수 있다
그렇다면 이전에는 왜 안됐던 걸까? 위에서 설정해준 server.tomcat.max-swallow-size
속성은 스프링 부트 애플리케이션에서 사용하는 내장 톰캣 서버의 maxSwallowSize를 설정해준다.
이 maxSwallowSize는 톰캣이 사용자의 요청을 받았을 때 부적절한 요청(대개 크기 제한 초과)인 경우 톰캣이 삼킬(swallow) 요청의 크기를 바이트 단위로 지정한다. 만약 톰캣이 삼키지 않는다면 웹 서버는 맨 위의 사진에서 본 것처럼 연결을 중단시켜버린다.
만약 위처럼 톰캣이 클라이언트와 연결을 끊어버린다면 애플리케이션에서 작성한 예외 처리 코드가 동작하더라도 사용자에게 응답이 전송되지 않는다. 그렇기 때문에 사용자는 무엇이 문제인지 알 방법이 없다.
그래서 적절한 크기(또는 무한대)의 버퍼를 설정해두면 톰캣 서버 측에서 적당히 삼키고 사용자는 애플리케이션의 예외 처리 응답을 받을 수 있다.
그런데 SizeLimitExceededException이 발생하는 부분을 다시 보자.
지난번 포스트를 작성할 때는 아래쪽의 스택트레이스만 보고 SizeLimitExceededException가 발생했다고 판단했었다. 하지만 위쪽의 에러 로그를 보면 MaxUploadSizeExceededException이란 것도 발생한 것을 볼 수 있다. 이 두 예외는 어떤 차이가 있을까?
일단 MaxUploadSizeExceededException은 스프링 프레임워크에서 발생하는 multipart 예외로 말 그대로 클라이언트가 업로드하는 리소스의 크기가 제한을 초과할 때 발생한다.
이 예외가 발생하는 구문을 자세히 보면 다음과 같다.
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.web.multipart.MaxUploadSizeExceededException: Maximum upload size exceeded; nested exception is java.lang.IllegalStateException: org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (27013115) exceeds the configured maximum (26214400)] with root cause
MaxUploadSizeExceededException 다음에 IllegalStateException, SizeLimitExceededException 순으로 nested exception 관계인 것을 알 수 있다. Nested exception이란 한 예외 처리기에서 다른 예외를 던질 때 어떤 예외가 던졌는지 참조하는 부분이다. 즉 SizeLimitExceededException이 발생했을 때 IllegalStateException을 거쳐서 MaxUploadSizeExceededException을 발생시킨다는 것이다.
그럼 SizeLimitexceededException이 실제로 어떻게 발생되는지 스택트레이스를 좀 더 자세히 보면 다음과 같다.
org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (27013115) exceeds the configured maximum (26214400)
...
org.apache.tomcat.util.http.fileupload.FileUploadBase.parseRequest(FileUploadBase.java:280) ~[tomcat-embed-core-9.0.43.jar:9.0.43]
...
org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.parseRequest(StandardMultipartHttpServletRequest.java:95) ~[spring-web-5.3.4.jar:5.3.4]
...
org.springframework.web.multipart.support.StandardServletMultipartResolver.resolveMultipart(StandardServletMultipartResolver.java:87) ~[spring-web-5.3.4.jar:5.3.4]
...
org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.4.jar:5.3.4]
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909) ~[spring-webmvc-5.3.4.jar:5.3.4]
너무 길기 때문에 중요한 부분만 남기고 생략했다. 스택 트레이스 특성상 거꾸로 출력되기 때문에 아래부터 읽어보자.
먼저 FrameworkServlet은 HttpServlet를 간접 상속한 객체다. 즉 사용자의 요청을 처리하는 서블릿 객체로 doPost 메서드에서 사용자가 전송한 파일을 처리한다.
스프링 프레임워크에서는 MultipartFile을 처리하려면 MultipartResolver 인터페이스를 구현한 아파치 커먼즈의 CommonsMultipartResolver나 서블릿 3.0의 API를 이용한 StandardServletMultipartResolver 중 하나를 Bean 객체로 등록해야 한다. 하지만 스프링 부트에서는 아무런 MultipartResolver가 등록되지 않았다면 자동 설정을 통해 StandardServletMultipartResolver를 사용한다.
StandardServletMultipartResolver 클래스에서는 StandardMultipartHttpServletRequest 클래스의 parseRequest 메서드를 사용하여 전송된 파일들을 처리하게 된다. 이 메서드의 소스 코드는 다음과 같다.
private void parseRequest(HttpServletRequest request) {
try {
...
}
catch (Throwable ex) {
handleParseFailure(ex);
}
}
protected void handleParseFailure(Throwable ex) {
String msg = ex.getMessage();
if (msg != null && msg.contains("size") && msg.contains("exceed")) {
throw new MaxUploadSizeExceededException(-1, ex);
}
throw new MultipartException("Failed to parse multipart servlet request", ex);
}
parseRequest 메서드에서는 예외가 발생했을 때 handleParseFailure 메서드로 예외를 넘기고 있다. 해당 메서드에서는 MaxUploadSizeExceededException이나 MultipartException을 발생시키고 있다.
그런데 스택 트레이스 상에서는 이 메서드가 동작하는 과정에서 SizeLimitExceededException이 발생했다고 기록하고 있다. 이 예외는 어디로 가고 MaxUploadSizeExceededException이 나타난 것일까? 이는 톰캣의 예외가 스프링 프레임워크의 예외로 변환된 것이라 추측할 수 있다.
애플리케이션에서는 parseRequest 이후로도 org.apache.tomcat 패키지의 FileUploadBase 클래스나 FileItemIteratorImpl 클래스에서 작업을 수행하고 있다. 그리고 그 결과 SizeLimitExceededException이라는 톰캣 예외가 발생했는데 만약 애플리케이션이 톰캣이 아니라 다른 웹 서버를 사용한다면 어떻게 될까? 이 경우 SizeLimitExceededException이 아닌 다른 예외가 발생할 수도 있으며 예외 처리 코드도 변경되어야 할 것이다.
그렇기 때문에 스프링 프레임워크에서는 애플리케이션의 구성 요소가 변경되더라도 유연하게 대처할 수 있도록 이런 크기 초과 예외를 공통적으로 MaxUploadSizeExceededException으로 변환하여 전달하는 것이다.
정리하면 SizeLimitExceededException은 톰캣에서 발생하는 예외며 이는 스프링 프레임워크에 의해 MaxUploadSizeExceededException로 변환된다.
이전 포스트에서 언급한 파일 크기 예외(FileSizeLimitExceededException) 역시 MaxUploadSizeExceededException로 변환되는 것을 볼 수 있다. 그러므로 다음처럼 하나의 MaxUploadSizeExceededException 예외 처리기로 구현할 수 있다.
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public String fileSizeLimitExceeded(Model model) {
model.addAttribute("errorTitle", "Uploaded Resource Size Exceeds Limit.");
model.addAttribute("errorDescription", "Uploaded resource's size exceeds limit. Please decrease request size.");
return "error/request-failed";
}
문제의 근본적인 해결책은 톰캣의 maxSwallowSize 설정이었다. 하지만 톰캣 서버가 얼마나 삼키도록 설정할 지는 고민해 볼 필요가 있을 것 같다.
만약 사용자가 정말로 몰라서 크기 제한을 초과한 파일을 전송하는 경우 일정 크기 이상의 파일은 업로드할 수 없다는 응답을 주는 것이 바람직할 것이다.
하지만 악의적인 사용자가 일부러 대용량 파일을 계속 전송하는 경우 톰캣이 제한 없이 삼키게 된다면 서버측 메모리에 DoS 공격을 수행하게 되는게 아닐까 하는 고민이 있다. 너무 작게 설정해두면 의미가 없고 크게 설정하려고 해도 얼마나 설정해야 할 지, 아니면 굳이 사용자에게 응답해 줄 필요가 없는 건지 생각해 볼 필요가 있을 것 같다.
Spring Boot의 MultipartResolver부터 MultipartException, MaxUploadSizeExceededException, SizeLimitExceededException 등 여러가지 클래스와 예외의 늪에 빠져서 오랫동안 구글링 및 소스 코드를 들여다보고서야 조금이나마 알 수 있었다.
데이터베이스에 접근할때도 스프링 프레임워크가 예외를 좀 공통적인 예외로 변환해준다는 얘기를 들어본 적이 있는데 아마 위의 예외 변환도 그런 이유에서 적용된 게 아닐까 싶다.
Spring Boot: 'Why' & 'How to' Configure maxSwallowSize property for embedded Tomcat
How to handle MaxUploadSizeExceededException
Nesting Exceptions