Framework/Spring

[Spring] 쉬우면서 정확하게 익혀보는 필터와 인터셉터의 예외처리 흐름과 예외 페이지 응답

leegeonwoo 2024. 9. 4. 00:43

스프링을 사용하지 않는 순수 서블릿 컨테이너는 Exceptionresponse.sendError(Http상태코드, 오류메시지)
두 가지 방식으로 예외를 처리한다.

 

 

Exception으로 처리하기

기본적으로 자바는 예외가 발생하면 예외를 다음 메서드로 계속 넘기다가 main()메서드가 종료될 때 까지 예외를 처리하지 못할 경우 예외 정보를 남기고 해당 쓰레드는 종료된다.

웹 애플리케이션은 쓰레드가 하나만 있는것이 아니라 사용자별로 각각의 쓰레드가 할당된다.
애플리케이션 어딘가에서 예외가 발생하고 그 예외를 처리하지 않고 서블릿 밖으로 까지 예외가 전달된다면 어떻게 될까?

컨트롤러(예외발생) -> 인터셉터 -> 서블릿 -> 필터 -> WAS
예외는 타고타고 올라가서 WAS까지 전달되며 WAS는 서버에서 발생한 예외이기때문에 500 Internal Server Error를 발생하고 서블릿에 기본값으로 지정된 해당 에러 페이지를 보여준다.

response.sendError를 사용하면 인자를 통해서 임의로 상태코드와 오류메시지를 지정하여 페이지를 보여줄 수 있다.
이 때 sendError에 담긴 상태코드와 오류메시지 역시 WAS에서 확인한다.

 

 

 

오류 화면 등록

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 errorPageRuntime = new ErrorPage(RuntimeException.class, "/error-page/RuntimeException");  

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

위 코드와 같이 WebServerFactoryCustomizer를 구현하면 customize메서드를 오버라이드해서 ErrorPage를 정의해 놓을 수 있다.
ErrorPage(예외 또는 HTTP상태코드, 처리할 경로)

ErrorPage가 어떻게 브라우저를 통해 보여지는지 알아보자.
만약 컨트롤러에서 RuntimeException이 발생되면 이 예외는 컨트롤러, 인터셉터, 필터...를 거쳐서 최종적으로는 WAS에 예외가 전달된다.
WAS는 전달받은 예외에 맞는 ErrorPage가 등록되어있는지 확인한다.
errorPageRuntime이라는 객체로 정의되어있기때문에 /error-page/RuntimeException을 호출한다.
이 과정에서 WAS->필터->인터셉터->컨트롤러를 다시 거쳐 View로 에러 페이지가 브라우저에 보여지는 것이다.
마치 HTTP 요청을 다시 보낸 것 같지만 실제로는 그렇지 않다.
참고로 오류페이지를 다시 불러오는 과정에서 request.attribute에 오류에 대한 정보를 추가해서 넘겨준다.

 

 

 

필터, 인터셉터가 두 번 호출되는 문제

위 내용을 이해했다면 WAS가 에러를 인지하고 error-page를 호출하는 과정에서 필터와 인터셉터가 다시 호출되는 것을 알 수 있다.
이미 처음 HTTP요청을 하는과정에서 필터와 인터셉터가 처리해야할 로직을 수행했지만 중복해서 수행하게 되어 불필요한 과정을 수행하게 되는 것이다.

서블릿에서는 이러한 문제를 해결하기위해서 DispatcherType이라는 정보를 제공한다.

DispatcherType은 Enum으로 정의되어있으며

  • REQUEST
  • ERROR
  • FORWARD
  • INCLUDE
  • ASUNC
    중 하나의 값을 가질 수 있다.

