framework/spring

스프링 mvc2 - 6. 예외 처리와 오류 페이지

wooweee 2023. 5. 7. 14:52
728x90
  • 목표
    • 예외가 터질 때 어떤 내용의 오류페이지를 작성할지, 어떻게 배치할지 고려하는 것
  • 학습 방향
    1. 서블릿부터 이해 -> 서블릿 컨테이너가 어떤식으로 예외를 처리하는지 메커니즘을 이해
    2. 서블릿 위에 spring이 예외를 어떻게 처리하는지 순차적으로 이해

 

  • 학습을 위해 스프링 부트에서 자동으로 제공하는 기본 예외 페이지를 꺼둔다.
# application.properties
server.error.whitelabel.enabled=false

 

1. 서블릿 예외 처리

  • 2가지 방식으로 예외 처리
    1. Exception(예외) - 무조건 500 error, 서버에서 처리 못하는 exception이 was까지 넘어가기 때문에 server문제라고 해석할 수 밖에 없다.

    2. response.sendError(Http status, error message) - 내가 지정 가능

 

1.1. Exception

  • 자바
    • main이란 쓰레드가 실행시 예외를 잡지 못하고 main() method를 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드는 종료된다.

 

  • 웹 어플리케이션
WAS(tomcat) <- filter <- servlet <- intercepter <- handler(exception 발생)
  • 사용자 요청별로 별도의 쓰레드가 할당되고 서블릿 컨테이너 안에서 실행된다.
  • 예외를 잡지 못하고 서블릿 밖으로 까지 예외가 전달될 시 WAS 까지 예외가 전달된다.
  • WAS(tomcat)
    1. 해당 예외가 server 내부에서 처리할 수 없는 오류가 발생한 것으로 생각해서 http status를 500으로 반환
    2. 추가적으로 Mapping이 되지 않은 uri는 404 화면을 보여준다.
    3. 더보기
      404 에러가 발생하는 위치는 일반적으로 WAS(Web Application Server) 측면
      클라이언트 측면에서는 브라우저에서 URL을 요청하고 서버 측면에서는 해당 요청을 처리하는 WAS가 URL에 해당하는 컨트롤러나 리소스를 찾지 못할 경우 404 에러를 반환
      따라서, 404 에러는 서버 측면에서 발생하는 예외

 

  • 예제 코드
// application.properties
server.error.whitelabel.enabled=false

//ServletexController
@Slf4j
@Controller
public class ServletExController {
    @GetMapping("/error-ex")
    public void errorEx() {
        throw new RuntimeException("exception 발생 시 WAS는 500 status 보낸다");
    }
}

 

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

  • 오류 발생시 해당 메서드 사용 - 호출한다고 당장에 예외가 발생하는 것은 아니지만, WAS에게 오류가 발생했다는 점을 전달 가능

  • 특징
    • HTTP 상태코드와 오류 메시지 추가 가능
      Exception과 달리 상태코드를 직접 지정 가능
    • 오류 메시지는 기본으로 숨김 처리 되지만 화면에 보이도록 할 수 있다.

  • sendError 흐름
WAS(sendError 호출 기록 확인) <- filter <- servlet <- intercepter <- handler(response.sendError(STATUS, message))
  1. response.sendErorr()를 호출 response 내부에는 오류가 발생했다는 상태 저장
  2. WAS는 고객 응답 전에 response에 sendError()가 호출됬는지 확인
  3. 호출되었다면 설정한 오류 코드에 맞춰 기본 오류 페이지를 보여준다.

  • 예제코드
@Slf4j
@Controller
public class ServletExController {

    @GetMapping("/error-ex")
    public void errorEx() {
        throw new RuntimeException("exception 발생 시 WAS는 500 status 보낸다");
    }

