예외 처리와 오류 페이지

고동현·2024년 5월 22일
0

스프링 MVC

목록 보기
11/13

서블릿 예외 처리 -시작

스프링이 아닌 순수 서블릿 컨테이너는 예외를 Exception, response.sendError 이 두가지 방식으로 처리한다.

  • Exception(예외)
    웹 애플리케이션에서는 사용자의 요청별로 쓰레드가 별도로 할당되고, 서블릿 컨테이너 안에서 실행된다.
    만약에 오류가 터졌는데 try-catch로 잡으면 괜찮은데 만약 애플리케이션 내부에서 예외를 잡지 못하고 서블릿 밖으로까지 예외가 전달되면 톰캣같은 Was까지 예외가 전달된다.

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

    그렇다면 톰캣 같은 WAS까지 예외가 발생하면 어떻게 될까?

    우선 스프링 부트가 제공하는 기본 예외 페이지를 꺼두자.
    server.error.whitelabel.enabled=false

    서블릿 예외 컨트롤러

    해당 url로 요청을 보내면 tomcat이 기본으로 제공하는 오류 화면을 볼 수 있다.

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

  • response.sendError(HTTP상태코드, 오류 메시지)
    오류가 발생했을때 HttpServletResponse가 제공하는 sendError라는 메서드를 사용해도 된다.
    이건 메서드를 호출한다고 예외가 당장 발생했다는건 아니고, 서블릿 컨테이너한테 예외가 발생했다는 것을 단순히 전달하는것이다.

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

    컨틀롤러에서 response.sendError()를 호출하면 response내부에 오류가 발생했다는 상태를 저장한다.
    그리고 서블릿 컨테이너는 고객에게 응답을 하기 전에 sendError()가 호출되었는지 확인하고 호출되어있는 기록이 있다면 오류코드에 맞춰서 기본 오류 페이지를 보여준다.

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

서블릿 컨테이너가 제공하는 기본 예외 처리는 좀 투박하다. 그래서 우리는 서블릿이 제공하는 오류 화면 기능을 사용해볼 것이다.

서블릿은 예외처리를 Exception과 response.sendError가 호출되었을때 각각의 상황에 맞춰 오류 처리 기능을 제공할 수 있다.

서블릿 오류 페이지 등록


톰켓이 뜰때 에러페이지를 등록해준다. 우리는 404,500,RuntimeException에 해당하는 오류가 발생되었을때 해당 경로로 이동하게 하였다.

해당 오류 처리할 컨트롤러

error-page/404경로로 요청이 들어오면 해당 view를 반환하게 하였다.

오류 처리뷰

<!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>

다시 정리해보자.
일단 톰캣이 뜨면서 WevbServerCustomizer 클래스가 Component가 달려있어서, 해당 오류 코드에 해당하는 ErrorPage를 등록해놓는다.

그다음에, 컨트롤러에서 예외를 발생시킨다.(앞에서 했던것)

이렇게 오류를 발생시키더라도 try-catch로 예외처리를 하지 않았기 때문에, WAS까지 다시 전파가 된다.

그렇게 되면 WAS에서는 이 예외를 처리하는 ErrorPage가 있는지 확인하고 있다면, 해당하는 경로를 호출한다. 이경우에는 /error-page/500을 다시 요청할 것이다.

그렇다면 이 요청을 처리하는 컨트롤러에서

다시 뷰를 반환하게 된다.

그럼 이런식으로 custum한 오류 페이지가 뜬다.

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

다시 한번 정리를 해보면

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

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

이러면 WAS까지 다시 오게 되는데 WAS에서 이제 해당 예외를 처리해주는 오류 페이지 정보를 확인한다. 우리는 컴포넌트를 사용해서 new ErrorPage를 통해서 에러 페이지를 등록 해두었다.

예를 들어 RuntimeException 예외가 WAS까지 전달되면, WAS는 오류 페이지 정보를 확인한다. 확인해보니까 RuntimeException의 오류페이지로 /error-page/500이 저장되어있으니까 이제 Was가 오류페이지를 출력하기 위해서 다시 /error-page/500을 요청한다.

