API 예외 처리

YH·2023년 4월 25일
0

✅ 목표

  • 이전 포스트에서 에러 발생 시 뷰 템플릿으로 오류 페이지를 보여주도록 처리를 해봤다면, 이번 포스트에선은 API 경우에 어떤식으로 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON 형태로 데이터를 반환해 줄지 알아본다.

✅ API 응답 - 시작

✔️ 예외 발생 시, API 형태로 내려주는 코드를 작성해본다.

@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(RequestDispatcher.ERROR_STATUS_CODE);

        return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
    }
  • produces = MediaType.APPLICATION_JSON_VALUE : HTTP 요청 헤더의 Accept 값이 application/json 일 때 해당 메소드가 호출된다는 의미
  • Jackson 라이브러리는 Map을 JSON 형태로 변환할 수 있다.
  • 반환 형식이 ResponseEntity이기 때문에 메시지 컨버터가 동작하면서 JSON 형태로 데이터를 반환한다.

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

✔️ 스프링 부트에서 제공하는 기본 오류 방식을 알아본다.

✔️ BasicErrorController

  • 스프링이 제공하는 BasicErrorController 컨트롤러에는 /error 경로에 대해 처리하는 메소드가 존재한다.
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
  • 위 두 메소드는 동일하게 /error 경로에 대해 처리를 하며, errorHtml() 은 HTTP 요청 헤더의 Accept 값이 text/hrml 인 경우에 호출되고, view를 제공한다.
  • 나머지 경우는 error() 메소드가 호출되고 ResponseEntity 로 HTTP 바디에 JSON 데이터를 반환한다.

💡 BasicErrorController 를 사용하면 HTML 페이지를 호출할 때는 오류에 대한 페이지를 잘 처리해준다. 하지만, API 같은 경우에는 기본적으로 JSON 데이터를 반환해주기는 하지만 서비스에 따라 응답 결과나 형태가 다른 경우가 대부분이다. 따라서 BasicErrorController를 통한 API 오류 처리보다는 @ExceptionHandler를 사용하여 처리하는 것이 좋다.

✅ API 예외 처리 - HandlerExceptionResolver 시작

✔️ 여기서는 예외가 발생했을 때 오류 메시지 형식 등을 API마다 다르게 처리하고 싶을 때 어떻게 해야할지 방법을 정리한다.

✔️ HandlerExceptionResolver(ExceptionResolver)

  • 스프링 MVC에서 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 처리하고 동작 방식을 변경하고 싶은 경우 HandlerExceptionResolver를 사용할 수 있다.

HandlerExceptionResolver 인터페이스

public interface HandlerExceptionResolver {

	ModelAndView resolveException(
		HttpServletRequest request, HttpServletResponse response,
		Object handler, Exception ex);
	}
  • handler : 핸들러(컨트롤러) 정보
  • Exception ex : 핸들러(컨트롤러)에서 발생한 예외

HandlerExceptionResolver 구현 예제

@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;
    }
}
  • ExceptionResolverModelAndView를 반환하는 이유는 Exception을 처리해서 마치 정상 흐름처럼 변경하려는 목적이다.
  • 위 예제에서는 특정 Exception이 발생하면 상태 코드 400을 지정하고 빈 ModelAndView를 반환한다.
  • 반환되는 값에 대한 동작 방식은 아래와 같다.
    • 빈 ModelAndView : 빈 ModelAndView를 반환하면 뷰를 렌더링 하지 않고 정상 흐름으로 서블릿이 리턴된다.
    • ModelAndView : ModelAndView에 View, Model 정보를 지정해서 반환하면 뷰를 렌더링한다.
    • null : null을 반환하면 다음 ExceptionResolver를 찾아서 실행한다. 처리할ExpceptionResolver가 없으면 예외 처리가 안되고 서블릿 밖으로 예외를 던진다.

ExceptionResolver 활용

  • 예외 상태 코드 변환
    • 예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
    • 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어서 스프링 부트가 기본으로 설정한 /error 가 호출됨
  • 뷰 템플릿 처리
    • ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
  • API 응답 처리
    • response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다. 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다

새로 만든 ExceptionResolver를 Config에 추가

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

