[Spring Web MVC] API 예외 처리
웹 페이지의 경우, 4xx / 5xx 번호마다 오류 페이지만 작성해주면 에러 페이지 관련 대부분의 문제를 쉽게 해결할 수 있다.
웹 뿐만 아니라 모바일, 서버 간의 통신 등에서 예외를 처리할 때는 HTML로 처리할 수 없고 API를 사용해서 통신해야 한다.
즉, 클라이언트에게 보여주는 HTML 화면이 아니라 API를 통해 정확한 데이터를 뿌려 줘야 한다.
API 통신에는 국제적으로 정해진 약속이 없기 때문에 서버와 API를 요청하는 클라이언트가 서로 오류 응답 스펙을 정의해야한다.
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response){
log.info("500 호출됨");
return "error-page/500";
}
@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(ERROR_STATUS_CODE);
return new ResponseEntity(result, HttpStatus.valueOf(statusCode));
}
같은 URL로 요청이 들어와도 클라이언트가 요청하는 HTTP 헤더의 Accept 값에 따라 호출되는 메서드를 다르게 조작할 수 있다.
(produces 를 사용해 요청 헤더의 accpet 값이 application/json 일 때 해당 메서드가 호출되도록 했다.)
Jackson 라이브러리는 Map을 JSON 구조로 변환할 수 있고, ReponseEntity를 사용해서 응답하기 때문에 메세지 컨버터를 통해 클라이언트에게 JSON 타입 데이터가 반환된다.
API 예외 처리에서도 스프링이 기본으로 제공하는 BasicErrorController를 사용할 수 있다.
클라이언트 요청 헤더의 Accept 값이 text/html 인 경우 errorHtml() 메서드를, 그 외의 경우는 error() 메서드를 호출해 ResponseEntity로 HTTP 바디에 JSON 객체를 반환한다.
컨트롤러가 기본으로 제공하는 정보를 바탕으로 오류 API를 생성하고, BasicErrorController를 확장하면 JSON 메세지도 변경할 수 있다. (옵션을 통해 오류 메세지 출력을 조작할 수 있다.)
하지만.. HTML 페이지에서 에러를 처리하는 경우와 API 에서 오류를 처리하는 경우는 매우 다르다.
회원과 관련된 API에서 예외가 발생했을 때의 응답, 결제와 관련된 API에서 예외가 발생했을 때의 응답이 전혀 다르듯, 발생하는 예외에 따라 응답 결과가 천차만별이기 때문에 서버 관련 오류 / 클라이언트 관련 오류 등으로 오류를 큰 범주로 묶어서 처리하는 HTML과는 다른 방법을 사용해 오류를 처리해야 한다.
API 에서 발생하는 오류를 처리하는 과정을 차근차근 살펴보자.
발생하는 예외에 따라서 다른 방법으로 처리하고 싶다.
예를 들어 IllegalArgumentException 예외가 발생했을 때는 상태코드 400으로 처리하려고 한다. (원래는 500이다)
어떻게 해야할까?
이 때 HandlerExceptionResolver를 사용한다.
컨트롤러 밖으로 던져진 예외를 해결하고 동작 방식을 변경하고 싶을 때 사용한다.
ExceptionResolver를 적용하면 컨트롤러에서 발생한 에러를 ExceptionResolver가 잡아서 예외를 해결한다.
(해결해도 postHandle 메서드는 실행되지 않는다.)
@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("그 오류 발생함 400번으로 보낼거");
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView();
}
}catch(IOException e){
}
return null;
}
}
비어있는 ModelAndView를 반환해 예외를 먹고 정상 흐름대로 흘러간다. (비어있지 않으면 예외를 먹고 뷰를 렌더링한다.)
WAS 서버까지 정상 흐름대로 흘러가고, WAS에서 sendError 메서드가 있음을 확인하고 상태코드를 400으로 설정한다.
여기서 ModelAndView로 null을 반환하면 다음 ExceptionHandler를 찾아서 실행한다. (null 은 예외를 처리하지 않음)
없으면 예외가 처리되지 않고 기존 예외를 서블릿 밖으로 던진다.
(이 세 가지 경우는 DispatcherServlet 에서 처리한다.)
sendError로 호출을 변경하는 방법 외에도 ModelAndView에 값을 넣어 예외에 따른 새로운 오류 화면을 렌더링하도록 해 클라이언트에게 제공하거나, HTTP 응답 메세지 바디에 직접 데이터를 넣어 줄 수도 있다.
이렇게 ExceptionHandler를 사용하면 오류를 WAS까지 던질 필요 없이 ExceptionHandler 선에서 처리할 수 있다.
(sendError를 사용하지 않으면 바로 처리되고, sendError를 사용하면 WAS에서 dispatcherType == ERROR 형식으로 내부적으로 요청하는 작업이 수행된다.)
스프링은 애플리케이션이 올라올 때 몇 가지 ExceptionResolver를 등록해준다.
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver
1번의 우선순위가 가장 높다.
ResponseStatusExceptionResolver
예외에 따라서 HTTP 상태 코드를 지정해 주는 역할을 한다.
Exception에 @ResponseStatus 애너테이션이 붙어있거나, ResponseStatusException 예외를 처리한다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {}
BadRequestException 예외가 컨트롤러 밖으로 넘어가게 되면 ResponseStatusExceptionResolver 예외가 해당 애너테이션을 확인해 오류 코드를 400으로 변경하고 오류 메세지를 담는다.
내부적으로는 sendError(statusCode, resolveReason) 메서드를 호출하기 때문에 WAS에서 다시 오류 페이지인 /error를 호출한다.
여기서 사용하는 오류 메세지는 messages.properties에 키-값 으로 정의해놓고 키로 사용해도 된다.
@ResponseStatus 애너테이션은 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없고, 애너테이션 기반이기 때문에 특정 조건에 따라 동적으로 변경하는 작업도 힘들다.
이런 경우 ResponseStatusException을 사용한다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
상태 코드와 오류 메세지를 한 번에 처리해주는 특수한 예외이다.
사용자가 지정한 특정 오류가 발생했을 때 상태 코드를 지정해 줄 수 있다.
DefaultHandlerExceptionResolver
스프링 내부에서 발생하는 예외를 해결할 때 사용한다.
파라미터 바인딩 시점에 타입이 일치하지 않아 TypeMismatch 오류가 발생하면 해당 예외가 서블릿 컨테이너까지 던져지고 결국 500번 오류가 발생한다.
하지만 파라미터 바인딩 실패는 클라이언트의 잘못이다. DefaultHandlerExceptionResolver는 해당 오류를 500번에서 400번 오류로 변경한다.
역시 내부적으로 sendError(400) 을 호출한다.
타입 오류 말고도 다양한 내부 예외에 대해 변환을 제공하니, 참고하도록 하자.
HTTP 상태 코드와 스프링 내부 예외의 상태 코드를 변경하는 기능들에 대해 알아봤는데..
API 오류 응답을 처리할 때는 response에 직접 데이터를 넣어야 하는데 이 작업이 매우 불편하고, ModelAndView를 반환하면서 예외를 처리하는 부분도 API 오류 응답과는 시너지가 좋지 않다.
좀 더 편하게 처리하는 방법이 없을까?
ExceptionHandlerExceptionResolver
BasicErrorController와 HandlerExceptionResolver는 API 관련 오류를 해결하기에 부적합하다.
@ExceptionHandler 애너테이션을 사용하는 ExceptionHandlerExceptionResolver을 통해 API 예외 처리 문제를 편하게 해결하자. (실무에서 API 예외 처리는 이 기능을 사용하고, 우선순위도 가장 높다.)
@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 애너테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 요소로 지정해준다. (요소 = element)
해당 컨트롤러에서 지정한 예외가 발생하면 해당하는 @ExceptionHandler 애너테이션이 붙은 메서드가 호출돼 내부 로직을 실행한다. (해당 컨트롤러 내부에서만 유효하고, 지정한 예외의 자식 클래스는 모두 잡을 수 있다.)
@ExceptionHandler가 여러 개 있는 경우 더 자세한 쪽이 우선된다.
요소를 생략하면 메서드 파라미터의 예외가 지정된다.
동작 순서를 살펴보자.
1. 컨트롤러를 호출했는데, IllegalArgumentException 예외가 던져진다.
2. ExceptionHandler가 작동하고, 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
3. IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 달린 메서드가 있는지 확인한다.
4. 해당 메서드를 실행한다. @RestController 이기 때문에 @ResponseBody가 적용돼 응답이 JSON 타입으로 반환된다.
5. @ResponseStatus(badrequest) 로 인해 상태 코드는 400으로 응답한다.
ModelAndView를 반환해 오류 화면을 렌더링 할 때 사용할 수도 있다.
@ExceptionHandler를 사용해 예외를 깔끔하게 처리했다.
이제 마지막으로 정상 코드와 예외 처리 코드를 분리해보자.
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@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", "내부 오류");
}
}
@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해준다.
대상을 따로 지정하지 않으면 모든 컨트롤러에 적용된다.
@ExceptionHandler를 사용해 API 예외를 편하게 처리하고, @ControllerAdvice를 사용해 예외 처리 코드를 분리했다.
두 가지 애너테이션을 함께 사용해 예외를 깔끔하게 처리하자.
@ResponseStatus와 @ExceptionHandler를 함께 사용해 예외 처리 로직과 HTTP 응답 상태를 명확히 지정한다.
'Spring > Spring Web MVC' 카테고리의 다른 글
[Spring Web MVC] Multipart (1) | 2022.08.29 |
---|---|
[Spring Web MVC] 스프링 타입 컨버터 (0) | 2022.08.28 |
[Spring Web MVC] 예외 처리와 오류 페이지 (0) | 2022.08.26 |
[Spring Web MVC] Bean Validation (0) | 2022.08.23 |
[Spring Web MVC] 데이터 검증 (0) | 2022.08.22 |
댓글
이 글 공유하기
다른 글
-
[Spring Web MVC] Multipart
[Spring Web MVC] Multipart
2022.08.29 -
[Spring Web MVC] 스프링 타입 컨버터
[Spring Web MVC] 스프링 타입 컨버터
2022.08.28 -
[Spring Web MVC] 예외 처리와 오류 페이지
[Spring Web MVC] 예외 처리와 오류 페이지
2022.08.26 -
[Spring Web MVC] Bean Validation
[Spring Web MVC] Bean Validation
2022.08.23