오류 페이지 요청 흐름
WAS가 '/error-page/500'을 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

또한 WAS는 오류 페이지를 단순히 다시 요청하는게 아니라, 오류정보를 request.setAttribute형식으로 추가해서 넘겨준다.
그래서 아래의 코드에서 ReqeustDispatcher.ERROR_EXCEPTION같은 상수를 키로, value에 해당하는 값들을 WAS가 채워서 다시 요청을 보낸다.

핵심은 웹브라우저(클라이언트)는 서버 내부에서 이런일이 일어나는지 모른다는 것이다. 오직 서버 내부에서 오류 페이지를 찾기위해서 추가적인 호출을한다.

오류 정보추가
오류에 대한 정보를 추가적으로 로그를 찍을 수 있다.

http://localhost:8080/error-404로 접속시

로그가 남음을 확인 할 수 있다.

서블릿 예외처리 - 필터

이전까지 배운 내용을 다시 정리하자면, 만약 예외가 발생하면
1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
Was까지 예외가 전파되고
2. WAS '/error-page/500' 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

즉 오류가 발생하면 오류 페이지를 출력하기 위해서 WAS가 내부에서 다시 호출을 보낸다. 이렇게 되면서 필터와 인터셉터가 두번 호출되게 된다.
이렇게 되면 매우 비효율적이라고 볼 수 있다.

결국, 클라이언트로 부터 발생한 정상 요청인지(1번), 아니면 오류 페이지를 출력하기 위한 내부 요청인지 구분할 수 있어야한다(2번).

그래서 서블릿은 DispatcherType을 통해서 추가 정보를 제공한다.

그러면 필터를 등록해서 실제로 DispatcherType이 어떻게 동작하는지 알아보자.

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

여기서 doFilter부분에 request.getDispatcherType()을 추가하였다.

필터를 만들었으면 당연히 등록을 해야한다.

WebConfig

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

setDispatcherTypes에서 이 필터가 언제 호출 될것인지 설정하는데 우리는 DispatcherType.Request,ERROR 두 경우에 이 필터가 적용되도록 했다.

이렇게 하면 클라이언트 요청은 물론이고, 오류 페이지 요청에서도 필터가 호출된다.
아무것도 넣지 않으면 default가 request라 클라이언트 요청이 있는경우에만 핉터가 적용된다.

실행을 해보자.

로그를 까보면 dispatchType이 두번 Request, ERROR로 로그를 남긴것을 볼 수 있다.

천천히, 다시 예시를 들어보자면, 만약 localhost:8080/error-page/400으로 접속을 한다면, WAS로 부터 쭉 컨트롤러까지 이어질때 정상 요청이므로 dispatch Type이 Request이다.
그러나 우리는 이 컨트롤러에서 exception을 발생시켰고 그러면 이게 WAS까지 다시 올라와서 다시 ErrorPage를 보고 요청을 보낸다. 이때는 dispatchType이 ERROR인 것이다.

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

이번에는 필터가 아닌 인터셉터로 확인하는 방법을 알아보자

LogInterceptor


@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에서 request.getDispatcherType이 있는것을 확인 할 수 있다.

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

해당 인터셉터를 등록해주었다. 여기서는 아까 서블릿 필터처럼 이 인터셉터를 적용할 DispatcherType을 지정할수있는것은 없다. 그러나 더 강력한 기능인 url패턴으로 적용할 수 있어서이다.

만약 /error-page/400으로 요청을 보내면 일단 먼저 컨트롤러까지 갈때는 정상 작동이니까,
고객요청(/request) -> 인터셉터 호출 -> 컨트롤러 호출(오류 발생) -> WAS로 오류 전달이 된다.
실제로 로그를 보면

