API에서 에러가 발생하면 HTML 페이지가 아닌 JSON 형태의 에러 데이터를 클라이언트에게 보내야 한다.
스프링 부트는 HTML뿐만 아니라 API 예외처리도 기본적으로 제공한다.

스프링부트 기본 오류처리

  • BaseErrorController
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)    // text/html
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}

@RequestMapping            // 그 외(applcation/json 등)
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}

API 예외처리

  • 예외처리는 ExceptionResolver가 처리한다.

API 예외처리 로직

image

  1. 컨트롤러 이하에서 로직 수행 중 예외처리가 발생하면 DispatcherServlet까지 예외가 던져진다
  2. processHandlerExcpetion()이 호출되어 적절한 HandlerExceptionResolver를 가져온다
  3. 호출된 ExceptionResolver가 예외를 처리한다
public interface HandlerExceptionResolver {
    @Nullable
    ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
}
  • Object handler : 핸들러 정보
  • Exception ex : 해당 핸들러에서 발생한 Exception 정보
  • ModelAndView를 return 함으로써 클라이언트에게 정상 흐름으로 반환된다
public class DispatcherServlet extends FrameworkServlet {
    protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
        ....
        processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
        ....
  }

    private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
            @Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
            @Nullable Exception exception) throws Exception {
        ....
        // Exception 발생시
        if (exception != null) {
            if (exception instanceof ModelAndViewDefiningException) {
                ...
            }
            else {
                Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
                mv = processHandlerException(request, response, handler, exception);
                errorView = (mv != null);
            }
        }
      ....
   }

  protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
            @Nullable Object handler, Exception ex) throws Exception {
        ...
        // 등록된 HandlerExceptionResolvers 탐색
        ModelAndView exMv = null;
        if (this.handlerExceptionResolvers != null) {
            for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
                exMv = resolver.resolveException(request, response, handler, ex);
                if (exMv != null) {
                    break;
                }
            }
        }
        ....
        throw ex;
    }
}

스프링이 제공하는 ExceptionResolver

  • 우선순위
    1. ExceptionHandlerExceptionResolver
    2. ResponseStatusExceptionResolver
    3. DefaultHandlerExceptionResolver

★ 1. ExceptionHandlerExceptionResolver

  • @ExceptionHandler로 처리한 exception
  • 세밀한 에러처리가 가능하다

ExceptionAdvice

  • @ExceptionHandler : 설정한 예외가 던져지면 해당 핸들러가 실행되어 처리한다
  • @ControllerAdvice
    • 여러 컨트롤러에서 발생한 예외를 한 곳에서 관리하고 처리할 수 있게 도와주는 어노테이션
    • 대상으로 지정한 여러 Controller에 @ExceptionHandler, @InitBinder 기능을 부여한다
    • 대상을 지정하지 않으면 모든 Controller에 적용된다
@Log4j2
@RestControllerAdvice
public class ExceptionAdvice {

    /*
    * Custom Exception
    */
    @ExceptionHandler(ApiException.class)
    protected ResponseEntity<ErrorResponse> apiExceptionHandler(final ApiException e) {
        log.error("ApiException : [" + e.getErrorCode().getCode() + "] " + e.getErrorCode().getMessage());
        final ErrorCode errorCode = e.getErrorCode();
        final ErrorResponse response = ErrorResponse.of(errorCode);
        return ResponseEntity.status(errorCode.getStatus()).body(response);
    }