    @GetMapping("/error-403")
    public void error403(HttpServletResponse response) throws IOException {
        response.sendError(403, "403 오류 - default로 숨김. WAS가 고객 응답 전에 sendError 확인 후 오류 페이지를 보여준다.");
    }
    @GetMapping("/error-500")
    public void error500(HttpServletResponse response) throws IOException {
        response.sendError(500);
    }
}

 

 

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

  • 서블릿은 exception , response.sendError() 호출 되었을 때 상황에 맞춘 오류 처리 기능을 제공
    • was 내부에 servlet과 filter가 있는 것이고 예외나 senderror()를 받을 경우 was가 다시 요청을 보내고 실제 error 관련 controller를 수행하는 것은 servlet(webservlet || dispatcherServlet) 이다.

    • 해당 예외나 sendError()를 받을 때 다시 servlet에서 새로운 url 요청을 보내야되는데 이는 설정파일에 등록을 해줘야 was가 그걸 보고 재 요청을 수행한다.

 

2.1. web.xml 등록 방식

<web-app>
    <error-page>
        <error-code>404</error-code>
        <location>/error-page/404.html</location>
    </error-page>
    <error-page>
        <error-code>500</error-code>
        <location>/error-page/500.html</location>
    </error-page>
    <error-page>
        <exception-type>java.lang.RuntimeException</exception-type>
        <location>/error-page/500.html</location>
    </error-page>
</web-app>

 

2.2. 스프링 부트 등록 방식

 

  • 예외 발생_종류 설정 파일 등록 방법
    1. implements WebServerFactoryCustomizer<ConfigurableWebServerFactory>

    2. @Override public void customize(ConfigurableWebServerFactory factory)
      1. new ErrorPage(HttpStatus.XXX, "/error-page/404")
        • 해당 status일 때 controller 호출
      2. new ErrorPage(RuntimeException.class, "/error-page/500)
        • 해당 exception일 때 controller 호출
        • runtimeException의 자손 모두 포함

 

  • 순서
    1. springboot 실행시 was에 errorPage 설정 파일이 등록
    2. 예외 || sendError() 발생 후 was 까지 올라감
    3. was에서 errorPage 설정 파일 내용을 확인 후 다시 servlet에 error에 맞는 요청을 다시 보낸다.

 

 

  • error config 등록, error controller , error html page 생성 과정

1. exception || response.sendError()로 예외 발생

2. WebServerCustomizer에 동일 예외 처리 부분 존재 확인 -> 존재시 작성된 controller 호출

package hello.exception;

@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); // 등록까지 해줘야 was에 등록이 됨
    }
}

 

3. 해당 요청 처리할 controller 생성

package hello.exception.servlet;

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

4. controller 에서 부르는 html page 생성

 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>404 오류 화면</h2>
    </div>
    <div>
        <p>오류 화면 입니다.</p>
    </div>
    <hr class="my-4">
</div> <!-- /container -->
</body>
</html>
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>500 오류 화면</h2>
    </div>
    <div>
        <p>오류 화면 입니다.</p>
    </div>
    <hr class="my-4">
</div> <!-- /container -->
</body>
</html>

 

 

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

 

3.1. 작동 원리

  • exception, sendError의 호출되어서 was로 넘어감

  • WAS는 해당 예외를 처리하는 오류페이지 정보를 확인 - 예외 설정 파일 등록한 것을 확인하는 것

  • WAS는 오류 페이지를 출력하기 위해 /error-page/500를 다시 요청 - 내부적으로 재요청

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

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

  • 해당 재요청은 웹 브라우저는 전혀 모른다 - 오직 서버 내부에서 오류페이지를 찾기 위해 추가적인 호출을 한다.

  • 정리
    1. 예외 발생 후 WAS까지 전파
    2. WAS는 오류페이지 경로를 찾아서 내부에서 오류페이지 호출
      이때, 오류 페이지 경로로 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출

 

3.2. 오류 정보 추가

  • WAS가 오류 페이지를 재 요청시 오류정보를 request의 attribute에 추가해서 넘겨준다.
package hello.exception.servlet;