Request가 한번 나옴을 알수있다.
그다음에 WAS에서 다시 에러페이지를 요청을 보내는데
WAS는 오류 페이지 호출을 위해 내부 호출(/error-page) -> 인터셉터 호출 -> 컨트롤러 호출(오류 페이지)
여기서는 /error-page/400을 요청 보내니까 인터셉터를 적용되지 않는다.
왜? 우리가 excludepatterns에서 제외시켰기 때문이다. 만약에 excludepatterns에서 지우면, dispatchType = ERROR로 로그가 남는다.

스프링 부트 - 오류페이지1

일단 우리는 서블릿을 사용하면, WebServerCustomizer를 통해서 예외 종류에 따라서 ErrorPage를 등록하고, 그다음에 예외 처리용 컨트롤러 ErrorPageController를 만들었다.

그러나 스프링 부트는 이런 과정을 모두 기본으로 해준다.
ErrorPage를 자동으로 등록하고, /error를 기본 오류 페이지로 설정한다.
new ErrorPage("/error"),상태코드와 예외를 설정하지 않으면 모두 기본 오류페이지로 가게 된다.
한마디로 따로 ErrorPage를 설정하지 않는한, 스프링부트가 기본으로 설정해 놓은 /error로 요청으로 가서 view를 보는것이다.

그리고 이 /error로 가라는 요청은 BasicErrorController라는 스프링 컨트롤러가 해준다.

그러면 개발자는 다른것 필요없이, 오류페이지만 등록하면된다.

뷰선택 우선순위

  1. 뷰 템플릿
    resources/templates/error/500.html
    resources/templates/error/5xx.html
  2. 정적 리소스
    resources/static/error/500.html
    resources/static/error/5xx.html
  3. 적용 대상이 없을때 뷰이름
    resources/templates/error.html

뷰만 등록하고

그다음에, 요청을 보내면

자동적으로 스프링부트가 처리해준다.

스프링 부트 - 오류페이지 2

BasicErrorController는 model에 아래 정보를 담아서 뷰에 전달한다.
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)

그러나 이런 정보는 고객에게 노출하지 않는것이 좋고,
application.properties에서
server.error.include-exception= false: exception포함여부
server.error.include-message = never: message 포함여부
server.error.include-stacktrace = never: trace 포함여부
server.error.include-binding-errors = never: errors 포함여부
를 설정하여서 model에다가 BasicErrorController가 오류를 포함 할지 안할지 결정할 수 있다.

스프링 부트 관련 옵션
server.error.whitelabel.enabled=true:오류 처리 화면을 못 찾을시, 스프링 whitelabel 오류 페이지 적용
server.error.path=/error:오류 페이지 경로, 스프링이 자동 등록하는 서블릿 글로벌 오류 페이지 경로와 BasicErrorController오류 컨트롤러 경로에 함께 사용된다.

정리
서블릿부터 스프링까지 장차 긴 내용을 설명했지만, 결국 스프링 부트를 사용하면, 오류페이지만 만들어서 넣어주면 알아서 오류페이지를 찾아서 렌더링 해준다.

API 예외 처리

그렇다면 API 예외 처리는 어떻게 할까? 이전에 했던 오류페이지는 단순히 고객에게 오류 화면을 보여주면 끝이지만, API는 오류 상황에 맞게 오류 응답 스펙을 정하고, JSON으로 데이터를 내려줘야한다.

WebServerCustomizer를 다시 살려주고


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

API 에외 컨트롤러를 만든다.

@Slf4j
@RestController
public class ApiExceptionController {
    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id){
        if(id.equals("ex")){
            throw new RuntimeException("잘못된 사용자");
        }
        return new MemberDto(id,"hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto{
        private String memberId;
        private String name;
    }
}

단순하게 id값에 ex가 안오면 그냥 MemberDto를 출력해주고 ex가 오면 RuntimeException을 던진다.

그런데 ex를 던지면 결과가, html file형식으로 온다.
그야 당연한게 Errorpage에 해당하는 view를 찾아서 WAS에서 요청하기 때문이다.

