서블릿은 다음 2가지 방식으로 예외 처리를 지원한다.
먼저 Exception에 대해 알아보자.
자바의 메인 메서드를 실행하면 main이라는 이름의 쓰레드가 실행된다.
실행 도중에 예외를 잡지 못하면 상위 메서드로 예외를 던진다. 그래서 처음 실행한 main() 메서드를 넘어 예외가 던져지면 예외 정보를 남기고 해당 쓰레드는 종료된다.
웹 애플리케이션은 쓰레드 하나가 실행되는게 아니라 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다.
애플리케이션에서 예외가 발생할 때 try-catch로 잡아서 처리하면 문제가 없다 하지만, 잡지 못하고 서블릿 밖으로 까지 예외가 전달되면 어떻게 동작할까
WAS <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
결국 WAS까지 예외가 전달된다. WAS는 예외가 오면 어떻게 처리해야할까
tomcat이 기본으로 제공하는 오류 화면이 나온다.
Exception 의 경우 서버 내부에서 처리할 수 없는 오류가 발생한 것으로 생각해서 HTTP 상태 코드 500을 반환한다.
오류가 발생했을 때 HttpServletResponse 가 제공하는 sendError 라는 메서드를 사용할 수 있는데, 이것을 호출한다고 당장 예외가 발생하는 것이 아니라, 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.
이 메서드를 사용하면 HTTP 상태 코드와 오류 메시지도 추가할 수 있다.
sendError 흐름
WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 (response.sendError())
response.sendError() 를 호출하면 response 내부에는 오류가 발생했다는 상태를 저장해둔다.
그리고 서블릿 컨테이너는 고객에게 응답 전에 response 에 sendError() 가 호출되었는지 확인한다.
그리고 호출되었다면 설정한 오류 코드에 맞추어 기본 오류 페이지를 보여준다.
서블릿 컨테이너는 오류가 발생했을 때 기본으로 예외처리 화면을 보여준다. 하지만 이 화면은 고객친화적이지 않으며, 고객이 보았을 때 무슨 오류인지 알아보기도 쉽지 않고 불편하다. 따라서 서블릿은 오류화면 기능을 제공한다.
서블릿은 Exception (예외)가 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError() 가 호출 되었을 때 각각의 상황에 맞춘 오류 처리 기능을 제공한다.
@Override
public void customize(ConfigurableWebServerFactory factory) {
//404 error가 발생하면 path인 /error-page/400 페이지를 호출해라, 정확히 얘기하면 컨트롤러를 호출
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");//runtime예외들의 자식예외들도 모두 /error-page/500"로 보내줌
factory.addErrorPages(errorPage404,errorPageEx,errorPage500);//등록
}
ErrorPage는 (HttpStatus, path)를 파라미터로 받는다. 따라서 HttpStatus의 에러가 발생했을 경우 path를 호출한다. 더 정확히 이야기 하자면 path에 해당하는 컨트롤러를 호출한다.
그리고 factory.addErrorPages를 통해 생성한 ErrorPage들을 등록한다.
컨트롤러는 다음과 같다.
@RequestMapping("/error-page/404")//POST와 GET등 모든 걸 다 받기 위해
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
printErrorInfo(request);
return "error-page/404";
}
따라서 error-page/404.html에 고객에게 보여줄 errorpage를 만들면 된다.
서블릿은 Exception (예외)가 발생해서 서블릿 밖으로 전달되어 WAS까지 전달되거나 또는 response.sendError() 가 호출 되었을 때 설정된 오류 페이지를 찾는다.
예외 발생 흐름
WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
sendError 흐름
WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러
(response.sendError())
WAS는 해당 예외를 처리하는 오류 페이지 정보를 확인한다.
new ErrorPage(RuntimeException.class, "/error-page/500")
예를 들어서 RuntimeException 예외가 WAS까지 전달되면, WAS는 오류 페이지 정보를 확인한다.
확인해보니 RuntimeException 의 오류 페이지로 /error-page/500 이 지정되어 있다. WAS는 오류 페이지를 출력하기 위해 /error-page/500 를 Http 요청이 다시온 것 처럼 (진짜로 다시 온것은 아님) 처음 부터 다시 요청한다.
오류 페이지 요청 흐름
WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View
예외 발생과 오류 페이지 요청 흐름 -> 고객은 한번 요청했는데 컨트롤러가 두번 호출됨
1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/errorpage/500)-> View
중요한 점은 웹 브라우저(클라이언트)는 서버 내부에서 이런 일이 일어나는지 전혀 모른다는 점이다. 오직 서버 내부에서 오류 페이지를 찾기 위해 추가적인 호출을 한다.
정리하면 다음과 같다.
1. 예외가 발생해서 WAS까지 전파된다.
2. WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다.
오류 정보 추가
WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 request 의 attribute 에 추가해서 넘겨준다.
필요하면 오류 페이지에서 이렇게 전달된 오류 정보를 사용할 수 있다.
//RequestDispatcher 상수로 정의되어 있음
public static final String ERROR_EXCEPTION = "javax.servlet.error.exception";
public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type";
public static final String ERROR_MESSAGE = "javax.servlet.error.message";
public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri";
public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name";
public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";
public void printErrorInfo(HttpServletRequest request) {
log.info("ERROR_EXCEPTION: {}",request.getAttribute(ERROR_EXCEPTION));
log.info("ERROR_EXCEPTION_TYPE: {}",request.getAttribute(ERROR_EXCEPTION_TYPE));
log.info("ERROR_MESSAGE: {}",request.getAttribute(ERROR_MESSAGE));
log.info("ERROR_REQUEST_URI: {}",request.getAttribute(ERROR_REQUEST_URI));
log.info("ERROR_SERVLET_NAME: {}",request.getAttribute(ERROR_SERVLET_NAME));
log.info("ERROR_STATUS_CODE: {}",request.getAttribute(ERROR_STATUS_CODE));
log.info("dispatchType={}", request.getDispatcherType());
}
위에서 예외가 발생했을 때 오류 페이지를 요청하는 흐름을 알아봤다.
처음 요청이 들어오면 필터가 적용된다. 하지만 오류가 발생하면 WAS는 내부에서 다시 웹 브라우저에 요청이 들어오는 것 처럼 처음 부터 요청된다 했으므로 필터가 다시 적용된다.
아래의 로그를 보면 필터가 두번 적용된것을 볼 수 있다.
하지만 이러한 필터나 인터셉터의 중복적용은 필요치 않은 상황이 많다. 우리가 했던 로그인만을 생각해도 이미 한번 체크한것을 또 체크하는것은 의미가 없다. 따라서 중복 호출은 비효츌적이므로 내부 요청시에는 필터를 다시 호출하지 않는 DispatcherType이라는 추가 정보를 제공한다.
2022-08-19 13:13:09.472 INFO 6064 --- [nio-8080-exec-8] hello.exception.filter.LogFilter : REQUEST [58508771-671b-4066-aee6-8d5ea0bfbca2][REQUEST][/error-ex]
2022-08-19 13:13:09.475 INFO 6064 --- [nio-8080-exec-8] hello.exception.filter.LogFilter : error info=Request processing failed; nested exception is java.lang.RuntimeException: 예외 발생!
2022-08-19 13:13:09.475 INFO 6064 --- [nio-8080-exec-8] hello.exception.filter.LogFilter : RESPONSE [58508771-671b-4066-aee6-8d5ea0bfbca2][REQUEST][/error-ex]
2022-08-19 13:13:09.477 ERROR 6064 --- [nio-8080-exec-8] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 예외 발생!] with root cause
java.lang.RuntimeException: 예외 발생!
at hello.exception.servlet.ServletExController.errorEx(ServletExController.java:16) ~[main/:na]
at javax.servlet.http.HttpServlet.service(HttpServlet.java:655) ~[tomcat-embed-core-9.0.65.jar:4.0.FR]
at javax.servlet.http.HttpServlet.service(HttpServlet.java:764) ~[tomcat-embed-core-9.0.65.jar:4.0.FR]
at hello.exception.filter.LogFilter.doFilter(LogFilter.java:25) ~[main/:na]
2022-08-19 13:13:09.478 INFO 6064 --- [nio-8080-exec-8] hello.exception.filter.LogFilter : REQUEST [48f2065c-4ed1-47ae-9fc5-0235fb09b113][ERROR][/error-page/500]
2022-08-19 13:13:09.479 INFO 6064 --- [nio-8080-exec-8] h.exception.servlet.ErrorPageController : errorPage 500
2022-08-19 13:13:09.480 INFO 6064 --- [nio-8080-exec-8] h.exception.servlet.ErrorPageController : ERROR_EXCEPTION: {}
java.lang.RuntimeException: 예외 발생!
at hello.exception.servlet.ServletExController.errorEx(ServletExController.java:16) ~[main/:na]
at javax.servlet.http.HttpServlet.service(HttpServlet.java:655) ~[tomcat-embed-core-9.0.65.jar:4.0.FR]
at javax.servlet.http.HttpServlet.service(HttpServlet.java:764) ~[tomcat-embed-core-9.0.65.jar:4.0.FR]
at hello.exception.filter.LogFilter.doFilter(LogFilter.java:25) ~[main/:na]
2022-08-19 13:13:09.480 INFO 6064 --- [nio-8080-exec-8] h.exception.servlet.ErrorPageController : ERROR_EXCEPTION_TYPE: class java.lang.RuntimeException
2022-08-19 13:13:09.481 INFO 6064 --- [nio-8080-exec-8] h.exception.servlet.ErrorPageController : ERROR_MESSAGE: Request processing failed; nested exception is java.lang.RuntimeException: 예외 발생!
2022-08-19 13:13:09.481 INFO 6064 --- [nio-8080-exec-8] h.exception.servlet.ErrorPageController : ERROR_REQUEST_URI: /error-ex
2022-08-19 13:13:09.481 INFO 6064 --- [nio-8080-exec-8] h.exception.servlet.ErrorPageController : ERROR_SERVLET_NAME: dispatcherServlet
2022-08-19 13:13:09.481 INFO 6064 --- [nio-8080-exec-8] h.exception.servlet.ErrorPageController : ERROR_STATUS_CODE: 500
2022-08-19 13:13:09.481 INFO 6064 --- [nio-8080-exec-8] h.exception.servlet.ErrorPageController : dispatchType=ERROR
2022-08-19 13:13:09.482 INFO 6064 --- [nio-8080-exec-8] hello.exception.filter.LogFilter : RESPONSE [48f2065c-4ed1-47ae-9fc5-0235fb09b113][ERROR][/error-page/500]
고객이 처음 요청하면 dispatcherType=REQUEST 이다.
로그 첫 줄을 보면 REQUEST [58508771-671b-4066-aee6-8d5ea0bfbca2][REQUEST][/error-ex]
로 [REQUEST]인 것을 볼 수 있다.
또한 마지막 줄에는 [ERROR]인 것을 확인할 수 있다.
이렇듯 서블릿 스펙은 실제 고객이 요청한 것인지, 서버가 내부에서 오류 페이지를 요청하는 것인지 DispatcherType 으로 구분할 수 있는 방법을 제공한다.
DispatcherType
빈을 설정할 때
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
이렇게 두 가지를 모두 넣으면 클라이언트 요청은 물론이고, 오류 페이지 요청에서도 필터가 호출된다.
아무것도 넣지 않으면 기본 값이 DispatcherType.REQUEST 이다. 즉 클라이언트의 요청이 있는 경우에만 필터가 적용된다. 특별히 오류 페이지 경로도 필터를 적용할 것이 아니면, 기본 값을 그대로 사용하면 된다. 물론 오류 페이지 요청 전용 필터를 적용하고 싶으면 DispatcherType.ERROR 만 지정하면 된다.
앞서 필터의 경우에는 필터를 등록할 때 어떤 DispatcherType 인 경우에 필터를 적용할 지 선택할 수 있었다. (filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
) 그런데 인터셉터는 서블릿이 제공하는 기능이 아니라 스프링이 제공하는 기능이다. 따라서 DispatcherType 과 무관하게 항상 호출된다.
대신에 인터셉터는 다음과 같이 요청 경로에 따라서 추가하거나 제외하기 쉽게 되어 있기 때문에, 이러한 설정을 사용해서 오류 페이지 경로를 excludePathPatterns 를 사용해서 빼주면 된다.
.excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
전체 흐름 정리
/hello
정상 요청
WAS(/hello, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> View
/error-ex
오류 요청
1. WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
2. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
3. WAS 오류 페이지 확인
4. WAS(/error-page/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) ->
컨트롤러(/error-page/500) -> View
스프링 부트는 위에서 배운 예외 처리 페이지를 만드는 기능들을 모두 기본으로 제공한다.
ErrorPage 를 자동으로 등록한다.
이때 /error 라는 경로로 기본 오류 페이지를 설정한다. 오류가 생겨 WAS까지 왔을 때 다른 에러 페이지들이 없으면 /error로 간다. 즉 default error page라고 생각하면 된다.
new ErrorPage("/error") , 상태코드와 예외를 설정하지 않으면 기본 오류 페이지로 사용된다.
서블릿 밖으로 예외가 발생하거나, response.sendError(...) 가 호출되면 모든 오류는 /error 를 호출하게 된다.
BasicErrorController 라는 스프링 컨트롤러를 자동으로 등록한다.
ErrorPage 에서 등록한 /error 를 매핑해서 처리하는 컨트롤러다.
따라서 오류가 발생하면 오류 페이지로 /error를 기본으로 요청하며 BasicErrorController가 이 경로를 기본으로 받는다. 중요한 점은 이 BasicErrorController의 기본적인 로직이 모두 개발되어있다는 것이다. 따라서 개발자가 할일은 오류페이지 화면을 컨트롤러가 제공하는 룰과 우선순위에 따라 등록하는 것 밖에 없다.
뷰 선택 우선순위
BasicErrorController 의 처리 순서
1. 뷰 템플릿
따라서 아래와 같다.
http://localhost:8080/error-404 -> 404.html
http://localhost:8080/error-400 -> 4xx.html (400 오류 페이지가 없지만 4xx가 있음)
http://localhost:8080/error-500 -> 500.html
http://localhost:8080/error-ex -> 500.html (예외는 500으로 처리)
오류 페이지는 왜 정적 리소스로 만들지 않고 뷰 템플릿으로 동적으로 만들까
BasicErrorController는 아래의 정보를 model에 담아 전달하기 때문에 오류페이지를 동적으로 만들면 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`)
하지만 오류와 관련된 내부 정보들을 고객에게 노출하는 것은 좋지 않다. 보안상의 문제가 되고, 고객들이 알아들을 수 없는 정보도 많기에 혼란을 가중시킨다. 따라서 컨트롤러에서 이러한 오류 정보들을 포함할 지 안할지를 선택할 수 있다.
기본 default 값은 아래와 같다.
server.error.include-exception=false : exception 포함 여부( true , false )
server.error.include-message=never : message 포함 여부
server.error.include-stacktrace=never : trace 포함 여부
server.error.include-binding-errors=never : errors 포함 여부
기본 값이 never 인 부분은 다음 3가지 옵션을 사용할 수 있다.
실무에서는 위의 오류 정보들을 출력하지 않고 고객들에겐 간단한 오류 설명을 보여주고 개발자는 서버에 로그를 남겨 오류를 로그로 확인해야한다.
스프링 부트 오류 관련 옵션
보통 기본값으로 많이 사용한다.
확장 포인트
에러 공통 처리 컨트롤러의 기능을 변경하고 싶으면 ErrorController 인터페이스를 상속 받아서
구현하거나 BasicErrorController 상속 받아서 기능을 추가하면 된다. 하지만 잘 사용하지 않는다.