BackEnd/Spring MVC

[Spring] 예외 처리와 오류 페이지 - 1

샤아이인 2022. 3. 14.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

 

1. 서블릿 예외 처리 - 시작

서블릿은 2가지 방식으로 예외 처리를 지원해 준다.

1) Exception

2) response.sendError(HTTP 상태 코드, 오류 메시지)

 

웹 어플리케이션 에서는 사용자 요청별로 thread가 할당된다. 해당 thread에서 예외가 발생했는데, try - catch로 예외를 처리하지 못한다면 어떻게 될까? => 톰캣 같은 WAS 까지 예외가 전달된다.

WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
 

이를 확인해보기 위해서 컨트롤러를 하나 만들어보자.

기존의 whitelabel.enabled는 false로 변경해 주자! 그래야 tomcat이 처리하는 화면을 볼 수 있다!

 

● ServletExController - 서블릿 예외 컨트롤러

@Slf4j
@Controller
public class ServletExController {

    @GetMapping("/error-ex")
    public void errorEx() {
        throw new RuntimeException("예외 발생!"); 
    }
}
 

위 컨트롤러를 실행하면 tomcat이 기본적으로 제공하는 오류 화면을 볼수가 있다.

에러코드는 500을 보여준다. Exception 의 경우 서버 내부에서 처리할 수 없는 오류가 발생한 것으로 생각해서 HTTP 상태 코드 500을 반환한다.

 

또한 리소스가 없는 아무 페이지나 호출하면, 톰켓이 기본적으로 제공하는 404 - Not Found를 확인할수 있다.

 

● response.sendError(HTTP 상태 코드, 오류 메시지)

오류가 발생했을 때 HttpServletResponse 가 제공하는 sendError 라는 메서드를 사용해도 된다.

이것을 호출한다고 당장 예외가 발생하는 것은 아니지만, 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.

@Slf4j
@Controller
public class ServletExController {

    @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 오류!");
    }
}
 

response.sendError() 메서드를 호출하면 response 내부에는 오류가 발생했다는 상태를 기록해둔다.

당장 예외를 발생시키는 않지만, 서블릿 컨테이너(WAS)까지 response가 전달됬을때 고객에게 응답 전에 response 에 sendError() 가 호출된적이 있는지 우선 확인한다.

그리고 호출되었다면 설정한 오류 코드에 맞추어 기본 오류 페이지를 보여준다.

WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 (response.sendError())
 

2. 서블릿 예외 처리 - 오류 화면 제공

위에서 만든 컨트롤러로 예외를 발생시키면, WAS가 보여주는 기본 예외 처리 화면은 고객 친화적이지가 않다.

 

이를 위해 서블릿은 exception이 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError()가 호출되었을때 상황에 맞춘 오류 처리 기능을 제공한다.

 

스프링 부트를 통해서 서블릿 컨테이너를 실행하기 때문에, 스프링 부트가 제공하는 기능을 사용해서 서블릿 오류 페이지를 등록하면 된다.

 

● 서블릿 오류 페이지 등록

@Component
public class WebServerCustomizer 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 errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");

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

500 예외가 서버 내부에서 발생한 오류라는 뜻을 포함하고 있기 때문에 여기서는 예외가 발생한 경우도 500 오류 화면으로 처리했다.

 

오류가 발생했을 때 처리할 수 있는 컨트롤러가 필요하다.

예를 들어서 RuntimeException 예외가 발생하면 errorPageEx 에서 지정한 /error-page/500 이 호출된다.

@Slf4j
@Controller
public class ErrorPageController {

    @RequestMapping("/error-page/404")
    public String errorPage404(HttpServletRequest request, HttpServletResponse response){
        log.info("errorPage 404");
        return "error-page/404";
    }

    @RequestMapping("/error-page/500")
    public String errorPage500(HttpServletRequest request, HttpServletResponse response){
        log.info("errorPage 500");
        return "error-page/500";
    }
}
 

(html 파일은 생략)

http://localhost:8080/error-404 로 접속하면 다음과 같은 화면을 볼수 있다.

http://localhost:8080/error-ex, http://localhost:8080/error-500 로 접속하면 다음과 같이 500 에러를 볼 수 있다.

3. 서블릿 예외 처리 - 오류 페이지 작동 원리

예외가 WAS까지 전달되면 WAS는 예외를 처리하는 오류 페이지 정보를 확인한다.

new ErrorPage(RuntimeException.class, "/error-page/500")
 

예를 들어서 RuntimeException 예외가 WAS까지 전달되면, WAS는 오류 페이지 정보를 확인한다.

위와 같이 확인해보니 RuntimeException 의 오류 페이지로 /error-page/500 이 지정되어 있다.

WAS는 오류 페이지를 출력하기 위해 /error-page/500 를 다시 요청한다.

 

● 예외 발생과 오류 페이지 요청 흐름

1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error- page/500) -> View
 

클라이언트 입장에서는 내부적으로 WAS가 오류 페이지로 재요청을 보내는것을 모른다.

 

● 오류 정보 추가하기

WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 request 의 attribute 에 추가해서 넘겨준다.

이를 활용하여 오류 정보를 확인해볼수 있다.

@Slf4j
@Controller
public class ErrorPageController {

    // 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";

    @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("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());
    }
}
 

