Spring MVC : Error 관련
- Spring/Spring
- 2022. 1. 6.
이 포스팅은 인프런의 김영한님 강의를 복습하면서 작성하는 글입니다.
ERROR
클라이언트가 이런 저런 요청을 할 때, 서버 입장에서 피할 수 없는 것은 바로 에러다. 에러는 클라이언트가 잘못된 요청을 할 수도 있고, 아니면 서버 자체에 문제가 있어서 발생할 수도 있다. 여기서 숙지하고 넘어가야할 것은 에러의 원인이 여러가지가 있을 수 있다는 점이다.
에러의 원인이 여러가지라면 당연하게도 에러의 원인에 따라 다양한 조치가 들어가야한다. 예를 들어 클라이언트에서 잘못 요청한 것이 문제라면, 잘못 요청했다는 화면을 보여주면 된다. 예를 들어 서버에서 문제라면, 서버가 현재 문제라는 것을 보여주면 된다.
스프링에서는 컨트롤러에서 문제가 발생하면 기본적으로 Exception이 발생하며 TomCat 서블릿 컨테이너까지 이동한다. 그리고 서블릿 컨테이너는 Error를 보고, 기본 에러 페이지인 Whiteable 페이지를 보여준다. 즉, 에러의 원인 구분이 되어있지 않고, 에러가 발생했을 때 보여지는 화면의 구분도 되어 있지 않다.
이번 포스팅에서는 에러 처리를 하는 방법, 그리고 화면 구현을 나누는 방법 복습한 것을 작성하고자 한다. Servlet 기술로 먼저 구현을 한 다음, Spring MVC 기술로 구현하는 방법으로 넘어가겠다.
ERROR의 동작 방식
기본적으로 클라이언트 요청이 들어오고 나가는 흐름은 다음과 같다. 여기서 Servlet이라고 적혀있는 것은 Spring MVC의 경우 Dispatcher Servlet이다. 그렇다면 Handler에서 Exception이 발생한다면 어떻게 될까?
Handler에서 EXCEPTION이 발생하면, 보통 HANDLER는 EXCEPTION을 위로 던진다. 중간에 이런 Exception을 누가 잡아서 처리해준다면 괜찮다. 그렇지만 모두 Exception을 처리하지 않고 위로 던지기만 하면 Exception은 WAS에 도달한다.
Tomcat Servlet Container는 이 EXCEPTION을 확인한 다음, EXCEPTION을 처리하기 위한 재요청을 한다. 이 재요청은 처음 요청이 들어왔던 것과 동일한 루트로 요청 / 응답을 진행한다.
위의 내용을 한 줄로 정리하면 다음과 같다. "WAS에 예외가 전달되면, WAS는 예외를 처리하기 위한 요청을 내부적으로 한번 더 한다". 아래의 내용들을 구현하는 것은 이 전제를 정확히 이해를 하는 것이 먼저이다.
ERROR를 요청하는 방식
- 에러를 직접 생성 : throw new RuntimError();
- HttpServletResponse로 에러 전달 : reponse.sendError()
WAS에 ERROR를 요청하는 방식은 크게 위 두 가지가 있다. 첫번째 방법은 에러가 직접 WAS로 전달이 되는 방식이다. 따라서 Exception을 출력하면 실제로 Exception 내용이 출력이 된다.
반면 두번째 방법은 Exception이 실제로 만들어지지는 않는다. HttpResponseServlet의 특정한 저장소에 에러 코드와 에러 메세지를 넣어서 서블릿 컨테이너에 전달해준다. 서블릿 컨테이너는 항상 Response의 Error 저장소를 살펴보는데, 이 때 sendError를 사용한 흔적이 있는 경우 1번과 동일하게 처리를 해준다.
HttpServletRequest의 특별한 기능
private 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("dispatcherType={}", request.getDispatcherType());
}
에러가 발생해서 서블릿 컨테이너가 재요청을 해야하는 시점이 올 수 있다. 이 때 서블릿 컨테이너는 HttpServletRequest 객체에 에러와 관련된 정보를 자동으로 넣어준다. 그리고 개발자는 이 정보를 request.getParamter()를 통해서 확인할 수 있다.
이 때, 사용하는 파라메터는 RequestDispatcher를 Import하면 자동으로 사용할 수 있게 된다.
구현을 하기 전에 해야할 일 : Whitelabel disable 하기
server.error.whitelabel.enabled=false
가장 먼저 스프링이 기본으로 제공해주는 에러 메세지를 꺼야한다. application.properties에 위의 코드를 넣어주면 항상 뜨는 whitelable 페이지가 잠기게 된다.
테스트를 위한 에러 코드 컨트롤러 만들기
@Slf4j
@Controller
public class ServletExController {
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
@GetMapping("/error-404")
public void error404(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.sendError(404, "404 오류! ");
}
@GetMapping("/error-400")
public void error400(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.sendError(400, "400 오류! ");
}
@GetMapping("/error-500")
public void error500(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.sendError(500, "500 오류! ");
}
}
- 테스트를 위한 에러 유발 컨트롤러를 작성했다.
- sendError를 통해서 Error를 WAS에 전달하는 방법, 그리고 실제로 Exception을 만들어 throw하는 방법으로 구현했다.
위의 코드를 만들고 난 다음에 맵핑된 URL로 접근하면, Servlet이 기본적으로 제공하는 아주 개발자스러운 에러 페이지들을 확인할 수 있다. 이제 에러 페이지가 잘 나올 수 있도록 Servlet 기술을 사용한 것, Spring 기술을 사용한 것으로 나누어서 구현해보겠다.
서블릿 기술 사용하기
에러 페이지 등록
@Component
public class WebCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPage400 = new ErrorPage(HttpStatus.BAD_REQUEST, "/error-page/400");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage400,errorPage500,errorPage404,errorPageEx);
}
}
- WebServerFactoryCustomizer를 구현한 WebCustomizer 클래스를 만들어준다.
- 이 클래스는 Generic Type을 구현해야하는데 이 때 ConfigurableWebServerFacotory를 구현해줘야한다.
- new ErrorPage()를 통해 에러 발생유형과 이에 대응되어 호출될 에러 페이지 컨트롤러의 주소를 넣어준다.
- 만들어진 ERROR PAGE를 factory.addErrorpage()를 이용해 등록해준다.
위의 방식은 서블릿에서 사용하는 WebServerFacotryCustomizer의 구현체를 스프링 빈으로 등록하고, 거기에 저장된 에러 페이지 정보를 바탕으로 에러가 발생했을 때 대응을 한다. 이 때 특정 유형의 에러가 발생하면 어떤 컨트롤러를 호출할 지 ErrorPage객체를 통해서 지정해주었다. 이 내용을 Factory에 전달하면, 특정 에러가 발생하면 특정 컨트롤러가 불러지는 것까지 연결이 된다.
에러 페이지 컨트롤러 만들기
@Controller
@Slf4j
public class ErrorPageController {
@GetMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage404");
printInfo(request);
return "error-page/404";
}
@GetMapping("/error-page/400")
public String errorPage400(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage400");
printInfo(request);
return "error-page/400";
}
@GetMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage500");
printInfo(request);
return "error-page/500";
}
public void printInfo(HttpServletRequest request) {
log.info("ERROR_EXCEPTION : {}", request.getAttribute(ERROR_EXCEPTION));
log.info("ERROR_MESSAGE : {}", request.getAttribute(ERROR_MESSAGE));
}
}
- 대응되는 에러페이지 컨트롤러를 만들어준다.
- 에러가 발생해서 서블릿 컨테이너가 재요청을 할 때, 서블릿 컨테이너는 request에 에러와 관련된 정보를 넣어준다. 이것도 함께 출력할 수 있도록 했다.
- 대응되는 에러페이지를 불러줄 수 있도록 작성했다.
등록 결과
- 앞서 어떤 에러가 발생했을 때, 어떤 컨트롤러가 호출되어야 할지를 WebServerFactoryCustomizer를 통해 등록했다.
- 호출된 컨트롤러가 어떤 HTML을 랜더링할지를 등록해주었다.
위의 일을 한 후에 등록을 해주면 다음과 같이 오류 화면이 잘 뜨는 것을 확인할 수가 있다. 그런데 서블릿 기술을 사용하면서 불편한 점이 있다는 것을 알 수 있다.
- 에러 페이지를 직접 만들어주고, WebCustomizer에 내가 하나씩 다 등록을 해야한다.
- WebCustomizer에서 지정한 컨트롤러를 또 직접 구현해줘야한다.
위의 귀찮은 일들을 스프링 MVC는 대신 해준다. 스프링 MVC를 통해서 에러 페이지가 잘 나올 수 있도록 해보자.
스프링 MVC로 에러 페이지 구현
스프링 MVC는 스프링부트가 시작될 때, ErrorMvcAutoConfiguration 클래스가 자동으로 ErrorPage와 BasicErrorController라는 컨트롤러를 등록해준다. 이 BasicErrorController는 아래 두 가지 일을 해준다.
- ErrorPage 자동 등록 : 특정 에러가 발생했을 때, 특정 에러와 호출된 컨트롤러를 매칭시켜준다.
- 이 때 경로는 /error가 기본적으로 설정된다.
- BasicErrorController 등록
- ErrorPage에서 등록한 /error를 맵핑해서 처리하는 컨트롤러다.
스프링 에러페이지 View 등록
스프링은 에러 페이지를 보여주기 위해서 개발자가 해야할 많은 일들을 대신해준다. 개발자가 해야할 일은 에러가 발생했을 때, 보여줄 View만 등록을 해주면 된다. BasicErrorController가 처리하는 View의 우선순위는 다음과 같다
- resources/template/error (가장 먼저 살펴본다.)
- resources/templates/error/500.html (1순위)
- resources/templates/error/5xx.html (2순위)
- resources/static/error
- resources/static/error/400.html(3순위)
- resources/static/error/4xx.html(4순위)
- resources/error.html(5순위)
BasicErrorController : Model로 Error 정보 전달
* 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`)
BasicErrorController는 Model에 위 Error 정보를 담아서 전달해준다. 실제로 타임리프에서는 위 Model에 바로 접근해서 사용을 할 수 있다.
<li th:text="|timestamp: ${timestamp}|"></li>
<li th:text="|path: ${path}|"></li>
<li th:text="|status: ${status}|"></li>
<li th:text="|message: ${message}|"></li>
<li th:text="|error: ${error}|"></li>
<li th:text="|exception: ${exception}|"></li>
<li th:text="|errors: ${errors}|"></li>
<li th:text="|trace: ${trace}|"></li>
타임리프에서는 위와 같이 ${변수이름}으로 바로 접근이 가능하다. 위의 값들을 출력해보면 다음과 같다.
오류 화면에 왜 오류가 났는지 자세한 정보를 담아서 전달할 수 있다. 그런데 이 방법은 좋은 방법이 아니다. 왜냐하면 해커들이 이런 정보를 보고 어떻게 해킹해야겠다는 계획을 짤 수 있기 때문이다. 따라서, 이런 정보를 전달하지 않기 위해서 아래 설정으로 해결할 수 있다.
BasicErrorController : Model로 Error 정보 View까지는 안가게 하기
server.error.include-message=never
server.error.include-binding-errors=never
server.error.include-stacktrace=never
server.error.include-exception=false
application.properites에 위 설정을 추가해주면 Model에 담긴 Error정보가 View까지 전달되는 것을 막을 수 있다. 그렇지만 에러 페이지 컨트롤러까지는 Model에 데이터가 담아서 넘어와지기 때문에 로그로 값을 볼 수 있다.
Exception 발생과 Servlet Filter
서블릿 컨테이너가 Exception을 감지하면 WAS는 다시 한번 Exception 처리를 위한 요청을 실행한다. 이 때, 요청을 새로 시작하는 것이기 때문에 기존의 Filter를 다시 한번 지나가게 된다. 그런데 이전에 이미 처리된 Filter인데 굳이 다시 한번 FILTER를 처리할 필요가 있을까? 그렇게 하지 않는 것이 더 좋다!가 Best Practice에 가깝지 않을까 싶다. 따라서 Exception이 터진 후, Filter는 Skip하는 것이 좋을 것 같다. 그렇다면 어떻게 이걸 처리할 수 있을까?
HttpServletRequest는 요청할 때, DispathcerType이라는 필드를 가진다. 이 필드를 구분해서 사용하면서 우리는 Exception에 의한 요청 시, 필터를 제외할 수 있다.
- 정상 요청 : DispatcherType = REQUEST
- Exception 재요청 : DispatcherType = ERROR
FilterRegistrationBeanFactory에 Filter를 등록할 때, setDispatcherType을 통해 Request의 DispacherType에 대해 선택적으로 동작할 수 있도록 셋팅이 가능하다. 단, 이 때 기본적으로 DispatcherType은 "REQUEST"로만 되어있기 때문에 아무 설정도 하지 않으면, Exception 상황에서는 자연스레 Filter를 건너뛴다는 것을 이해하자.
코드로 이해하기 / DispatcherType을 고려한 필터 등록
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setDispatcherTypes(DispatcherType.ERROR);
return filterRegistrationBean;
}
- FilterRegistrationBeanFactory를 등록할 때, setDispatcher(...)로 DispatcherType을 지정할 수 있다.
- DispatcherType을 ERROR, REQUEST로 등록해주면 ERROR, REQUEST에 있어서 항상 Filter가 적용된다.
실제 동작유무 확인하기
DispatcherType = REQUEST, ERROR로 등록했을 때
- 처음에 Dispacher는 REQUEST로 들어와서 Handler에서 Exception이 발생했다. RESPONSE까지 REQUEST로 나간다.
- Dispatcher는 ERROR로 되어 다시 한번 REQUEST, RESPONSE 되는 것을 확인했다.
DispatcherType = REQUEST만 등록
- LOG 필터는 DISPATCHER TYPE REQUEST에만 적용이 된다.
- ERROR로 다시 한번 요청이 되는데, 이 때는 필터 설정값에 의해서 필터가 적용되지 않는다.
Exception 발생과 Spring Interceptor
Interceptor도 Filter와 크게 다르지 않다. 요청 시, Exception이 발생하고 그 Exception이 서블릿 컨테이너까지 전달되면 서블릿 컨테이너는 다시 한번 재요청을 한다. 이 때, 당연하지만 Interceptor도 지나가게 된다. 마찬가지로 Interceptor도 다시 한번 실행을 할 필요는 없을 가능성이 농후하다. 그렇다면 Interceptor는 어떻게 다시 한번 ERROR 요청하는 것을 무시하게 할 수 있을까?
Interceptor는 이 떄 excludePath라는 기능을 활용해서 Interceptor를 넘어가도록 한다. Interceptor를 등록할 때, excludePath로 이 인터셉터를 스킵할 요청을 지정할 수 있다. 이 때, 에러 페이지 컨트롤러가 호출되는 경로를 excludePath에 등록해서 처리를 할 수 있다!
그렇다면 어떤 경로로 에러 페이지 컨트롤러가 요청되는지를 알아야 한다. 그런데 우리는 앞에서 그 부분에 대해서 공부를 했다. 스프링 부트는 ErrorPage에 자동으로 /error 경로로 컨트롤러 Mapping을 해준다고 했다. 따라서, /error 경로로 가는 path를 차단하게 되면 Interceptor가 동작하지 않는다.
코드로 알아보기
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/*", "/*.ico", "/error");
}
- 스프링에서 제공하는 에러 페이지는 BasicErrorController를 통해서 모든 페이지가 처리된다.
- BasicController의 Mapping은 "/error"로 되어있기 때문에 해당 Path를 excludePath에 넣어주면 된다.
- 에러 페이지 Servlet을 직접 등록해서 사용한다면, 해당 컨트롤러가 호출되지 않도록 excludePath에 적절히 등록해주면 된다.
'Spring > Spring' 카테고리의 다른 글
스프링의 이해 : 로그 추적기 개발(쓰레드 로컬까지 적용) (0) | 2022.01.25 |
---|---|
@RequiredArgsConstruct (0) | 2022.01.11 |
@Autowired가 붙었을 때 (0) | 2021.11.13 |
빈 스코프, Provider (0) | 2021.11.12 |
빈 생명주기 콜백 (0) | 2021.11.12 |