@Slf4j
@Controller
public class ErrorPageController {

    //RequestDispatcher 상수로 정의되어 있음 - 다시 에러페이지 호출을 위한 controller 들어갈 시 필요 정보들을 뽑아낼 수 있다.
    public static final String ERROR_EXCEPTION = "jakarta.servlet.error.exception";
    public static final String ERROR_EXCEPTION_TYPE = "jakarta.servlet.error.exception_type";
    public static final String ERROR_MESSAGE = "jakarta.servlet.error.message";
    public static final String ERROR_REQUEST_URI = "jakarta.servlet.error.request_uri";
    public static final String ERROR_SERVLET_NAME = "jakarta.servlet.error.servlet_name";
    public static final String ERROR_STATUS_CODE = "jakarta.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: ex=", 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));
        
        // 정보 중 dispatchType 이 중요. filter를 중복 호출하지 않는 기준점이 된다.
        log.info("dispatchType={}", request.getDispatcherType());
    }
}

 

 

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

  • 오류 발생시 WAS 내부에서 다시 한번 호출이 발생 : 필터, 서블릿, 인터셉터도 모두 다시 호출
    -> 하지만 경우에 따라 오류페이지를 출력한다고 필터나 인터셉트를 한번 더 호출하는 것은 비효율적이다.

  • 해결방안
    • client로부터 발생한 정상 요청인지, 오류 페이지를 출력하기 위한 내부 요청인지 구분 할 줄 알아야 한다.
    • 서블릿은 DispatcherType이란 추가 정보를 제공

    • dispatcherType 종류
      1. REQUEST: 클라이언트 요청
      2. ERROR: 오류 요청
      3. FORWARD: 서블릿에서 다른 서블릿이나 JSP 호출 시
      4. INCLUDE: 서블릿에 다른 서블릿이나 JSP 결과를 포함할 때
      5. ASYNC: 서블릿 비동기 호출

  • 전체 흐름 정리
    • 필터 : DispatchType으로 중복 호출 제거 (dispatchType = REQUEST)
    • 인터셉터 : 경로 정보로 중복 호출 제거 (excludePathPatterns("/error-page/**"))

 

4.1. 필터

  • dispatcherTypes 옵션 제공
    • client의 첫 요청시 : dispatcherType=REQUEST
    • 에러로 인한 WAS의 내부적 재요청 일어날 때 : dispatcherType=ERORR 

 

  • 사용

1. dispatcherType log 확인 filter

package hello.exception.filter;

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

 

2. 필터 등록

  • 2가지 dispatcher를 넣으면 client 요청 및 error-page 요청에서도 필터가 호출
  • 아무것도 안넣으면 DispatcherType.REQUEST일 경우에만 filter 작동 default가 Request 이다. -> 사실 error 요청에 filter 적용 제외하고 싶을 경우 굳이 설정을 안건들이면 된다.
  • error 요청시 filter 작동 하고 싶으면 setDispatcherTypes에 Request랑 error 넣기
package hello.exception;

@Configuration
public class WebConfig{
    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
        // /* 경로를 지키는 선에서 해당 dispatcher일 때 filter 수행
        return filterRegistrationBean;
    }
}

 

 

4.2. 인터셉터

 

1. LogInterceptor 생성

  • 로그 찍히는 부분에 request.getDispatcherType() 생성한 것 제외하고는 이전과 동일

 

