반응형
개요
- 이전 글에서 오류와 예외처리에 대한 설명을 진행했다.
- https://hyeophyeop.tistory.com/150
- 이전 글은 BaseController에 대한 설명이었으며 개발자는 편하게 error 페이지를 관리할 수 있다는것이 핵심이었다.
- 하지만 API의 예외 처리는 다르다.
- HTML 페이지는 4xx, 5xx 오류 페이지만 있으면 대부분의 오류 문제를 해결할 수 있으나 API는 그렇지 않다.
- 왜냐하면 API는 각 서버와 연동을 하기 때문에 오류 응답 스펙이 다향하며 JSON 데이터로 응답을해줘야 하기 때문이다.
- 이 글에서는 복잡한 API 예외 처리를 효율적으로 처리하기 위한 ExceptionResolver에 대햇 설명하며 해당 내부기술이 어떻게 동작하는지와 사용법에 대해 설명한다.
Spring의 기본 오류 처리 방식인 BasicController를 활용한 예외 처리
- 위 메소드는 BasicController의 일부 메소드이다.
- /error 경로를 처리하는 두 메소드 errorHtml, error는 각각 HTML, JSON 오류를 반환해주는다 작업을 진행한다.
- errorHtml :
produces = mediaTyp.TEXT_HTML_VALU
가 어노테이션 옵션이 설정되어있는것을 확인할 수 있는데 이것은 클라이언트 요청의 Accept 헤더값이 text/html일 경우 errorHtml()을 호출해서 View를 제공한다. - error() : 그 외 경우에 호출되며 ResponseEntity를 통해 Body에 JSON 데이터를 반환한다.
- errorHtml :
- BasicController를 사용하면 아래와 같은 실행 결과를 만날수 있다.
- 그러나 이런 방식은 응답코드의 변경을 통제하는데 불편할 뿐더러 상황에 맞는 API 오류를 작성하기에 불편하다.(400을 반환하고 싶은데 할 수 가 없어!)
- 실무에서도 이와 같은 방식을 사용하지 않기 때문에 예제 코드는 첨부하지 않는다. 다만 기존 BasicController로도 가능하다는 정도만 알아두자.
HandlerExceptionResolver를 활용한 예외처리
- 이전의 BasicController의 문제는 MVC 컨트롤러 밖으로 예외가 던져진 경우 통제하지 못해 API 예외처리에서 상태코드 500만 응답했다는것이다.
- 스프링 MVC는 이와 같은 예외 처리를 제공하기 위해 HandlerExceptionResolver를 제공한다. 줄여서 ExceptionResolver라고 하며 이를 활용해 예외처리를 진행하자
HnadlerExeptionResolver 인터페이스
- Handler : 핸들러(컨트롤러) 정보를 의미한다.
- Exception ex : 핸들러에서 발생한 예외
- HandlerExceptionResolver는 반환 값에 따라서 DispatcherServlert의 동작 방식이 바뀐다.
- 빈 ModelAndVIew : new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
- MoelAndView 지정 : ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
- null : null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.
- 아래의 예제 코드를 통해서 예시를 들어보겠다.
- try문안의 if절에 return값이 new ModelAndView인걸 볼 수 있다.
- 이는 response에 error를 첨부하여 WAS로 전송하여 정상흐름으로 바꾸게 되며 이를 통해 Error요청을 위해 다시 한번 WAS에서부터 Controller로 오는 Cost를 줄인다.
- 두 사진을 비교하면 좀 더 이해하기 쉽다. ExceptionResolver를 적용하기 전에는 Error 요청을 WAS로 다시 전달하지만 ExceptionResolver를 적용한 이후에는 ModelAndView를 반환하여 정상적인 로직으로 View에 반환하게된다
스프링의 ExceptionResolver
- Spring Boot가 기본으로 제공하는 ExceptionResolver는 다음과 같다.
- 1.ExceptionHandlerExceptionResolver
- 2.ResponseStatusExceptionResolver
- 3.DefaultHandlerException Resolver
- 위의 3가지 Resolver는 우선순위가 높은 순더대로 HandlerExceptionResolverComposite에 등록되어 동작한다.
- 순서대로 3가지의 Resolver에 대해 설명하겠다.
ExceptionHandlerExceptionResolver(중요)
- HandlerExceptionResolver 를 직접 사용하기는 복잡하다. API 오류 응답의 경우 response 에직접 데이터를 넣어야 해서 매우 불편하고 번거롭다.
- ModelAndView 를 반환해야 하는 것도 API에는 잘맞지 않는다.
- 스프링은 이 문제를 해결하기 위해 @ExceptionHandler 라는 매우 혁신적인 예외 처리 기능을 제공하며 @ExceptioHandler를 통해 편리하게 API 예외를 처리할 수 있다.
- 실무에서 가장 많이 사용하는 방법이다.
- 예제 코드를 먼저 확인해보자
ErrorResult
package hello.exception.exhandler;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
ApiExceptionV2Controller
package hello.exception.api;
import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@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;
}
}
ExceptionControllerAdvice
package hello.exception.exhandler.advice;
import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
//RestControllerAdvice는 @ControllerAdvice + @ResponseBody이다
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
//IllegalArgumentException 발생시 예외 처리
//중요. 정상 흐름이 반환되기 때문에 이런 방식으로 응답할거면 응답 코드가 정상 200이 반환됨, 응답 코드도 400을 요청하고 싶으면 @ResponseStatus를 설정해야함
//200이 반환되는 이뉴는 ModelAndView로 객체를 반환하기때문에 정상 프로세스로 종료되기 때문이다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e){
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
//UserException 발생시 예외 처리
//위와 다르게 동적으로 응답메시지를 바꿔서 반환이 가능함. 조건문 사용이 가능하기 때문에
//ExceptionHandler의 ()매개변수는 메소드의 매개변수로 생략이 가능함. 위의 illegalExhandle 메소드랑 비교해서 보면된다.
@ExceptionHandler
//위와 아래는 같다. 매개변수의 자료형이 기본값으로 설정되어 있다.
// @ExceptionHandler(UserException.class)
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);
}
//Exception은 다른 Exception 오류들의 최상위 부모이다.
//즉 해결하지 못한 예외 발생시 exHandle이 동작한다.
//가장 많이 사용하는 방법. response.senderror는 @ResponseStatus를 사용하고 예외는 ExceptionResolver를 사용하여 캐치한다.
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e){
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
- 예제의 getMember의 Get 요청으로 IllgalArgumentException이 발생했다고 가정하자
- 이후 ExceptionHandler에 등록한 IllgalArgumentException.class와 일치하여 ApiExceptionV2Controller의 illgalExHandle이 동작한다.
- exceptionHandler의 사용방법은 어노테이션을 선언하고 해당 컨트롤에 처리하고 싶은 예외를 지정해주면된다.
- 해당 컨트롤러에서 예외가 발생하면 지정해둔 클래스 또는 패키지의 @ExceptionHandler가 동작한다.
- @ControllerAdvice를 사용하여 예외를 동작할 Controller, 패키지명, 클래스를 정할 수 도 있다.
ControllerAdvice를 활용한 대상 컨트롤러 지정방법
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class,
AbstractController.class})
public class ExampleAdvice3 {}
우선순위
- 예외가 상속관계 일때 자식 예외가 발생하면 부모예외처리, 자식예외처리 둘 다 호출 대상이다.
- 이후 둘 중 더 자세한 것이 우선 동작권을 가지므로 자식예외처리가 호출되어 동작한다.
- 예를 들어 Exception.class와 IllegalArgumentException.class를 예외처리로 지정할 경우 좀 더 구체적인 예외 타입인 IllegalArgumentException.class가 동작한다.
ResponseStatusExceptionResolver(중요)
- ResponseSattusExceptionResolver는 다음과 같은 2가지 예외를 처리한다.
@ResponseStatus가 사용된 예외
- 위와 같이 @ResponseStatus가 사용된 예외에 code를 활용해 응답코드를 설정해준다.
- ResponseStatusExceptionResolver가 해당 어노테이션을 확인한 뒤 HttpStatus.BAD_REQUEST(400)오류로 변경하고 메시지를 담는다.
- ResponseStatusExceptionResolver 코드를 확인해보면 servlet의 response.sendError(statusCode,resolvedReason) 를 호출하는 것을 확인할 수 있다. sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다.
- @ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다.(애노테이션을 직접 넣어야하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.)
- 추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는ResponseStatusException 예외를 사용하면 된다.
ResponseStatusException 예외
DefaultHandlerExceptionResolver
- DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결한다.
- 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서TypeMismatchException 이발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.
- 그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다.
- HTTP 에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다.
- DefaultHandlerExceptionResolver 는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경하여 응답한다.
- 우선순위가 3번째인 이유는 ExceptionResolver에서도 해결못한 오류를 오류 상태코드에 맞게 응답해주기 위함이며 Spring이 제공하는 마지막 보루라고 생각하면 이해하기 쉽다.
- DefaultHandlerExceptionResolver의 doResolveException에 핵심 오류코드 반환 로직이 구현되어있다.
DefaultHandlerExceptionResolver의 doResolveException
@Override
@Nullable
protected ModelAndView doResolveException(
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
//해당 Exception에 일치할 경우 오류코드를 반환.
try {
// method가 일치하지 않을 경우 405 오류 반환. handleHttpRequestMethodNotSupported를 확인 해보자.
if (ex instanceof HttpRequestMethodNotSupportedException) {
return handleHttpRequestMethodNotSupported(
(HttpRequestMethodNotSupportedException) ex, request, response, handler);
}
else if (ex instanceof HttpMediaTypeNotSupportedException) {
return handleHttpMediaTypeNotSupported(
(HttpMediaTypeNotSupportedException) ex, request, response, handler);
}
else if (ex instanceof HttpMediaTypeNotAcceptableException) {
return handleHttpMediaTypeNotAcceptable(
(HttpMediaTypeNotAcceptableException) ex, request, response, handler);
}
else if (ex instanceof MissingPathVariableException) {
return handleMissingPathVariable(
(MissingPathVariableException) ex, request, response, handler);
}
else if (ex instanceof MissingServletRequestParameterException) {
return handleMissingServletRequestParameter(
(MissingServletRequestParameterException) ex, request, response, handler);
}
else if (ex instanceof ServletRequestBindingException) {
return handleServletRequestBindingException(
(ServletRequestBindingException) ex, request, response, handler);
}
else if (ex instanceof ConversionNotSupportedException) {
return handleConversionNotSupported(
(ConversionNotSupportedException) ex, request, response, handler);
}
else if (ex instanceof TypeMismatchException) {
return handleTypeMismatch(
(TypeMismatchException) ex, request, response, handler);
}
else if (ex instanceof HttpMessageNotReadableException) {
return handleHttpMessageNotReadable(
(HttpMessageNotReadableException) ex, request, response, handler);
}
else if (ex instanceof HttpMessageNotWritableException) {
return handleHttpMessageNotWritable(
(HttpMessageNotWritableException) ex, request, response, handler);
}
else if (ex instanceof MethodArgumentNotValidException) {
return handleMethodArgumentNotValidException(
(MethodArgumentNotValidException) ex, request, response, handler);
}
else if (ex instanceof MissingServletRequestPartException) {
return handleMissingServletRequestPartException(
(MissingServletRequestPartException) ex, request, response, handler);
}
else if (ex instanceof BindException) {
return handleBindException((BindException) ex, request, response, handler);
}
else if (ex instanceof NoHandlerFoundException) {
return handleNoHandlerFoundException(
(NoHandlerFoundException) ex, request, response, handler);
}
else if (ex instanceof AsyncRequestTimeoutException) {
return handleAsyncRequestTimeoutException(
(AsyncRequestTimeoutException) ex, request, response, handler);
}
}
catch (Exception handlerEx) {
if (logger.isWarnEnabled()) {
logger.warn("Failure while trying to resolve exception [" + ex.getClass().getName() + "]", handlerEx);
}
}
return null;
}
/**
* Handle the case where no request handler method was found for the particular HTTP request method.
* <p>The default implementation logs a warning, sends an HTTP 405 error, sets the "Allow" header,
* and returns an empty {@code ModelAndView}. Alternatively, a fallback view could be chosen,
* or the HttpRequestMethodNotSupportedException could be rethrown as-is.
* @param ex the HttpRequestMethodNotSupportedException to be handled
* @param request current HTTP request
* @param response current HTTP response
* @param handler the executed handler, or {@code null} if none chosen
* at the time of the exception (for example, if multipart resolution failed)
* @return an empty ModelAndView indicating the exception was handled
* @throws IOException potentially thrown from {@link HttpServletResponse#sendError}
*/
protected ModelAndView handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
String[] supportedMethods = ex.getSupportedMethods();
if (supportedMethods != null) {
response.setHeader("Allow", StringUtils.arrayToDelimitedString(supportedMethods, ", "));
}
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, ex.getMessage());
return new ModelAndView();
}
정리
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e){
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
- 위의 형식이 가장 많이 사용하는 예외처리 방법이다.
- response.senderror의 응답코드는 @ResponseStatus를 사용하여 등록해주고 예외는 ExceptionResolver를 사용하여 예외를 Catch하자
예제의 모든 코드
https://github.com/NamHyeop/Spring_Boot_Study/tree/master/4.spring-MVC2/exception
REFERENCE.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
반응형
'Spring' 카테고리의 다른 글
Spring의 예외처리와 오류페이지 (0) | 2022.07.04 |
---|---|
Spring의 필터와 인터셉터 (0) | 2022.07.01 |
Spring의 쿠키,세션 (0) | 2022.06.29 |
Spring의 Bean Validation (0) | 2022.06.28 |
Spring 메시지, 국제화 (0) | 2022.06.27 |