그런데 우리는 API를 요청했는데 JSON형식으로 반환이 되야하지만, WAS가 다시 view를 요청하는 바람에, 오류 HTML이 반환이 되었다.
클라이언트는 정상요청이던 아니던 JSON이 반환되기를 원한다.

그러면 이제 컨트롤러를 새롭게 만드는것이다.

@RequestMapping(value = "/error-page/500",produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map<String,Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response){
        log.info("API errorPage 500");
        Map<String,Object> result = new HashMap<>();
        Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
        result.put("status",request.getAttribute(ERROR_STATUS_CODE));
        result.put("message",ex.getMessage());
        Integer statusCode = (Integer) request.getAttribute(ERROR_STATUS_CODE);
        return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
    }

우선 result라는 MAP을 만들고, 그다음에 request.getAttribute를 통해서 오류에 해당하는 정보들을 가져와서 넣고, JSON형식은 HTTP메시지 바디에 콱 직접 넣어줘야하니까 ResponseEntity에다가 해당 reuslt와 상태코드를 넣어서 반환한다.

어? request.getAttribute에서 난 ERROR_EXCEPTIOn같은 오류 정보를 넣어준적이 없는데? setAttribute를 통해서? 근데, WAS가 다시 요청할때 오류정보들을 담아서 다시 요청하므로, 여기서 get으로 꺼낼 수 있다.

그리고 produces를 사용하여서 클라이언트가 요청하는 HTTP Header의 Accept값이 application/json일때 해당 errorPage500API메서드가 호출되게 하였다.

그래서 요청을 보내면 JSON형식으로 제대로 요청이 온것을 볼 수 있다.

다시한번 흐름을 설명해보면 /ex경로로 요청을 보내면, RuntimeException이 발생한다. 우리는 try catch를 통해서 오류 설정을 안해줬으니까, WAS까지 오류가 전파되고, WAS에서는 오류에 대한 정보를 포함하여 해당 ERROR-Page로 설정한 컨트롤러로 다시 요청을 보낸다. 우리는 RuntimeException을 ErrorPage errorPageEx = new ErrorPage(RuntimeException.class,"/error-page/500"); /error-page/500으로 요청하게 하였고, 그런데 세부적으로 client가 Accept헤더를 application/json으로 설정하여서 우리가 만든 errorPage500API메서드가 호출된 것이다.

API 예외 처리 - 스프링 부트 기본 오류 처리

스프링 부트는 똑똑하게 API 예외 처리도 스프링 부트가 기본적으로 제공을 해준다.
일단 WebServerCustomizer의 @Component를 주석처리해서 비활성화 해주자. 왜냐하면 우리는 스프링 부트가 제공하는 기본 컨트롤러로 요청이 가야하기 때문이다. 뒤에서 설명할꺼다.


일단 요청을 보내보면, 정상적으로 JSON형식으로 들어왔음을 알 수 있다.
이건 바로 앞에서 설명한 예외처리에 있는 BasicErrorController가 해주는건데,
/ex경로로 요청을 보내면 RuntimeException을 던지고, 그러면 스프링부트 입장에서는 WAS가 /error경로로 요청을 보내게 된다.

이 /error경로를 처리해주는게 BasicErrorController이고

여기 내부에 produce type에 따라 메서드가 이미 정의되어있다.

 @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
 public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse 
response) {}
 @RequestMapping
 public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}

그러므로 /error 동일한 경로를 처리하는 메서드 중에서 Accept type을 보고 호출해준다.

그러나, 더 생각해봐야하는 부분이, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야할 수도 있다. 만약에 상품등록이나, 회원등록에 따라 예외가 발생할때 그 결과가 달라야할때가 있다.

그런데 앞에서처럼 모든 컨트롤러에 기본으로

이렇게 오류가 나오면 안될것이다.

그러면 복잡한 API 오류는 어떻게 처리해야 할까?

API 예외 처리 - HandlerExceptionResolver

