[Spring] 예외 처리(BasicErrorController, HandlerExceptionResolver)

dooboocookie·2022년 12월 9일
0

목표

  • 웹 애플리케이션은 잘못된 요청, 서버 내부의 에러 등 여러 원인으로 예외 상황을 맞딱뜨리게 된다.
  • 위의 화면은 예외 발생 시, Spring이 기본으로 설정해놓은 Whitelavel Error Page이다.
  • 응답 코드나, 예외 상황에 따라 클라이언트에게 보여줄 HTML 페이지응답할 JSON 객체에 대해 설정해야한다.

웹 애플리케이션에서 예외

  • 서블릿이 지원하는 예외
    • Exception 발생
    • httepServletResponse.sendEroror(HTTP 상태 코드, "예외 메세지");
  • 위의 예외 상황이 발생하면, server.error.whitelabel.enabled 설정에 따라, 스프링부트나 서블릿이 기본적으로 제공하는 예외 페이지가 나타난다.

에러페이지 등록

@Component
public class WebServerCustomizer implements
WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
	@Override
	public void customize(ConfigurableWebServerFactory factory) {
    	// 404 응답 코드 발생 시 > 내부 요청 발생
		ErrorPage errorPage1= new ErrorPage(HttpStatus.NOT_FOUND, "에러페이지 요청 url");
        // 런타임 예외 발생 시 > 내부 요청 발생
		ErrorPage errorPage2= new ErrorPage(RuntimeException.class, "에러페이지 요청 url");
        // 에러 페이지 등록
		factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
	}
}
  • 위의 설정을 더하면, ErrorPage에 예외나 응답 코드 발생 시 Servlet 내부에서 새로 요청을 다시 하게 된다.
    • 이 요청 또한 뷰페이지를 연결하는 컨틀롤러에 매핑해야된다.
  • 따라서 예외 발생 시,

클라이언트 요청WAS서블릿 필터디스패쳐 서블릿인터셉터컨트롤러예외 발생인터셉터디스패쳐 서블릿서블릿 필터WAS
예외 감지 후 ErrorPage 등록 여부에 따라 내부 요청서블릿 필터디스패쳐 서블릿인터셉터컨트롤러예외 발생인터셉터디스패쳐 서블릿View 렌더링

  • 필터와 인터셉터를 2번씩 거치게 된다.

DispatcherType

public enum DispatcherType {

    /**
     * {@link RequestDispatcher#forward(ServletRequest, ServletResponse)}
     */
    FORWARD,

    /**
     * {@link RequestDispatcher#include(ServletRequest, ServletResponse)}
     */
    INCLUDE,

    /**
     * Normal (non-dispatched) requests.
     */
    REQUEST,

    /**
     * {@link AsyncContext#dispatch()}, {@link AsyncContext#dispatch(String)}
     * and
     * {@link AsyncContext#addListener(AsyncListener, ServletRequest, ServletResponse)}
     */
    ASYNC,

    /**
     * When the container has passed processing to the error handler mechanism
     * such as a defined error page.
     */
    ERROR
}
  • 서블릿에서 에러와 맞딱뜨렸을 때 ErrorPage 설정에 의해서 내부 요청이 다시 일어날 때 DispatcherTypeERROR가 된다.
  • 이를 FilterRegistrationBean설정에서 .setDispatcherTypes(DispatcherType.REQUEST)로 주면 DispatcherType.ERROR일 때는 필터를 거치지 않게 된다.
    • 해당 설정으로 예외 요청에도 필요한 필터와 그렇지 않은 필터를 구분할 수 있다.

예외 요청 시 인터셉터

  • 인터셉터는 DispatcherType 여부와 상관 없이 경로 패턴에 따라 호출된다.
  • 따라서 인터셉터에는 .excludePathPatterns()설정을 통하여 예외 요청을 제거해주면 된다.