이제 예외를 던지는 error-ex에 접근하면 다음과 같은 결과를 얻을 수 있다.

(ps. cmd + shift + =>(방향키) 단어 끝으로 이동)

 

4. 서블릿 예외 처리 - 필터

오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 한번 호출이 발생한다.

이때 필터, 서블릿, 인터셉터도 모두 다시 호출된다.

그런데 로그인 인증 체크 같은 경우를 생각해보면, 이미 한번 필터나, 인터셉터에서 로그인 체크를 완료했다.

따라서 서버 내부에서 오류 페이지를 호출한다고 해서 해당 필터나 인터셉트가 한번 더 호출되는 것은 매우 비효율적이다.

 

이를 위해서는 클라이언트로부터 전달된 요청이 정상인지? 오류 페이지 출력을 위한 내부 요청인지? 구분해야 한다.

서블릿은 이를 위해 DispatcherType을 사용한다.

 

● DispatcherType

고객이 처음 요청올때는 dispatcherType=REQUEST 로 전달된다.

이후 에러가 발생하면 바로 위에서 살펴봤듯 dispatcherType=ERROR 을 전달한다. dispatcherType를 통해 요청을 구분할수가 있다.

 

● 필터와 DispatcherType

@Slf4j
public class LogFilter implements Filter{

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("log filter doFilter");
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();

        String uuid = UUID.randomUUID().toString();

        try{
            log.info("REQUEST [{}][{}][{}]", uuid, request.getDispatcherType(), requestURI);
            chain.doFilter(request, response);
        }catch (Exception e){
            throw e;
        }finally {
            log.info("RESPONSE [{}][{}][{}]", uuid, request.getDispatcherType(), requestURI);
        }
    }

    @Override
    public void destroy() {
        log.info("log filter destroy");
    }
}
 

 

● WebConfig

@Component
public class WebConfig implements WebMvcConfigurer {

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

필터를 새롭게 등록했다.

filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
 

REQUEST, ERROR 두가지를 모두 등록하였다. 따라서 클라이언트 요청은 물론이고, 오류 페이지 요청에서도 필터가 호출된다.

 

다음 예를 살펴보자.

 

1) /error-ex 접속

처음 /error-ex로 이동하면 DispatcherType.REQUEST 로 필터를 통과하게 된다.

컨트롤러 까지 도착하면 throw new RuntimeException이 던저지게 된다.

 

이후 다시 WAS까지 예외가 올라가면서 다음과 같이 출력하게 된다.

위 REQUEST, RESPONSE는 filter에서 출력된 부분이다.

 

2) WAS에서 에러페이지로 재 요청

WAS는 예외를 인지하고 이전에 등록해둔 사용자 등록 예외페이지에서 해당 예외 페이지를 찾아 재요청 을 보낸다.

보낼때는 다음과 같이 ERROR 로 /error-page/500 으로 재요청을 보내고 있다.

추가적으로 아무것도 넣지 않으면 기본 값이 DispatcherType.REQUEST 이다.

즉 아무것도 적지 않으면 클라이언트의 요청이 있는 경우에만 필터가 적용된다.

특별히 오류 페이지 경로도 필터를 적용할 것이 아니면, 기본 값을 그대로 사용하면 된다.

 

5. 서블릿 예외 처리 - 인터셉터

인터셉터도 흐름은 필터와 거의 비슷하다. 다만 인터셉터는 DispatcherType이 없다! 그럼 어떻게 처리할까? 이를 알아가 보자!

 

● LogInterceptor - DispatcherType 로그 추가

@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);

        if(handler instanceof HandlerMethod){
            HandlerMethod hm = (HandlerMethod) handler;
        }

        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, handler);
        if(ex != null){
            log.error("afterCompletion error!!", ex);
        }
    }
}
 

인터셉터는 서블릿이 제공하는 기능이 아니라, 스프링이 제공하는 기능이다. 따라서 DispatcherType과 무관하게 항상 호출된다.

 

대신에 인터셉터는 다음과 같이 요청 경로에 따라서 추가하거나 제외하기 쉽게 되어 있기 때문에, 이러한 설정을 사용해서 오류 페이지 경로를 excludePathPatterns 를 사용해서 빼주면 된다.

@Component
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
    }
}
 

"/error-page/**" 를 통해 error-page 하부로 들어오는 모든 요청을 무시하게 된다.

 

추가적으로 인터셉터는 처음 사용자 요청이 들어올때 컨트롤러에서 예외가 발생하여 postHandle이 작동하지 않는다.

이후 WAS에서 에러 페이지 요청이 들어올때는 컨트롤러가 정상작동하기 때문에 postHandle이 작동한다.

 

● 전체 흐름 정리하기

필터는 DispatchType 으로 중복 호출 제거 ( dispatchType=REQUEST )

인터셉터는 경로 정보로 중복 호출 제거( excludePathPatterns("/error-page/**") )

1. WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
2. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
3. WAS 오류 페이지 확인
4. WAS(/error-page/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트롤러(/error-page/500) -> View
 

뭔가 아직 불편하다. 예외 처리 페이지를 하나하나 추가해주는 것도 불편하고, 뭔가 스프링이 더 우릴 도와줄것 같다.

이를 다음글에서 알아보자!

댓글