서블릿에서의 예외 처리 방법
웹 애플리케이션 실행 흐름
WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러
만약, 애플리케이션 실행 도중 컨트롤러에서 예외가 발생했다면?
WAS ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)
결과적으로 톰켓과 같은 WAS까지 예외가 전달된다. WAS에 예외가 전달되면 어떻게 처리가 되는지 알아보자. 스프링 부트가 제공하는 예외 설정 페이지 제공 기능을 끄기 위해 application.properties
에 아래와 같이 작성한다.
server.error.whitelabel.enabled=false
컨트롤러를 작성하여 예외를 발생시키면 tomcat이 기본으로 제공하는 오류 화면을 볼 수 있다.
WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러
흐름으로 진행하여 컨트롤러에서 URL을 매핑시켜 해당하는 컨트롤러를 호출하였고 예외가 발생하여 다시 tomcat(WAS)으로 예외가 전달되어 위와 같은 화면이 사용자에게 보여지는 것이다.
오류가 발생했을 때 HttpServletResponse
가 제공하는 sendError
라는 메서드를 사용할 수 있다. 이것을 호출한다고 위와 같이 Exception이 바로 발생하는 것은 아니지만 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.
웹 애플리케이션 실행 흐름
WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러
sendError 실행 흐름
WAS(sendError 호출 기록 확인) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(sendError 발생)
현재 오류 화면은 tomcat이 제공하는 기본 제공 오류 화면으로 사용자에게는 다소 불친절하다. 사용자 친화적인 오류 화면을 제공하도록 바꿀 수 있다.
// 스프링 부트에서 제공하는 tomcat의 오류 페이지 대신 사용할 오류 페이지 커스터마이징
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/400");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPage = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage);
factory.addErrorPages(errorPage404);
factory.addErrorPages(errorPage500);
}
}
@Controller
@Slf4j
public class ErrorPageController {
@RequestMapping("/error-page/404") // @GetMapping (X)
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
return "/error-page/404";
}
@RequestMapping("/error-page/500") // @GetMapping (X)
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
return "/error-page/500";
}
}
오류 페이지는 예외를 다룰 때 해당 예외와 그 자식 타입의 오류를 함께 처리한다. 예를 들어, RuntimeException
이 발생했다면 RuntimeException
은 물론 그 하위 예외들도 처리가 된다.
이 오류가 발생했을 때 처리할 수 있는 컨트롤러가 필요하다. 예를 들어, RuntimeException
예외가 발생하면 위의 errorPage
에서 지정한 /error-page/500
이 호출된다.
웹 애플리케이션 실행 흐름
WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러
만약, 애플리케이션 실행 도중 컨트롤러에서 예외가 발생했다면?
WAS ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)
웹 애플리케이션 실행 흐름
WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러
sendError 실행 흐름
WAS(sendError 호출 기록 확인) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(sendError 발생)
WAS는 해당 예외를 처리하는 오류 페이지 정보를 확인한다.
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/400");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPage = new ErrorPage(RuntimeException.class, "/error-page/500");
예를 들어, 컨트롤러에서 RuntimeException
예외가 발생하면 WAS는 오류 페이지 정보를 확인하고 ErrorPage errorPage = new ErrorPage(RuntimeException.class, "/error-page/500")
이 지정된 것을 확인하고 이 오류 페이지를 출력하기 위해 다시 /error-page/500
를 호출한다. 결과적으로 사용자로부터의 요청과 이 요청에서의 예외 발생 흐름을 합해 나타내면 아래와 같다.
- 정상 : WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(예외 발생)
- 예외 : WAS(WAS로 전달) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)
- 오류 페이지 : WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(오류 페이지)
WAS는 오류 페이지만을 단순히 다시 요청하는 것이 아니라 오류 정보를 HttpServletRequest
의 attribute
에 추가해서 넘겨준다. 필요하면 오류 페이지에서 오류 정보를 사용할 수 있다.
@Controller
@Slf4j
public class ErrorPageController {
public static final String ERROR_EXCEPTION = "jakarta.servlet.error.exception";
public static final String ERROR_EXCEPTION_TYPE = "jakarta.servlet.error.exception_type";
public static final String ERROR_MESSAGE = "jakarta.servlet.error.message";
public static final String ERROR_REQUEST_URI = "jakarta.servlet.error.request_uri";
public static final String ERROR_SERVLET_NAME = "jakarta.servlet.error.servlet_name";
public static final String ERROR_STATUS_CODE = "jakarta.servlet.error.status_code";
@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
printErrorInfo(request);
return "/error-page/404";
}
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
printErrorInfo(request);
return "/error-page/500";
}
private void printErrorInfo(HttpServletRequest request) {
log.info("ERROR EXCEPTION: {}", request.getAttribute(ERROR_EXCEPTION));
// ...
log.info("dispatcher type: {}", request.getDispatcherType());
}
}
실행 결과
2024-08-18T22:27:23.839+09:00 INFO 11960 --- [exception] [nio-8080-exec-7] h.exception.servlet.ErrorPageController : errorPage 500
2024-08-18T22:27:23.839+09:00 INFO 11960 --- [exception] [nio-8080-exec-7] h.exception.servlet.ErrorPageController : ERROR EXCEPTION: null
2024-08-18T22:27:23.839+09:00 INFO 11960 --- [exception] [nio-8080-exec-7] h.exception.servlet.ErrorPageController : dispatcher type: ERROR
// ...
2024-08-18T22:27:08.503+09:00 INFO 11960 --- [exception] [nio-8080-exec-3] h.exception.servlet.ErrorPageController : errorPage 404
2024-08-18T22:27:08.504+09:00 INFO 11960 --- [exception] [nio-8080-exec-3] h.exception.servlet.ErrorPageController : ERROR EXCEPTION: null
2024-08-18T22:27:08.504+09:00 INFO 11960 --- [exception] [nio-8080-exec-3] h.exception.servlet.ErrorPageController : dispatcher type: ERROR
- 정상 : WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(예외 발생)
- 예외 : WAS(WAS로 전달) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)
- 오류 페이지 : WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(오류 페이지)
위와 같이 오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 한 번 호출이 발생한다. 이 때, 필터, 서블릿, 인터셉터 등이 모두 다시 호출된다. 이미 거친 것들이기에 오류 페이지를 호출하기 위해 중간에 있는 것들이 한 번 더 호출되는 것은 비효율적이다.
결국 클라이언트로부터 발생한 정상 요청인지 아니면 오류 페이지를 제공하기 위한 내부 요청인지 구분할 수 있어야 한다. 서블릿은 이런 문제를 해결하기 위해 DispatcherType
이라는 추가 정보를 제공한다.
2024-08-18T22:27:08.504+09:00 INFO 11960 --- [exception] [nio-8080-exec-3] h.exception.servlet.ErrorPageController : dispatcher type: ERROR
dispatcher type: ERROR
인 부분을 볼 수 있는데 이 서블릿 스펙은 실제 고객으로부터의 요청인 것인지, 서버가 내부에서 오류 페이지를 요청하는 것인지 구분할 수 있는 방법을 제공한다.
public enum DispatcherType {
FORWARD, // MVC에서 배웠던 서블릿이나 다른 서블릿, 혹은 JSP를 호출 시
INCLUDE, // 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
REQUEST, // 클라이언트 요청
ASYNC, // 서블릿 비동기 호출
ERROR; // 오류 요청
private DispatcherType() {
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean filterRegistrationBean() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1);
filterFilterRegistrationBean.addUrlPatterns("/*");
filterFilterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
return filterFilterRegistrationBean;
}
}
실행 결과
2024-08-18T22:43:38.253+09:00 INFO 10852 --- [exception] [nio-8080-exec-1] hello.exception.filter.LogFilter : REQUEST [0840888a-26e5-4d8f-ae59-983e11af2bce][REQUEST][/error-500]
2024-08-18T22:43:38.303+09:00 INFO 10852 --- [exception] [nio-8080-exec-1] hello.exception.filter.LogFilter : RESPONSE [0840888a-26e5-4d8f-ae59-983e11af2bce][REQUEST][/error-500]
2024-08-18T22:43:38.309+09:00 INFO 10852 --- [exception] [nio-8080-exec-1] hello.exception.filter.LogFilter : REQUEST [db977e24-9d39-45a1-a891-784b7e7019de][ERROR][/error-page/500]
2024-08-18T22:43:38.311+09:00 INFO 10852 --- [exception] [nio-8080-exec-1] h.exception.servlet.ErrorPageController : errorPage 500
2024-08-18T22:43:38.312+09:00 INFO 10852 --- [exception] [nio-8080-exec-1] h.exception.servlet.ErrorPageController : ERROR EXCEPTION: null
2024-08-18T22:43:38.312+09:00 INFO 10852 --- [exception] [nio-8080-exec-1] h.exception.servlet.ErrorPageController : dispatcher type: ERROR
2024-08-18T22:43:38.594+09:00 INFO 10852 --- [exception] [nio-8080-exec-1] hello.exception.filter.LogFilter : RESPONSE [db977e24-9d39-45a1-a891-784b7e7019de][ERROR][/error-page/500]
위 필터는 클라이언트의 요청이 올 때(REQUEST), 오류 요청일 때(ERROR) 작동하는 필터이다.
filterFilterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR)
와 같이 두 가지를 모두 넣으면 클라이언트 요청은 물론이고 오류 페이지 요청에서도 필터가 호출된다. 아무것도 넣지 않으면 기본 값은 REQUEST이다. 특별히 오류 페이지 경로도 필터를 적용할 것이 아니라면 기본 값 그대로 사용하면 된다.
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String requestURI = request.getRequestURI();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
log.info("REQUEST [{}][{}][{}][{}]", uuid, request.getDispatcherType(),
requestURI, handler);
return true;
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
log.info("postHandle [{}]", modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String logId = (String)request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}][{}]", logId, request.getDispatcherType(),
requestURI);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
실행 결과
2024-08-18T22:59:31.638+09:00 INFO 22468 --- [exception] [nio-8080-exec-3] h.exception.interceptor.LogInterceptor : REQUEST [5108e21b-c57a-4e6a-870c-167ee1b2e6ef][REQUEST][/error-ex][hello.exception.servlet.ServletExceptionController#errorEx()]
2024-08-18T22:59:31.638+09:00 INFO 22468 --- [exception] [nio-8080-exec-3] h.exception.interceptor.LogInterceptor : RESPONSE [5108e21b-c57a-4e6a-870c-167ee1b2e6ef][REQUEST][/error-ex]
2024-08-18T22:59:31.639+09:00 ERROR 22468 --- [exception] [nio-8080-exec-3] h.exception.interceptor.LogInterceptor : afterCompletion error!!
전체 흐름 정리
WAS(/hello, REQUEST) → 필터 → 서블릿 → 인터셉터 → 컨트롤러 → View 반환
예외 흐름 정리
DispatcherType
으로 중복 호출 제거(REQUEST
)excludePathPatterns
으로 중복 호출 제거("/error-page/**"
)
정상 : WAS(/error-ex, ERROR) → 필터 → 서블릿 → 인터셉터 → 컨트롤러(예외 발생)
예외 : WAS(/error-ex, ERROR) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)
오류 페이지 : WAS(/error-ex, ERROR) →
필터(X)→ 서블릿 →인터셉터(X)→ 컨트롤러(오류 페이지) → View 반환
위와 같은 수동 작업을 스프링 부트는 모두 기본으로 제공한다.
ErrorPage
를 자동으로 등록한다. 이 때, /error
라는 경로로 기본 오류 페이지를 설정한다.BasicErrorController
라는 스프링 컨트롤러를 자동으로 등록한다.@Slf4j
@Controller
public class ServletExceptionController {
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
@GetMapping("/error-404")
public void error404(HttpServletResponse response) throws IOException {
response.sendError(404, "404 오류");
}
@GetMapping("/error-500")
public void error500(HttpServletResponse response) throws IOException {
response.sendError(500, "500 오류");
}
@GetMapping("/error-400")
public void sendError(HttpServletResponse response) throws IOException {
response.sendError(400, "4xx 오류");
}
}
이렇게 작성한 후 개발자는 오류 페이지만을 등록한다. 정적 HTML이면 정적 리소스(static), 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶다면 뷰 템플릿(template) 경로에 오류 페이지 파일을 만들어 넣어두기만 하면 된다.
이 때, 뷰 템플릿이 정적 리소스보다 우선순위가 높고, 404, 500처럼 구체적인 것이 4xx, 5xx처럼 추상적인 것보다 우선순위가 높다.
스프링 부트는 BasicErrorController
라는 스프링 컨트롤러를 자동으로 등록하는데 이 때, 다음과 같은 정보를 model에 담아서 뷰에 전달할 수 있다. 뷰 템플릿은 이 값을 활용해서 출력할 수 있다.
* timestamp: Fri Feb 05 00:00:00 KST 2021
* status: 400
* error: Bad Request
* exception: org.springframework.validation.BindException
* trace: 예외 trace
* message: Validation failed for object='data'. Error count: 1
* errors: Errors(BindingResult)
* path: 클라이언트 요청 경로 (`/hello`)
application.properties
에 예외 관련 설정 추가(그러나 이 부분은 보안상의 이유로 추가하지 않는 것이 좋음)# Error
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
server.error.include-binding-errors=always