예시를 들어보자, 만약에, 클라이언트가 잘못된 파라미터를 가지고 전송해서 IllegalArgumentException이 터졌다 치자, 그러면 사용자 오류니까 400에러가 나와야할것 같지만, 실제 해보면 서버 internal error로 500 에러가 뜬다.


여기에서 /api/members/bad로 요청을하면 예외가 발생해서 서블릿을 넘어 WAS까지 전달되니까 HTTP 상태코드가 500으로 처리된다.
그런데,이게 서버 오류가 아니라 Bad Request니까 400으로 처리하고싶은것이다.

ExceptionResolver사용전

그림에서 보다싶이 컨트롤러가 예외가 발생되면 Was까지 예외가 전달되는것을 볼 수 있다.
그리고 WAS에 오류가 전달되면 500 에러를 내보내는것이다.

그러면, 이제 이걸 어떻게 바꿀 수 있을까?

ExceptionResolver를 사용하면 서블릿이 예외 해결을 시도한다. 그리고 ModelAndView를 반환해서 view를 렌더링해준다.

참고: ExceptionResolver로 예외를 해결해도 postHandle()은 호출되지 않는다.

그림으로 보면 이해가 어렵다. 코드로 한번가보자.

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try{
            if(ex instanceof IllegalArgumentException){
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
        }catch (IOException e){
            log.error("resolver ex",e);
        }
        return null;
    }
}

ex로 넘어온 오류가 IllegalArgumentException이면 response.sendError에 HttpServletResponse.SC_BAD_REQUEST를 담아서 보내준다.
그리고 빈 ModelAndView를 반환한다.

이렇게 예외를 던지는게 아니라 ModelAndView를 던지는 이유가 우리는 WAS로 예외를 전달시키는게 목적이 아니다 -> 이러면 500에러나옴
ModelAndView를 반환하여 마치 정상적인 흐름인듯 진행을 시키고 이후 WAS까지 왔을때 response.sendError에서 상태코드에 대한 오류를 처리하게 된다. 그러면 이제 서블릿 오류 페이지를 찾아서 내부 호출을 다시 해주는데 예를 들어 스프링 부트가 기본으로 설정한 /error가 호출되는것이다.

당연히 ModelAndView를 반환하니까 MV에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링을 제공할 수도 있다. -> 근데 우리는 어차피 API라 지금 사용 x

즉, 한마디로, ExceptionResolver를 활용하면, 기존에는 WAS까지 Error가 전달되는 상태였는데, 이제는 오류 코드를 던져서 이 오류코드에 해당하는 페이지를 WAS에서 재요청 하게 하였다.

webConfig에 등록

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }

API 예외 처리 - HabndlerExceptionResolver 활용

그런데 지금 문제가 있다. 이전까지는 오류를 처리할때, ExceptionResolver에서 ModelAndVeiw를 반환하여, 오류를 삼키고 마치 정상적인 흐름인냥 로직이 진행되는건 맞는데 response.sendError이게 있어서 WAS에서 또 /error 기본 오류 페이지 찾아서 이걸 요청을 다시 보내주는 단점이 있다.

그러면 왔다갔다를 계속 하니까 이걸 하지 않고 딱 ExceptionResolver에서 끝내는걸 만들어보겠다.

UserExcpetion

package hello.exception.exception;