    /*
    * @Validated Binding Exception
    */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<ErrorResponse> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
        log.error("handleMethodArgumentNotValidException", e);
        final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }

    /*
     * @ModelAttribute Binding Exception
     */
    @ExceptionHandler(BindException.class)
    protected ResponseEntity<ErrorResponse> bindExceptionHandler(BindException e) {
        log.error("handleBindException", e);
        final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }

    /*
    * HTTP method not allowed
    */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    protected ResponseEntity<ErrorResponse> httpRequestMethodNotSupportedExceptionHandler(HttpRequestMethodNotSupportedException e) {
        log.error("handleHttpRequestMethodNotSupportedException", e);
        final ErrorResponse response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED);
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(response);
    }


    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> exceptionHandler(Exception e) {
        log.error("handleEntityNotFoundException", e);
        final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

ApiException

  • 커스텀 Exception
@Getter
public class ApiException extends RuntimeException {

    private ErrorCode errorCode;

    public ApiException(ErrorCode e) {
        super(e.getMessage());
        this.errorCode = e;
    }

    public ApiException(ErrorCode e, String message) {
        super(message);
        this.errorCode = e;
    }
}

ErrorResponse (DTO)

  • HttpStatus code, error code, error message를 전달할 객체
  • errors를 List로 바인딩 할 수 있게 작성
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {

    private int status;
    private String code;
    private String message;
    private List<CustomFieldError> errors;

    private ErrorResponse(final ErrorCode code) {
        this.status = code.getStatus().value();
        this.code = code.getCode();
        this.message = code.getMessage();
        this.errors = new ArrayList<>();
    }

    private ErrorResponse(final ErrorCode code, final List<CustomFieldError> errors) {
        this.status = code.getStatus().value();
        this.code = code.getCode();
        this.message = code.getMessage();
        this.errors = errors;
    }

    public static ErrorResponse of(final ErrorCode code) {
        return new ErrorResponse(code);
    }

    public static ErrorResponse of(final ErrorCode code, final List<CustomFieldError> errors) {
        return new ErrorResponse(code, errors);
    }

    public static ErrorResponse of(final ErrorCode code, final BindingResult bindingResult) {
        return new ErrorResponse(code, CustomFieldError.of(bindingResult));
    }

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public static class CustomFieldError {
        private String field;
        private String value;
        private String message;

        public static List<CustomFieldError> of(final String field, final String value, final String message) {
            List<CustomFieldError> fieldErrors = new ArrayList<>();
            fieldErrors.add(new CustomFieldError(field, value, message));
            return fieldErrors;
        }

        private static List<CustomFieldError> of(final BindingResult bindingResult) {
            final List<FieldError> fieldErrors = bindingResult.getFieldErrors();
            return fieldErrors.stream().map(error -> new CustomFieldError(
                    error.getField(),
                    error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(),
                    error.getDefaultMessage()))
                .collect(Collectors.toList());
        }
    }
}

ErrorCode (enum)

  • 커스텀 에러코드를 enum으로 관리한다
@Getter
@ToString
public enum ErrorCode {
    // COMMON
    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "CO01", "잘못된 입력값 입니다."),
    NO_AUTHENTICATION(HttpStatus.UNAUTHORIZED, "CO02", "권한이 없습니다."),
    NO_AUTHORIZATION(HttpStatus.FORBIDDEN, "CO03", "권한이 없습니다."),
    NOT_FOUND(HttpStatus.NOT_FOUND, "CO04", "잘못된 요청입니다."),
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "CO05", "잘못된 요청입니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "CO06", "서버 에러입니다."),

    // USER
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "US01", "존재하지 않는 회원입니다."),
    FOUND_NAME(HttpStatus.CONFLICT, "US02", "이미 존재하는 닉네임입니다."),

    // SUPPORT
    UNOPENED_CONTENT(HttpStatus.FORBIDDEN, "SU01", "비공개 게시글입니다."),
    NOT_FOUND_CONTENT(HttpStatus.NOT_FOUND, "SU02", "존재하지 않는 게시글입니다.");

    private final HttpStatus status;
    private final String code;
    private String message;

    ErrorCode(HttpStatus status, String code) {
        this.status = status;
        this.code = code;
    }

    ErrorCode(HttpStatus status, String code, String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }
}

2. ResponseStatusExceptionResolver

  • @ResponseStatus로 처리한 exception
  • 해당 커스텀 Exception이 발생했을 때 설정한 Response Status가 반환된다
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 오청 오류")
public class BadRequestException extends RuntimeException {
    public BadRequestException(String message) {
        super(message);
    }
}
  • 만약에 message가 null로 비어서 표출된다면 다음 설정을 추가한다
server:
  error:
    include-message: always
    include-binding-errors: always

3. DefaultHandlerExceptionResolver

  • 스프링 내부 기본 예외를 처리하는 exception
  • 다양한 Exception 경우를 스프링에서 기본으로 구현해 놓았다

Spring Web Mvc Exception 공식문헌