Spring의 API 예외 처리
Spring

Spring의 API 예외 처리

반응형

개요

  • 이전 글에서 오류와 예외처리에 대한 설명을 진행했다.
  • https://hyeophyeop.tistory.com/150
  • 이전 글은 BaseController에 대한 설명이었으며 개발자는 편하게 error 페이지를 관리할 수 있다는것이 핵심이었다.
  • 하지만 API의 예외 처리는 다르다.
  • HTML 페이지는 4xx, 5xx 오류 페이지만 있으면 대부분의 오류 문제를 해결할 수 있으나 API는 그렇지 않다.
  • 왜냐하면 API는 각 서버와 연동을 하기 때문에 오류 응답 스펙이 다향하며 JSON 데이터로 응답을해줘야 하기 때문이다.
  • 이 글에서는 복잡한 API 예외 처리를 효율적으로 처리하기 위한 ExceptionResolver에 대햇 설명하며 해당 내부기술이 어떻게 동작하는지와 사용법에 대해 설명한다.
 

Spring의 예외처리와 오류페이지

예외 처리랑 오류 페이지를 만드는 작업을 왜 해야 하나요? 웹 사이트 이용시 비정상적인 접근을 할 경우 서버에서 오류 페이지를 반환받은 경험이 있을것이다. 위와 같은 상황에서 개발자는 사

hyeophyeop.tistory.com

Spring의 기본 오류 처리 방식인 BasicController를 활용한 예외 처리

  • 위 메소드는 BasicController의 일부 메소드이다.
  • /error 경로를 처리하는 두 메소드 errorHtml, error는 각각 HTML, JSON 오류를 반환해주는다 작업을 진행한다.
    • errorHtml : produces = mediaTyp.TEXT_HTML_VALU가 어노테이션 옵션이 설정되어있는것을 확인할 수 있는데 이것은 클라이언트 요청의 Accept 헤더값이 text/html일 경우 errorHtml()을 호출해서 View를 제공한다.
    • error() : 그 외 경우에 호출되며 ResponseEntity를 통해 Body에 JSON 데이터를 반환한다.
  • 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. 1.ExceptionHandlerExceptionResolver
    2. 2.ResponseStatusExceptionResolver
    3. 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

 

GitHub - NamHyeop/Spring_Boot_Study: Spring 공부를 하며 기록한 자료들입니다.

Spring 공부를 하며 기록한 자료들입니다. Contribute to NamHyeop/Spring_Boot_Study development by creating an account on GitHub.

github.com

REFERENCE.

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

 

반응형

'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