2. webConfig 등록

  • filter의 경우 dispatchertype을 적용 가능 했지만 interceptor 같은 경우 서블릿이 제공하는 기능이 아닌 스프링이 제공하는 기능
    -> dispatcherType 과 무관하게 항상 호출

  • excludePathPatterns를 사용해서 빼준다.
    • /error-page/**를 제거하면 error-page/500 같은 내부 호출의 경우에도 인터셉터가 호출
@Configuration
public class WebConfig implements WebMvcConfigurer {


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

 

 

5. 스프링 부트

5.1. 이전까지 작동원리

  • 에러 발생 - exception || response.sendError()
    1. webServerCustomizer 만들기
      -> 에러 발생 대비해서 WAS에 등록할 것들 ex) 이런 error 발생시 이쪽으로 요청을 보내셈 역할

    2. ErrorPageController 만들기
      -> 이쪽으로 요청 보낸 것에 대한 controller  생성 필요

    3. ErrorPage
      -> ErrorPageController에서 error 화면을 보여줘야하는데 그에 맞는 view template 생성 필요

 

5.2. 스프링 부트

  • 에러 발생 - exception || response.sendError()
    1. webServerCustomizer 기본 제공 = ErrorMvcAutoConfiguration
      -> /error URI 로 재 요청 보냄
      -> 이전에 한 webServerCustomizer 에서 new ErroPage() 등록하지 않아야 default로 적용됨

    2. BasicErrorController 기본 제공 및 등록됨
      -> /error를 매핑해서 처리하는 컨트롤러

    3. ErrorPage 자동 등록 (html 말하는 것)
      -> dictionary 중 error 라는 경로를 기본 오류 페이지로 설정되어있다.
  • 다 자동 등록이 되어있기 때문에 error html page만 만들면 된다.

  • 규칙 - 우선순위(BasicErrorController가 제공하는 우선순위를 따른다.)

    1. 뷰 템플릿
      동적 수정이 필요한 경우, 뷰 템플릿 내부에서도 자세한 것이 우선순위를 가진다
      • resources/templates/error/500.html
      • resources/templates/error/5xx.html   // 500번대 모두

    2. 정적 리소스(static, public)
      뷰 템플릿에 없을 때 그 다음으로 찾는다.
      • resources/static/error/400.html
      • resources/static/error/4xx.html
    3. 적용대상이 없을 때 뷰 이름(error)
      • resources/templates/error.html
        어떤 status의 error가 나타날지 예상이 안가는 것을 대비해서 사용하는 것
        html 이름만 error로 해놓으면 된다.
    4. 1~3 번 모두 없을 경우
      • 제일 처음에 false로 해놓은 spring boot가 제공하는 whitelabel page를 날려준다.

 

 

5.1. BasicErrorController 추가 기능

  • 여러 정보를 model에 담아서 view에 전달 : 그래서 동적 page를 우선순위1 에 둔 것이다.

  • 정보 - server 측 log에만 보이는 것을 권장
    1. timestamp: Fri Feb 05 00:00:00 KST 2021
    2. status: 400
    3. error: Bad Request
    4. exception: org.springframework.validation.BindException
    5. trace: 예외 trace
    6. message: Validation failed for object='data'. Error count: 1
    7. errors: Errors(BindingResult)
    8. path: 클라이언트 요청 경로 (`/hello`)
  • 오류 정보는 고객에게 노출안하는 것이 권장 - 보안상의 문제
    • application.properties에 노출 여부 결정
      • exception : true / false
      • 나머지
        1. always: 항상 보여줌
        2. never: 절대 안보여줌 -default
        3. on_param : url에 해당 property를 params로 적었을 때 보여줌
// application.properties
server.error.include-exception     = true
server.error.include-message       = always 
server.error.include-stacktrace    = never 
server.error.include-binding-errors= on_param

 

5.2. 스프링 부트 오류 관련 옵션

  • 기본 오류 화면
server.error.whitelable.enabled=true 
// error.html 까지 없을 때 나오는 기본 페이지

 

  • 오류 페이지 경로 변경 - 그냥 손대지 마셈
server.error.path= /error
// 오류페이지 경로 변경 - 1. 2. 번 동일 적용
// 1. 스프링이 자동 등록하는 서블릿 글로벌 오류 페이지 경로
// 2. BasicerrorController 오류 컨트롤러 경로
// 거의 안씀

 

  •  확장
    • ErrorController 인터페이스를 상속 받아서 구현
    • BasicerrorController 상속 받아서 기능 추가