public class UserException extends RuntimeException {
    public UserException() {
        super();
    }
    public UserException(String message) {
        super(message);
    }
    public UserException(String message, Throwable cause) {
        super(message, cause);
    }
    public UserException(Throwable cause) {
        super(cause);
    }
    protected UserException(String message, Throwable cause, boolean
            enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

예외 추가

@Slf4j
@RestController
public class ApiExceptionController {
    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id){
        if(id.equals("ex")){
            throw new RuntimeException("잘못된 사용자");
        }
        if(id.equals("bad")){
            throw new IllegalArgumentException("잘못된 값 입력");
        }
        if(id.equals("user-ex")){
            throw new UserException("사용자 요류");
        }
        return new MemberDto(id,"hello " + id);
    }

UserHandlerExxceptionResolver


@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try{
            if(ex instanceof UserException){
                log.info("UserException resolver to 400");
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

                if("application/json".equals(acceptHeader)){
                    Map<String,Object> errorResult = new HashMap<>();
                    errorResult.put("ex",ex.getClass());
                    errorResult.put("message",ex.getMessage());
                    String result = objectMapper.writeValueAsString(errorResult);
                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);
                    return new ModelAndView();
                }else{
                    //Text/html
                    return new ModelAndView("error/500");
                }
            }
        }catch (IOException e){
            log.error("resolver ex",e);
        }
        return null;
    }
}

Json형식으로 반환하기위해서 ObjectMapper를 사용하였고
일단 ex가 UserException이면 400 오류를 설정한다음에, acceptHeader를 확인한다.
만약에, acceptHeader가 application/Json형식이라면, response에다가 Json형식에 맞게 error를 넣어준다. 그다음에 return에서는 정상적인 Mv를 return한다.
이게 뭐냐면 결국 이전에는 response.sendError를 통해서 WAS에서 /error를 재요청 하는 과정이 있었지만, 이제는 애초에 response에다가 application/json형식으로 오류를 넣어주고 비어있는 ModelAndView를 반환하는것이다.
그러면 Mv를 반환하니까 정상흐름으로 작동하고 response에 있는 json형식을 꺼내보면 error내용이 있는것이다.
그러면 Was에서 두번 왔다갔다 안해도 된다.
else문에서는 그냥 application/json형식이 아니면 html을 보여주기 위해서 viewname을 적어줬다.

정리해보자면, ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생하더라도 ExceptionResolver에서 예외를 처리하기 떄문에 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외처리가 끝이난다. 결과적으로 WAS입장에서는 정상처리가 된것이다.
서블릿 컨테이너까지 예외가 올라가면 복잡하고 지저분하므로 ExceptionResolver에서 해결하였다.

API 예외 처리 - 스프링이 제공하는 ExcpetionResolver

스프링 부트가 기본적으로 제공하는 ExceptionResolver는 다음과 같다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver -> 우선순위가 제일 낮음

우선 2번부터 알아 볼건데,
결국 우리가 하고 싶은 것이 뭐냐면, try catch로 예외 설정을 하지 않고 예외가 WAS까지 올라가버리면 서버 오류 500에러가 발생하는것이다.
그렇기 때문에 500이 아니라 상황에따라 400,404 등등을 표현하기 위해서 배우는것이다.

이제는 스프링 부트가 기본적으로 제공하는것을 배워보도록 할것인데,
2번, ResponseStatusExceptionResolver는 두가지 경우를 처리한다.

  1. @ResponseStatus가 달려있는 경우
  2. ResponseStatusException 예외가 달려있는경우

@ResponseStatus

@ResponseStatus(code = HttpStatus.BAD_REQUEST,reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}

BadRequestException 예외가 컨트롤러 발생 -> ResponseStatusExceptionResolver가 해당 애노테이션을 확인 -> 오류코드를 HttpStatus.BadRequest로 변경하고 메시지도 담는다.

ResponseStatusExceptionResolver의 내부 코드를 확인해 보면 결국 response.sendError를 호출하는데 이러면 sendError(400)을 호출 하였으므로 WAS에서 다시 오류 페이지 /error를 내부요청한다.

APiExceptionController추가

    @GetMapping("/api/response-status-ex1")
    public String responseStatusEx1(){
        throw new BadRequestException();
    }

ResponseStatusExcpetion
근데 이건 우리가 @ResponseStatus를 직접 해당 예외에 붙여주었다. 그러나 개발자가 직접 변경할 수 없는 라이브러리에는 적용할 수 없다.
이럴떄는 @ResponseStatus 애노테이션을 이용한 예외 class를 직접 만드는것이 아니라,
ResponseStatusException을 직접이용한다.


    @GetMapping("/api/response-status-ex2")
    public String responseStatusEx2(){
        throw new ResponseStatusException(HttpStatus.NOT_FOUND,"error.bad",new IllegalArgumentException());
    }