✅ HandlerExceptionResolver 활용

✔️ ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 예외를 안에서 처리하고 서블릿 컨테이너까지 예외가 전달되지 않게할 수 있다. 아래 예제 코드를 보자.

@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) {
            throw new RuntimeException(e);
        }

        return null;
    }
}
  • UserException 이 발생하면 HTTP 요청 헤더의 Accept 에 따라서 JSON으로 내려주거나 HTML 오류 페이지를 보여준다. 여기서 예외 처리가 끝나고, 서블릿 컨테이너까지 전달되지 않는다.
  • 그러나, ExceptionResolver를 직접 구현하는 것은 상당히 복잡하므로 스프링이 제공해주는 ExceptionResolver를 사용하자.

✅ API 예외 처리 - 스프링이 제공하는 ExceptionResolver 1

✔️ 스프링 부트가 제공하는 ExceptionResolver는 아래와 같고, HandlerExceptionResolverComposite에 순서에 따라 등록한다.
1. ExceptionHandlerExceptionResolver → @ExceptionHandler 을 처리한다.
2. ResponseStatusExceptionResolver → HTTP 상태 코드를 지정해준다.
3. DefaultHandlerExceptionResolver → 스프링 내부 기본 예외를 처리한다.

  • 여기서는 가장 간단한 ResponseStatusExceptionResolver 부터 알아본다.

✔️ ResponseStatusExceptionResolver

  • ResponseStatusExceptionResolver는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 하며, 아래 두 가지 경우를 처리한다.
    • @ResponseStatus가 붙어있는 예외
    • ResponseStatusException 예외
  1. @ResponseStatus가 붙어있는 예외
//BadRequestException class
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
  • 위와 같은 Excepction 클래스가 있을 때, BadRequestException을 발생시키면, @ResponseStatus에 있는 값을 보고 response.sendError(statusCode, resolvedReason)로 호출한다.
  • 결국 WAS에서 다시 오류 페이지(/error)를 내부 요청하게 된다.
  • reason 은 MessageSource에서 메시지를 찾아서 해당 메시지로 출력해주는 기능도 있다.
    • messages.properties
      ex ) error.bad=잘못된 요청 오류입니다. 메시지 사용

  1. ResponseStatusException 예외
  • @ResponseStatus 는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없고, 어노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다.
  • 이럴 경우에 ResponseStatusException 예외를 사용하면 된다.
//Controller
@GetMapping("/api/response-status-ex2")
    public String responseStatusEx2() {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
    }

✅ API 예외 처리 - 스프링이 제공하는 ExceptionResolver 2