BasicErrorController

  • 스프링부트에서 기본적으로 제공하는 예외 처리 컨트롤러이다.
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {

	private final ErrorProperties errorProperties;
	
    //... 
    
    // 헤더의 미디어 타입이 text/html일 때 호출되는 컨트롤러 메소드
	@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
	public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
		HttpStatus status = getStatus(request);
		Map<String, Object> model = Collections
				.unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
		response.setStatus(status.value());
		ModelAndView modelAndView = resolveErrorView(request, response, status, model);
		return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
	}

	// 헤더의 미디어 타입이 text/html이 아닐 경우 호출되는 컨트롤러 메소드
	@RequestMapping
	public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
		HttpStatus status = getStatus(request);
		if (status == HttpStatus.NO_CONTENT) {
			return new ResponseEntity<>(status);
		}
		Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
		return new ResponseEntity<>(body, status);
	}
    
    // ...

	protected ErrorAttributeOptions getErrorAttributeOptions(HttpServletRequest request, MediaType mediaType) {
		ErrorAttributeOptions options = ErrorAttributeOptions.defaults();
		if (this.errorProperties.isIncludeException()) {
			options = options.including(Include.EXCEPTION);
		}
		if (isIncludeStackTrace(request, mediaType)) {
			options = options.including(Include.STACK_TRACE);
		}
		if (isIncludeMessage(request, mediaType)) {
			options = options.including(Include.MESSAGE);
		}
		if (isIncludeBindingErrors(request, mediaType)) {
			options = options.including(Include.BINDING_ERRORS);
		}
		return options;
	}   
}

예외를 HTML로 응답할 때

  • errorHtml()의 경우 기본적으로 뷰 페이지 정보를 아래 순서로 탐색한다.
    1. resources/templates/error/500.html or 400.html or 404.html or ...
    2. resources/templates/error/5xx.html or 4xx.html or ...
    3. resources/static/error/500.html or 400.html or 404.html or ...
    4. resources/static/error/5xx.html or 4xx.html or ...
    5. resources/templates/errors.html
  • 해당 경로에 원하는 뷰 정보만 남겨 두면 예외 발생 시 BasicController.errorHtml()로 내부 요청이 일어나서 해당 뷰페이지를 렌더링하여 클라이언트에게 보여준다.

예외를 JSON 객체로 응답할 때

  • 미디어 타입이 text/html 아닌 경우에 예외 발생 시, BasicController.error()로 내부 요청이 일어나서 ResponseEntity<>에 원하는 Map으로 응답받이에 JSON 형태로 담겨 응답한다.

예외 옵션 설정

  • application.properties
# exception 정보 포함 여부
server.error.include-exception=true
# 예외 메세지 포함 여부
server.error.include-message=false
# trace 포함 여부
server.error.include-stacktrace=false
# errors 내용 포함 여부
server.error.include-binding-errors=false
  • 위 설정 내용에 따라 errorHtml()의 모델 어트리뷰트 내용이나, error()의 ResponseEntity 내용이 달라진다.

BasicErrorController의 한계

  • API 통시할 때 발생하는 예외에 대해서는 상황에 따라 크게 다른 내용의 예외 결과를 전달해줘야한다.
    • 따라서, BasicController에서 처리하는 내용과는 다르게 전달하는 내용이 충분하지 않거나, 너무 많은 정보를 넘겨주게 될 수 있다.
  • 또한, 다시 BasicController로 재요청하게 된다.

이러한 한계점 때문에, Spring 에서는 대부분 HandlerExceptionResovler를 사용하여 예외 처리를 한다.

HandlerExceptionResolver

HandlerExceptionResolver 구현

public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        return null;
    }
}
  • 위와 같이 HandlerExceptionResolver를 구현하는 클래스를 만들고 resolveException()를 재정의하므로서 HandlerExceptionResovler를 구현할 수 있다.
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
        resolvers.add(...);
    }
    
    @Override
    public void configureHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
     	// 이 메소드는 Spring이 기본적으로 HandlerExceptionResolver를 무시하게 된다. (사용하지 않는 편이 좋겠따.)
    }
    
}
  • 그 후 Resolver들을 WebMvcConfigurer에 등록해주면 된다.
  • 과정
    1. 예외 발생 시 HandlerExceptionResolver가 DispatcherServlet에서 호출된다.
    2. 매개변수로 요청,응답,핸들러,예외 정보를 갖고 호출된다.
    3. 해단 Resolver에서 해결하고싶은 예외 상황에 대해서 해결하고 ModelAndView를 리턴한다.
    4. 이때, 반환된 ModelAndView는 거의 Controller와 마찬가지로 동작한다.
    5. 응답바디에 JSON객체 등을 직접 쓰고 싶으면 response.getWriter()로 작성 후 빈 ModelAndView를 반환한다.
    6. return null을 하면 해당 resovler는 통과하고 다음 HandlerExceptionResolver를 탐색하고 끝내 해결이 되지 않은 에러는 그냥 WAS로 던져진다.