애플리케이션 로직에서 예외가 발생하고 WAS에서 해당 예외를 확인한 뒤, error-page를 호출하기위해 임의의 요청을 다시 애플리케이션으로 보낼 때, DispatcherType의 값을 ERROR로 설정해서 보낸다.
이렇게하면 애플리케이션은 해당 요청(?)이 일반적인 HTTP 요청인지 아니면 error-page를 출력하기위한 요청인지 구분할 수 있게되는것이다. 참고로 일반 HTTP요청은 REQUEST값이 설정되서 나간다.

이 값을 통해서 서블릿 필터의 불필요한 동작을 예방할 수 있을 것이다.

@Slf4j  
public class LogFilter implements Filter {  

    @Override  
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain){  
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;  
        String requestURI = httpRequest.getRequestURI();  

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

        log.info("REQUEST [{}][{}][{}]", uuid, servletRequest.getDispatcherType(), requestURI);  
        filterChain.doFilter(httpRequest, servletResponse);  

    }  

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

먼저 DispathcherType필드가 어떻게 동작하는지 확인하기위해 LogFilter라는 로그를 찍어주는 역할을 하는 서블릿 필터 클래스를 하나 생성하자.

첫번째 출력되는 로그는 식별자, DispatcherType의 값, 요청 URI를 출력할 것이다.
이 때 예상되는 실행결과는

  • UUID = 임의의 UUID
  • DispatcherType = REQUEST
  • requestURI = /error-ex
    일 것이다.
@Configuration  
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);  
        // 이 필터(LogFilter)는 REQUEST와 ERROR요청시에 사용된다.  

        return filterRegistrationBean;  
    }  
}

그리고 생성한 서블릿 필터를 적용하기위해 설정클래스인 WebConfig를 생성해주고 해당 필터를 적용해보자.

  • setFilter: 적용할 필터를 설정해준다
  • setOrder: 적용한 필터의 동작 순서를 정한다
  • addUrlPatterns: 필터가 수행하기위한 Request URI를 설정해준다
  • setDispatcherType: 필터를 수행하기위한 DispatcherType을 설정해준다