여기서 상태코드와 메시지, 그리고 해당 오류를 직접 명시할 수 있다.

API 예외 처리 - 스프링이 제공하는 ExcpetionResolver2

이번엔 DefaultHandlerExceptionResolver를 살펴보자.
DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다.

api부터 한번 짜보자


그리고 요청을 data에다가 qqq를 넣었는데 400오류로 정상적으로 나온다.
그런데 생각을 해보면 이전에 서버의 오류는 500대 클라이언트 오류는 400대라고 말했는데
파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchExceptioin이 발생하고 이걸 try catch 안해주면 WAS까지 타고 올라와서 500에러가 발생해야하는것이 맞다.
그러나 스프링에 있는 DeafaultHandlerExceptionResolver가 해결해준다.

API 예외 처리 - @ExceptionHandler

결국 이 ExceptionHandler 애노테이션을 배우기 위해서 이 많은 내용들을 배웠다.

  • HTML 화면 오류를 보여주고싶은경우
    BasicErrorController를 사용하여서 /errors 디렉토리 하위에 파일로 5xx,4xx등등 HTML을 만들면 알아서 WAS에서 재요청하는것 까지 다 해준다.

  • API 오류 응담
    이게 문제가 뭐냐면 앞서서 말했던 것처럼, 예외에 따라서 각각 다른 데이터를 출력해줘야하는데 이때 사용되는것이 ExcpetionHandler이다.

@ExceptionHandler
스프링 API 예외처리 문제를 해결하기 위해서 @ExcpetionHandler를 사용하는데, ExceptionHandlerExceptionResolver가 기본으로 제공하고 해당 기능이 우선순위도 가장 높다.

예외가 발생했을때 우리는 API응답 하고 싶으니까 그때 사용할 객체를 정의하였다.

ErrorResult

@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message;
}

ApiExcpetionV2Controller


@Slf4j
@RestController
public class ApiExceptionV2Controller {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e){
        log.error("[exceptionHandle] ex",e);
        return new ErrorResult("BAD",e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e){
     log.error("[exception Handle] ex",e);
     ErrorResult errorResult = new ErrorResult("USER-EX",e.getMessage());
     return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e){
        log.error("[exceptionHandle] ex",e);
        return new ErrorResult("EX","내부 오류");
    }

    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id){
        if(id.equals("ex")){
            throw new RuntimeException("잘못된 사용자");
        }
        if(id.equals("bad")){
            throw new IllegalArgumentException("잘못된 값 입력");
        }
        if(id.equals("user-ex")){
            throw new UserException("사용자 요류");
        }
        return new MemberDto(id,"hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto{
        private String memberId;
        private String name;
    }
}

위에서부터 하나하나 알아보자.
일단 RestController쓴게 RestController안에 ResponseBody가 있는데, 우리는 html을 반환하는게 아니라 api응답이니까 써줬다.

@GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id){
        if(id.equals("ex")){
            throw new RuntimeException("잘못된 사용자");
        }
        if(id.equals("bad")){
            throw new IllegalArgumentException("잘못된 값 입력");
        }
        if(id.equals("user-ex")){
            throw new UserException("사용자 요류");
        }
        return new MemberDto(id,"hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto{
        private String memberId;
        private String name;
    }

이건 응답할떄 필요한 DTO랑 메서드 이다.

    @ResponseStatus(HttpStatus.BAD_REQUEST)
 @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e){
        log.error("[exceptionHandle] ex",e);
        return new ErrorResult("BAD",e.getMessage());
    }

일단 반환을 ErrorResult로 해주는데 api응답이니까 ExceptionHandler를 사용하였다.
이러면 IllegalArgumentExcpetion이 발생하였을때 illegalExHandle메서드가 호출된다. 그러면 ErrorResult를 생성해서 반환한다.