아주 약간의 문제점

  • resolveException()을 구현해서 사용하게 되면, 반환이 ModelAndView로 고정이 되므로 응답 바디에 직접 내용을 써야하는 JSON 같은 경우에는 번거로움이 있다.
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    	if(ex instanceof RuntimeException) {
            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();
            }
            
            // 500 예외 뷰페이지 렌더링하여 응답
        	return new ModelAndView("error/500");
        }
        
        //다음 리졸버로 넘김
        return null;
    }
}

Spring 에서 기본적으로 제공하는 HandlerExceptionResolver 구현체가 매우 잘 되어 있다.
그를 사용하여 예외 처리

Spring 에서 기본적으로 제공하는 HandlerExceptionResolver 구현체 우선 순위

  1. ExceptionHandlerExceptionResolver
    • @ExceptionHanlder어노테이션을 처리 (⚡️제일 중요)
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

ExceptionHandlerExceptionResolver

  • 핸들러에 @ExceptionHandler 어노테이션이 있는 메소드를 통하여 예외 해결을 시도한다.
@RestController
public class MyController {

	//@ExceptionHandler 
    //@ExceptionHandler(Exception.class)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResultDTO illegalExeptionHandler(IllegalArgumentException e) {
        return new ErrorResultDTO(...);
    }	
    
    @GetMapping("/test")
    public ResultDTO test() {
    	if(예외 발생 시나리오) {
            throw new IllegalArgumentException();
        }
        return new ResultDTO(...);
    }
}
  • @ExceptionHandler에 속성으로 있는 예외(그 자식 예외)에 대해서 해당 처리를 해준다.
    • 예외에 대한 속성이 생략되면 매개변수로 넘어오는 에외에 대해서 처리한다.
  • Controller와 마찬가지로 ModelAndView로 반환값을 잡으면 뷰페이지를 렌더링해서 응답할 수 있다. (이 경우는 BasicErrorController를 사용하는 것이 대부분)
  • ExceptionResolver는 어찌됐는 에러를 해결하고 응답하는 것이므로 상태코드가 200이므로 상태코드를 4xx or 500 으로 적절히 넘겨줄 필요가 있다.
    • @ResponseStatus(HttpStatus.BAD_REQUEST)
    • ResponseEntity(new ErrorResultDTO(), HttpStatus.BAD_REQUEST)로 반환

@ControllerAdvice

  • ExceptionHandlerExceptionResolver 적용을 위해서 매 핸들러 마다 @ExceptionHandler메소드를 넣어야 적용이 가능하다.
  • @ControllerAdvice는 컨트롤러들을 지정하여 @ExceptionHandler메소드를 지정할 수 있다.
    • 따로 ConrollerAdvice에 @ExceptionHandler 메소드를 분리하여 컨트롤러의 메소드들과 분리할 수 있다.

가장 많이 쓸 형태

//@ControllerAdvice(annotations = RestController.class)
//@ControllerAdvice("org.test")
//@ControllerAdvice(assignableTypes = {MyController.class, ...})
@RestControllerAdvice(annotations = RestController.class)
public class ExceptionControllerAdvice {
    //@ExceptionHandler 
    //@ExceptionHandler(Exception.class)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResultDTO illegalExeptionHandler(IllegalArgumentException e) {
        return new ErrorResultDTO(...);
    }	
}
@RestController
public class MyController {
    
    @GetMapping("/test")
    public ResultDTO test() {
    	if(예외 발생 시나리오) {
            throw new IllegalArgumentException();
        }
        return new ResultDTO(...);
    }
    
}
  • 대상
    • 특정 어노테이션
    • 특정 패키지 (하위 항목 포함)
    • 특정 클래스
    • 지정하지 않으면 모든 컨트롤러에 해당

ResponseStatusExceptionResolver

  • 예외 위에 @ResponseStatus(value = HttpStatus.NOT_FOUND, reason)를 지정한 예외를 인지하여 예외 결과 반환

DefaultHandlerExceptionResolver

  • 스프링 내부의 에러를 처리
    • 예) TypeMismatchException
profile
1일 1산책 1커밋

0개의 댓글