✔️ DefaultHandlerExceptionResolver

  • DefaultHandlerExceptionResolver 스프링 내부에서 발생하는 스프링 예외를 처리한다.
    대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우에는 따로 처리하지 않으면 서블릿 컨테이너까지 오류가 전달되고 500오류가 발생한다.
    그런데 파라미터 바인딩은 대부분 클라이언트에서 요청을 잘못 보내서 발생하는 문제이므로 상태 코드 400을 사용하도록 되어있다.
  • DefaultHandlerExceptionResolver 은 이것을 500이 아닌 400오류로 변경해준다.
  • 코드를 확인해보면 response.sendError(HttpServletResponse.SC_BAD_REQUES 를 호출한다. 이 것 또한 sendError()를 호출했기 때문에 WAS에서 다시 오류 페이지(/error)를 내부 요청한다.

타입을 맞지 않게 호출해보면 아래와 같이 400 오류가 찍힌다.

{
"status": 400,
"error": "Bad Request",
"exception":"org.springframework.web.method.annotation.MethodArgumentTypeMismatchException",
"message": "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'; 
  nested exception is java.lang.NumberFormatException: For input string: \"hello\"",
"path": "/api/default-handler-ex"
}

✅ API 예외 처리 - @ExceptionHandler

✔️ API는 각 시스템 마다 오류 응답 내용이나 스펙이 모두 다르기 때문에, 지금까지 알아본 BasicErrorController를 사용하거나 HandlerExceptionResolver를 직접 구현하는 방법으로 API 예외 처리를 하려면, HttpServletResponse에 직접 응답 데이터를 넣어주고 ModelAndView를 반환하므로 복잡하고 API 응답에는 적절하지 않다.
그래서, 스프링은 API 예외 처리를 편리하게 하도록 @ExceptionHandler 라는 어노테이션을 제공하는데, 이 것이 바로 ExceptionHandlerExceptionResolver이다.
기본으로 제공하는 ExceptionResolver 중 에서도 우선 순위가 가장 높다. 실무에서도 API 예외 처리는 대부분 이 기능을 사용한다.

✔️ 아래 예제를 통해 @ExceptionHandler를 어떻게 사용하는지 알아본다.

@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("[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", "내부 오류");
    }

    @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;
    }
}
  • @ExceptionHandler 어노테이션을 선언하고 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
  • 지정한 예외의 하위 클래스들의 예외도 모두 처리할 수 있다.
    • 예를 들어 아래와 같이 설정되어 있으면 IllegalArgumentException 클래스 및 하위 클래스들도 모두 처리 가능한 것이다.
      @ExceptionHandler(IllegalArgumentException.class)
         public ErrorResult illegalExHandle(IllegalArgumentException e) {
             log.error("[exceptionHandle] ex", e);
             return new ErrorResult("BAD", e.getMessage());
         }
  • 대신 우선 순위는 항상 자세한 것이 우선권을 가지므로, 자식 클래스(RuntimeException)와 부모 클래스(Exception) 모두 선언되어 있는 경우, 자식 예외(RuntimeException) 발생 시, 자식 예외 처리 메소드가 호출된다. 부모 예외(Exception) 발생 시에는 당연히 부모 예외 처리 메소드가 호출된다. 자식 예외로 부모 예외를 처리할 수 없기 때문이다.
  • 아래와 같이 다양한 예외를 한 번에 처리도 가능하다.
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
	log.info("exception e", e);
}
  • @ExceptionHandler 에 예외 클래스를 생략할 수 있다. 생략하면 메소드의 파라미터로 넣어준 예외 클래스로 지정된다.
@ExceptionHandler //@ExceptionHandler(UserException.class)와 동일
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

✔️ 그럼 이제 각 예제에 대한 실행 흐름을 정리해본다.

  • 예제 1
@ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }
  1. 컨트롤러를 호출해서 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
  2. 예외가 발생했으므로 ExceptionResolver가 동작하고 가장 우선 순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
  3. ExceptionHandlerExceptionResolver는 IllegalArgumentException을 처리할 수 있는 ExceptionHandler가 있는지 확인한다.
  4. illegalExHandle() 메소드를 실행한다. 클래스에 @RestController를 선언해놔서 해당 메소드는 @ResponseBody가 적용된다.
    따라서 HTTP Converter가 사용되고 응답이 JSON 형태로 반환된다.
  5. @ResponseStatus를 BAD_REQUEST로 지정했으므로 HTTP 상태 코드는 400으로 응답한다.

  • 예제 2
@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);
    }
  1. 여기서는 @ExceptionHandler에 예외 클래스를 지정하지 않았으므로 파라미터의 UserException 예외를 사용한다.
  2. ResponseEntity를 사용하면 HTTP Converter가 사용되므로 HTTP 메시지 바디에 직접 JSON 형태로 반환한다.
  3. 또한, ResponseEntity를 사용하면 HTTP 응답 코드를 동적으로 변경해줄 수 있다.

  • 예제 3 (HTML 오류 화면 응답) - 아래와 같이 ModelAndView로 뷰를 반환하는 것도 가능하다.
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
	log.info("exception e", e);
	return new ModelAndView("error");
}

✅ API 예외 처리 - @ControllerAdvice

✔️ @ControllerAdvice 또는 @RestControllerAdvice를 사용하면 정상 처리 로직과 예외 처리 로직을 분리할 수 있다.

✔️@ControllerAdvice (@RestControllerAdvice)

  • @ControllerAdvice는 대상으로 지정한 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다.
  • @ControllerAdvice에 대상 컨트롤러를 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌)
  • @RestControllerAdvice 는 기능은 같고 @ResponseBody가 추가된 것이다.

대상 컨트롤러 지정 방법

// 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 {}
profile
하루하루 꾸준히 포기하지 말고

0개의 댓글