Language`/Spring

[Spring] 예외 처리 & 오류 페이지

Avenus 2022. 7. 11. 22:01

Servlet 예외 처리

1. Exception

Exception은 중간에서 "catch"를 통해서 잡을 수 있고, 아니면 계속 "throws"를 통해서 던질 수 있다

 

웹 애플리케이션은 Request마다 별도의 thread가 할당되고 이 thread는 서블릿 컨테이너 내부에서 수행된다

그런데 갑자기 예외가 터졌는데 이 예외를 잡지 않고 계속 throw를 하다가 결국 서블릿 외부까지 넘어간다면 어떻게 동작될까??

>> 최종적으로 WAS까지 전파된다

예외를 전파받은 WAS는 뒤에 설명하겠지만 예외 페이지를 Client에게 보여주기 위해서 "예외 전용 Request"를 또 다시 컨트롤러에게 요청한다

 

간단한 예제를 통해서 정상 흐름과 예외가 터졌을 때의 흐름을 살펴보자

LogFilter

@Slf4j
public class LogFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("----- 서블릿 필터 (doFilter) 호출 [BY REQUEST] -----");
        String uuid = UUID.randomUUID().toString();

        try{
            log.info("REQUEST -> [{}][{}]", uuid, request.getDispatcherType());
            chain.doFilter(request, response);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("----- 서블릿 필터 (doFilter) 호출 [BY RESPONSE] -----");
            log.info("RESPONSE -> [{}][{}]", uuid, request.getDispatcherType());
        }
    }
}

LogInterceptor

@Slf4j
public class LogInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("----- 스프링 인터셉터 (preHandle) 호출 -----");
        String uuid = UUID.randomUUID().toString();

        log.info("REQUEST -> [{}][{}]", uuid, request.getDispatcherType());

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("----- 스프링 인터셉터 (postHandle) 호출 -----");
        String uuid = UUID.randomUUID().toString();

        log.info("RESPONSE -> [{}][{}]", uuid, request.getDispatcherType());
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info("----- 스프링 인터셉터 (afterCompletion) 호출 -----");

        if(ex == null){
            log.info("컨트롤러에서 예외 발생 X!!");
        } else {
            log.info("컨트롤러에서 예외 발생... ", ex);
        }
    }
}

LogController

@Slf4j
@RestController
public class LogController {
    @RequestMapping("/exception")
    public String exception(){
        log.info("컨트롤러 호출!!");
        return "ok";
    }
}

먼저 Request에 대해서는 WAS → Filter → Servlet → Interceptor → Controller의 흐름을 가지고 있다.

Controller에서 Response할 때는 Controller → Interceptor → Servlet → Filter → WAS의 흐름을 가지고 있다

 

그러면 이제 Controller에서 예외를 터뜨려보자

@Slf4j
@RestController
public class LogController {
    @RequestMapping("/exception")
    public String exception(){
        throw new RuntimeException("런타임 예외!!");
    }
}

afterCompletion에서 구현한대로 컨트롤러에서 예외가 발생했다고 알려주고 실제로 런타임 예외가 던져졌다.

이 다음 로그를 더 살펴보자

>> 예외가 터졌는데 WAS에서는 왜 다시 Request를 보내는 걸까?

기본적으로 예외가 터지면 스프링 부트에서는 "예외에 대한 예외 처리 화면"을 보여준다. 따라서 이 예외 처리 화면을 보여주기 위해서 컨트롤러에 다시 GET Request를 보내는 것이다

 

그리고 노란색으로 표시한 부분을 보면 처음 Client의 Request에서는 REQUEST로 표시되었지만 "WAS가 예외 페이지를 위한 Request를 보낸 지금"ERROR라고 표시되었다

 

구분선 이전이 Client의 Request이고, 구분선 이후가 WAS의 "예외 처리 페이지를 위한 Request"이다

그리고 서버 내부에서 throw된 예외에 대해서 WAS는 "이 예외는 서버에서 처리못한 심각한 오류이구나"라고 생각해서 500 에러 페이지 view를 request한다

 

2. response.sendError

response를 통한 예외 처리는 Exception과 달리 서블릿에게 오류가 발생했다는 점을 "전달"할 수 있다

@RequestMapping("/error-404")
public void error404(HttpServletResponse response) throws IOException {
    response.sendError(HttpServletResponse.SC_NOT_FOUND, "404 에러!!");
}

@RequestMapping("/error-500")
public void error500(HttpServletResponse response) throws IOException {
    response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "500 에러!!");
}

Exception서버 내부에서 예외를 그냥 던지게되면 WAS는 무조건 500 에러로 이해하지만, response어떤 예외가 발생할지 response에 적어서 보내기 때문에 예외를 구분할 수 있다


DispatcherType

DispatcherType은 "서블릿 필터"가 제공해주는 옵션이다

이전에 봤듯이 Client의 Request와 WAS의 예외 페이지를 위한 Request는 분명히 다른 Request인데 이를 구분할 무언가가 필요하다. 그 무언가가 바로 "DispatcherType"이다

DispatcherType에는 총 5가지 종류가 있다

REQUEST : 클라이언트의 요청
ERROR : 오류 요청
FORWARD : 서블릿에서 {다른 서블릿 or JSP}를 호출할 때
INCLUDE : 서블릿에서 {다른 서블릿 or JSP}의 결과를 포함할 때
ASYNC : 서블릿 비동기 호출

그러면 이 DispatcherType을 필터에 어떻게 적용하는지 살펴보자

@Bean
public FilterRegistrationBean filterRegistrationBean(){
    FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
    registrationBean.setFilter(new LogFilter());
    registrationBean.setOrder(1);
    registrationBean.addUrlPatterns("/*");
    registrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
    return registrationBean;
}

기본적으로 필터를 등록할 때 FilterRegistrationBean을 스프링 빈으로 등록해주는데 이 때 "setDispatcherTypes"를 지정할 수 있다

>> 여기서 지정하는 DispatcherType일 경우에만 Filter가 동작한다

  • 기본 값은 REQUEST하나이고 ERROR는 WAS의 Request를 구분하기 위해서 넣어주었다
서블릿 필터는 서블릿에서 제공해주는 기능이고 따라서 서블릿이 제공해주는 DispatcherType을 사용할 수 있다
하지만 스프링 인터셉터의 경우 스프링이 제공해주는 기능이므로 DispatcherType을 사용할 수 없다. 그대신 더 정교하고 세밀한 URL 매핑을 할 수 있다

오류 페이지

과거에는 web.xml이라는 파일에 따로 오류 화면을 등록했지만, 스프링 부트를 통해서 서블릿 컨테이너를 실행하면 스프링 부트가 제공해주는 기능을 활용하면 된다

@Component
public class ErrorConfig implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {

    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"); // response.sendError
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500"); // response.sendError
        ErrorPage errorPageException = new ErrorPage(RuntimeException.class, "/error-page/500"); // Exception

        factory.addErrorPages(errorPage404, errorPage500, errorPageException);
    }
}

이처럼 WebServerFactoryCustomizer를 implements해서 factory에 직접 구현한 에러 페이지들을 add해주면 된다

  • 각 에러페이지의 "Path"는 URL에 해당한다
@Controller
public class ErrorController {
    @RequestMapping("/error-page/404")
    public String error404(){
        return "error-page/404";
    }

    @RequestMapping("/error-page/500")
    public String error500(){
        return "error-page/500";
    }
}

이제 해당 URL로 Request를 보내서 제대로 예외 페이지가 구현되었나 확인해보자

 

커스텀한 예외 페이지가 잘 매핑이 되는 것을 확인하였다

 


스프링 부트 오류 페이지

위에서 예외 처리 페이지를 만들기 위해서 다음과 같은 복잡한 과정을 거쳤다

1. WebServerCustomizer 구현
2. 예외 종류에 따라서 ErrorPage 생성해서 추가
3. 예외 처리용 Controller 생성

>> 이러한 과정들을 스프링 부트는 전부 기본으로 제공해준다

 

이제 스프링 부트의 자동화 과정을 알아보자

1. "/error"에 에러 페이지를 만들어주면 ErrorPage를 자동으로 생성해준다
-> 서블릿 밖으로 예외 발생 or response.sendError가 호출되면 모든 오류는 /error를 호출

2. "BasicErrorController"라는 스프링 컨트롤러를 자동 등록
-> 이 컨트롤러는 ErrorPage에서 등록한 "/error"를 매핑해서 처리하는 컨트롤러이다

{오류 페이지를 자동으로 등록해주는 역할은 ErrorMvcAutoConfiguration라는 클래스가 해준다}

이 두가지 메소드가 "/error"를 처리한다

errorHtml은 request message의 accept header value가 "text/html"로 설정되어 있을 경우 호출된다

error"text/html"이 아닌 이외의 경우 호출되는 메소드이다

 

"따라서 개발자는 오류 페이지만 "/error"에 등록해주면 스프링 부트가 알아서 전부 해준다"


View 선택 우선순위

BasicErrorController가 View를 선택하는 우선순위는 다음과 같다

  • 무조건 구체적이고 자세할수록 우선순위가 높다

1. View Template

(1) resource/templates/error/500.html

(2) resource/templates/error/5xx.html

500 에러는 500.html에서 처리하고 500이 아닌 {502, 503, ..}같은 5xx 계열은 5xx.html에서 처리한다

 

2. 정적 리소스

(1) resource/static/error/404.html

(2) resource/static/error/4xx.html

 

3. 적용 대상이 없을 때 (error)

→ resources/templates/error.html

 

@RequestMapping("/error404")
public void error404(HttpServletResponse response) throws IOException {
    response.sendError(HttpServletResponse.SC_NOT_FOUND, "404");
}

@RequestMapping("/error500")
public void error500(HttpServletResponse response) throws IOException {
    response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "500");
}

@RequestMapping("/error4xx")
public void error4xx(HttpServletResponse response) throws IOException {
    response.sendError(HttpServletResponse.SC_BAD_REQUEST, "400");
}

@RequestMapping("/error5xx")
public void error5xx(HttpServletResponse response) throws IOException {
    response.sendError(HttpServletResponse.SC_BAD_GATEWAY, "502");
}

404 에러 / 500 에러
400 에러 / 502 에러