그런데, 여기서 ResponseStatus가 있음을 알 수 있는데, 그 이유는 로직에 있다.
그냥 ExceptionHandler를 처음부터 알려주지 않은 이유가 여기에 있다.


이전에 했었던 그림이다. 컨트롤러에서 예외가 터지면, exceptionResolver를 통해 오류 해결 시도를 한다. 그러면 ExceptionResolver는 @ExceptionHandler가 있는지 확인을 한다. 그러면 ExcpetionHandler가 있으면 오류를 WAS까지 다시 보내서 /error로 요청하고 이게 아니라 정상흐름처럼 작동하여 서블릿 컨테이너로 안올라가는 것이다.

그래서 아까 위 코드에서 ResponseStatus를 지정한 이유를 알겠는가? 만약
ResponseStatus를 설정하지 않는다면, ErrorResult객체가 반환은 될것이다. 그러나 400 오류가 아니라 200오류가 될것이다 왜? 우리는 정상 흐름 처럼 작동하게 오류 예외 처리를 해주었기 때문이다.
고로, 상태코드를 지정해줘야한다.

그리고 @RestController의 @ResponseBody가 적용되어서 HTTP컨버터가 사용되고 JSON형식으로 변환해서 반환된다.

@ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e){
     log.error("[exception Handle] ex",e);
     ErrorResult errorResult = new ErrorResult("USER-EX",e.getMessage());
     return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

당연히 API응답에서 ResponseEntity를 사용할 수도 있다. 여기서는 상태코드를 지정할수있으니까 ResponseStatus를 사용하지 않았음을 확인 할수있다.
여기서는 ResponsEntity를 사용해서 HTTP메시지 바디에 직접 응답한다. 당연히 HTTP 컨버터가 작동한다.

참고: HTTP 응답 메시지 바디에 메시지를 설정하는 방법은 다음과 같이 2가지가 존재한다.
1. 반환 시 ResponseEntity 사용
2. @ResponseBody + 반환 객체 사용


 @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e){
        log.error("[exceptionHandle] ex",e);
        return new ErrorResult("EX","내부 오류");
    }

항상 자세한것부터 우선권을 가진다. 만약 부모 클래스와 자식 클래스를 처리할 수 있으면, 자세한것인 자식 클래스의 예외처리가 호출된다.

만약에 우리가 설정한 illegalArgument나 UserException이 아닌 나머지 모든 예외처리를 하고 싶을때 처리하기위해서 Internal 서버에러를 두었다.

API 예외 처리 - @ControllerAdvice

@ExceptionHandler를 사용해서 예외를 깔끔하게 처리했지만 컨트롤러 내부에 정상코드와 예외 처리 코드가 섞여있어서 불편하다.
@ControllerAdvice와 @RestControllerAdvice를 사용하면 둘을 분리할 수 있다.

 @Slf4j
 @RestController
 public class ApiExceptionV2Controller {
    @GetMapping("/api2/members/{id}")
 public MemberDto getMember(@PathVariable("id") String id) {
 if (id.equals("ex")) {
 throw new RuntimeException("잘못된 사용자");
        }
 if (id.equals("bad")) {
 throw new IllegalArgumentException("잘못된 입력 값");
        }
 if (id.equals("user-ex")) {
 throw new UserException("사용자 오류");
        }
 return new MemberDto(id, "hello " + id);
    }
    @Data
    @AllArgsConstructor
 static class MemberDto {
 private String memberId;
 private String name;
    }
 }

컨트롤러는 동일하게 두고
예외처리코드만 빼와서 ExControllerAdvice를 만들었다.

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  @ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
      log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
  }
  @ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
      log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
  }
  @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
  @ExceptionHandler
public ErrorResult exHandle(Exception e) {
      log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
  }
}

여기서 @RestControllerAdvice는 @ControllerAdvice와 같고, @ResponseBody가 추가되어있다.

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글