위 코드를 기반으로 동작을 유추해보자면,
LogFilter는 클라이언트로부터 요청이 들어올때 첫번째로 동작하며 이 때 동작하기위한 URI조건은 /* 즉, 모든 요청에 LogFilter가 동작되도록한다.
또한 해당 요청의 DispatcherTypeREQUEST, ERROR일 경우에만 동작하게 된다.

자 그럼 LogFilterWebConfig를 적용해준 상태로 애플리케이션을 실행하고 /error-ex요청을 하면 어떻게 될까? (요청시 컨트롤러에서 RuntimeException발생)

먼저 DispatcherType=REQUEST인 상태에서 HTTP 정상요청이 들어오고
컨트롤러에서 예외가 발생하고 이 예외에 대한 처리를 하지 않았기때문에 WAS까지 예외가 올라간다
그리고 WAS는 등록된 error-page목록을 확인한 뒤 유효한 예외라면, 다시 서블릿, 스프링 컨테이너로 돌려보낸다 (마치 HTTP 요청을 다시 보내는것같음)
그 과정에서 WAS는 DispatcherType=ERROR로 설정하기때문에 서블릿 필터나 인터셉터에서 해당 상태를 구분할 수 있다!

 

 

실제 실행결과

[exception] .... : REQUEST [uuid][REQUEST][/error-ex]
[Request processing failed: java.lang.RuntimeException.....

java.lang.RuntimeException: 예외 발생

REQUEST [8a3d00a6-d7d5-4089-9071-fd9625b4e292][ERROR][/error-page/500]
errorPage 500

(불필요한 예외 메시지는 제거하거나 ...으로 생략했다)
[REQUEST], [ERROR] 각각 한 번씩 출력되는 것을 확인할 수 있다.

 

 

 

ㅇㅋㅇㅋ필터는 알겠어요, 그럼 인터셉터는요?

인터셉터도 서블릿 필터와 매우 유사하게 실제 HTTP요청인지, error-page출력을 위한 요청(?)인지 구분할 수 있다.
차이점은 서블릿 필터는 DispatcherType=ERROR라는 값을 통해 구분했지만,
스프링 인터셉터는 excludePathPatterns()메서드의 인자에 인터셉터의 동작을 원치않는 요청경로를 설정해주면된다.

먼저 이전에 사용한 FilterRegistrationBean@Bean또는 해당 코드 자체를 주석처리해주자

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

간략하게 각각의 핸들링 메서드에대해 설명해보자면

  • preHandle: 컨트롤러 메서드 호출전에 실행
  • postHandle: 컨트롤러 메서드 호출후 수행, 예외 발생시 수행되지 않음
  • afterCompletion: 뷰가 렌더링 된 후, 즉 전체 요청처리가 완료된 후 수행 - 예외가 발생해도 수행
@Configuration  
public class WebConfig implements WebMvcConfigurer {  

    @Override  
    public void addInterceptors(InterceptorRegistry registry) {  

        registry.addInterceptor(new LogInterceptor())  
                .order(1)  
                .addPathPatterns("/**")  
                //인터셉터를 제외할 요청주소들  
                //서블릿처럼 DispatcherType은 없지만, 에러페이지 자체를 요청리스트로부터 제외할 수 있다.  
                .excludePathPatterns("/css/**", "/*.ico", "/error", "/error-page/**");  
    }
}

인터셉터를 등록하기위한 설정 클래스를 수정하자
이전에 서블릿 필터를 등록하는 방법과 매우 유사하며 갓프링답게 set()방식 보다는 메서드 체이닝 방식을 통해 보다 편리한 설정을 제공한다.

  • addInterceptor: 커스텀한 LogInterceptor를 등록
  • order: 인터셉터의 동작순서 등록
  • addPathPatterns: 인터셉터를 수행하고자하는 요청경로설정 (필터의 addUrlPatterns)
  • excludePathPatterns: 인터셉터 수행할 필요 없는 요청경로를 설정

excludePathPatterns메서드를 사용한다는 점이 서블릿 필터와 가장 큰 차이점인듯하다.
css, error, image와 같은 경로들은 인터셉터를 굳이 설정해줄 필요가 없다(해야할 경우도 있지만)
그런 경우에 해당 메서드안에 인자로 경로를 정의해주면 된다.

정리해보자
웹 애플리케이션에 error-page를 등록해두고 사용할 때 애플리케이션 코드에서 예외가 발생하고, 해당 예외를 처리해주지 않으면 WAS까지 예외가 올라가고 해당 예외에 맞는 error-page가 등록되어있다면 WAS는 재요청을 하는형태로 애플리케이션코드로 다시 보낸다.
그 과정에서 처음 REQUEST에 대한 필터, 인터셉터 동작과 error-page를 호출하기위한 필터, 인터셉터 동작 각각 두번씩 동작한다는 것을 알 수 있었다.
이런 불필요한 중복동작을 막기위해 서블릿 필터는 DispatcherType을, 인터셉터는 excludePathPatterns를 이용하여 필터와 인터셉터의 중복동작을 막을 수 있었다.

 

 

 

이제 스프링 답게 부트를 통해 숟가락만 얹어보자

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

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

처음에 error-page를 편리하게 사용하기위해서 예외 또는 HTTP STATUS CODE에 맞는 오류 페이지를 응답하기위해 new ErrorPage를 사용하여 오류 페이지를 미리 정의해두었었다.
그 과정에서 필터와 인터셉터의 동작을 알아보았었다.

ErrorPage등록하고.. 이 에러페이지들을 WAS로부터 출력하기위해 에러 페이지들을 처리할 컨트롤러를 따로 등록해주고.. 프로젝트를 진행한다면 반복될만한 작업들을 계속 등록하고, 사용해주어야한다.
이럴때 꼭 스프링부트가 등장해서 번거로운 작업들을 대신해준다.

 

스프링 부트가 제공해주는 기능을 사용하기위해 WebServerCustomizer@Component어노테이션을 주석처리해주자. (참고로 스프링은 부트가 제공해주는 디폴트 설정보다 개발자가 직접정의한 설정파일과 Bean에 더 우선권을 부여한다.)

 

@Component어노테이션을 주석처리했다면 이제 스프링 부트를 통해 더욱더 간편하고 심플하게 에러 페이지를 출력해보자.

 

스프링 부트는 위에서 우리가 직접구현했던 에러페이지와 이를 처리하는 컨트롤러를 구현해놓았으며 구현해놓은 자리에 우리가 사용하고자하는 에러 페이지를 만들고, 그 페이지를 등록하기만 하면된다.

 

어떻게 등록하느냐?
컨트롤러를 통해 처리하기때문에 먼저 스프링 부트가 설정한 경로를 알아야하는데 /error 라는 경로를 기본값으로 설정해놓았다.

스프링 부트는 BasicErrorController라는 컨트롤러를 아래와 같이 정의해놓았다. (대충 이런느낌)

@RequestMapping("/error")
public class BasicErrorController{

    @GetMapping("/404")
    public String error404(){
        return "/error/404";
    }
}

이런식으로 컨트롤러가 구현되어있으며 우리는 에러페이지를 만들고, 만든 에러페이지의 경로를 옳바르게만 지정해주면된다.
만약 동적 처리할 데이터가 없다면 /resources/static/error경로에
동적 처리를 위해 뷰템플릿을 등록한다면 /resources/templates/error경로에 에러페이지 템플릿을 만들어주면 끝이다!

 

 

 

마지막으로 BasicErrorController에 대해 맛만 봐보자

BasicErrorController는 실제 등록한 에러페이지를 매핑해줌과 동시에 model에 여러가지 정보를 담아서 전달한다.
model에 담긴 정보는 thymeleaf와 같은 뷰 템플릿에서 데이터를 꺼내어 화면에 보여줄 수 있다.

  • timestamp
  • status
  • error
  • exception
  • trace
  • message
  • errors
    ...등등 여러가지 정보를 담을 수 있다.
    하지만 에러 페이지의 목적은 사용자가 웹 애플리케이션을 사용함에 있어서 어떤 요청을 보냈을 때, BadReqeust라던가 500 서버에러와 같은 잘못된 리소스 접근 또는 서버에 장애가 있다는 것을 표시할 뿐 그 외에 대한 정보를 넘겨주게 된다면 보안적으로 취약할 수 있고 사용자 입장으로는 알아듣기 어려운 불필요한 정보일 뿐이다.

따라서 굳이 BasicErrorController가 넘겨준 model의 정보값을 사용할 일은 없을 것 같다.
하지만 굳이 사용하자면 개발과정에서 브라우저를 통해 직접 에러를 확인한다면 스택트레이스를 브라우저로 확인할 수 있도록 활용할 수는 있을 것 같다.
만약 이와 같이 BasicErrorController가 넘겨준 model의 값을 사용하고싶다면 아래 설정옵션을 참고하자.

application.properties

server.error.include-exception=false || true
server.error.include-message=never || always
server.error.include-stacktrace=never || always
server.error.include-biding-errors=never || always
따로 설명하지않아도 어떤 옵션에 대한 설정인지 알 수 있을것이다.

 

기타 참고하면 좋을 수 있는 설정옵션
server.error.whitelabel.enable=true || false: 등록한 /error의 에러페이지가 존재하지 않는 오류가 발생할 경우 스프링 whitelabel을 응답한다

 

server.error.path=/error: 에러 페이지를 등록할 경로를 지정한다.

 

참고로 BasicErrorController를 상속받아 확장하여 기능을 추가할수도있다.

 

 

 

이 포스팅은 스프링 일타강사 인프런의 김영한님의 강의를 듣고 스스로 정리한 포스팅입니다!
https://www.inflearn.com/course/lecture?courseSlug=%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2&unitId=83355&tab=curriculum

728x90