API 예외 처리
HTML 페이지의 경우 BasicErrorController에 의해서 4xx, 5xx와 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있었다.
하지만 API의 경우 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
BasicErrorController는 클라이언트 요청의 Accept 헤더 값에 따라 에러를 다르게 처리한다.
[BasicErrorController 코드]
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
동일한 경로(/error)를 처리하는 errorHtml(), error() 두 메서드를 확인할 수 있다.
● errorHtml()의 produces = MediaType.TEXT_HTML_VALUE 는 클라이언트 요청의 Accept 헤더 값이 text/html일 경우에 호출됨을 의미한다.
● error()는 Accept 헤더가 text/html이 아닌 경우에 호출되고 ResponseEntity로 HTTP Body에 JSON 데이터를 반환한다.
※ 오류 페이지 경로는 기본적으로 /error 이고 application.properties에서 sercer.error.path로 수정 가능하다.
[API 컨트롤러]
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId; private String name;
}
}
경로 변수로 id를 받아서 JSON 형식으로 데이터를 응답하는 컨트롤러를 만들었다.
id가 ex일 경우 RuntimeException이 발생한다.
오류가 발생할 경우 스프링 부트는 BasicErrorController가 제공하는 기본 정보들을 활용해서 오류 API를 생성해준다.
[API 오류 결과 - BasicErrorController]
{
"timestamp": "2021-04-28T00:00:00.000+00:00", "status": 500,
"error": "Internal Server Error",
"exception": "java.lang.RuntimeException",
"trace": "java.lang.RuntimeException: 잘못된 사용자\n\tat
hello.exception.web.api.ApiExceptionController.getMember(ApiExceptionController .java:19...,
"message": "잘못된 사용자", "path": "/api/members/ex"
}
1. API 예외 처리 - HandlerExceptionResolver
BasicErrorController를 확장하면 JSON 메시지도 변경할 수 있다.
하지만 이 방법으로는 각 API 마다 오류 메시지, 형식 등을 다르게 처리하기 어렵다.
스프링 MVC은 상태코드, 오류 메시지, 형식 등을 API마다 다르게 처리할 수 있게 하는 HandlerExceptionResolver를 제공한다.
HandlerExceptionResolver, 줄여서 ExceptionResolver는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 새롭게 동작을 정의할 수 있는 방법을 제공한다.
[예외 처리 기본 흐름]
기본적으로 컨트롤러에서 예외가 발생하면 서블릿을 거쳐 WAS 까지 예외가 전달된다.
[예외 처리 흐름 - ExceptionResolver 적용]
ExceptionResolver를 적용하면 컨트롤러에서 예외가 발생하면 서블릿까지 예외가 전달되는 것은 같지만, 서블릿은 ExceptionResolver를 호출에 예외 해결을 시도한다.
이후, 예외를 해결하면 ModelAndView를 반환하고 다시 정상 흐름대로 작동한다.
※ ExceptionResolver로 예외를 해결해도 postHandle()은 호출되지 않는다.
[HandlerExceptionResolver - 인터페이스]
public interface HandlerExceptionResolver {
ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex);
}
● handler : 핸들러(컨트롤러) 정보
● Exception ex : 핸들러(컨트롤러)에서 발생한 예외
ExceptionResolver를 사용하면 예외가 발생할 때 ExceptionResolver에서 예외를 처리하고 끝낼 수도 있다.
[API 컨트롤러 - ExceptionResolver]
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
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;
}
}
id가 user-ex 일때 UserException(사용자 정의 예외) 예외를 발생시키는 컨트롤러이다.
UserException을 처리하는 UserHandlerExceptionResolver를 작성한다.
[UserHandlerExceptionResolver]
@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) {
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) {
log.error("resolver ex", e);
}
return null;
}
}
서블릿을 사용할 때 ObjectMapper를 사용해서 JSON 형식으로 변환해서 응답한 것처럼 ExceptionResolver에서도 자료구조를 JSON 형식으로 변환해서 response에 담을 수 있다.
● reponse.setStatus(HttpServletResponse.SC_BAD_REQUEST) : 오류 상태 코드를 404로 응답하게 한다.
● ModelAndView("error/500") : Accept 헤더가 application/json이 아니면 "error/500" 뷰를 렌더링 한다.
ExceptionResolver는 반환 값에 따라 예외 발생 이후 동작이 달라진다.
● 빈 ModelAndView : 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
● ModelAndView 지정 : ModelAndView에 View, Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
● null : null을 반환하면, 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 수 있는 ExcptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.
[UserhandlerExceptionResolver 추가]
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new UserHandlerExceptionResolver());
}
WebMvcConfigurer를 상속받아 extendHandlerExceptionResolvers를 사용해 등록한다.
UserException 예외를 발생시키면 UserHandlerExceptionResolver에 의해서 다음과 같은 결과가 나온다.
[UserHandlerExceptionResolver 결과]
{
"ex": "hello.exception.exception.UserException",
"message": "사용자 오류"
}
http://localhost:8080/api/members/user-ex 을 application/json 헤더로 요청하면 위와 같은 결과를 얻을 수 있다.
2. 스프링이 제공하는 ExceptionResolver
스프링 부트는 기본으로 몇 가지 ExcptionResolver를 제공해준다.
■ ResponseStatusExceptionResolver
ResponseStatusExceptionResolver는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.
다음 두 가지 경우를 처리한다.
● @ResponseStatus가 달려있는 예외
● ResponseStatusException 예외
예외나 컨트롤러에 다음과 같이 @ResponseStatus 애노테이션을 적용하면 HTTP 상태 코드를 변경해준다.
[@ResponseStatus 사용 예]
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
위 예제처럼 예외에 @ResponseStatus가 붙으면, 예외가 발생했을 때 ResponseStatusExceptionResolver이 해당 애노테이션을 확인하고 오류 코드를 지정된 코드(HttpStatus.BAD_REQUEST)로 변경하고 메시지(reason)도 담는다.
reason은 MessageSource에서 찾는 기능도 제공한다.
ex) reason = "error.bad"
@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없으며, 조건에 따라 동적으로 변경하는 것도 어렵다.
이때는 ResponseStatusException 예외를 사용하면 된다.
[API 컨트롤러 - ResponseStatusException]
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"error.bad", new IllegalArgumentException());
}
■ DefaultHandlerExceptionResolver
DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외로 발생하는 상태 코드를 적절하게 변경해준다.
대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우 예외가 WAS까지 올라가고 결과적으로 500 오류가 발생해야 한다.
그런데 파라미터 바인딩은 대부분 클라이언트 측 문제이다.
이런 이유로 DefaultHandlerExceptionResolvers는 이 경우 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.
그 외의 수많은 스프링 내부 오류를 어떻게 처리할지 정의되어 있다.
■ @ExceptionHandler
API 오류 응답의 경우 HandlerExceptionResolver를 직접 사용하는 것은 다음과 같은 이유 때문에 적절하지 않다고 볼 수 있다.
● HandlerExceptionResolver는 ModelAndView를 반환한다. 이것은 API 응답에는 필요하지 않다.
● API 응답을 위해 HttpServletResponse에 직접 응답 데이터를 넣어주었다. 이것은 매우 매우 불편한 작업이다.
● 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다.
스프링은 이 문제를 해결하기 위해 ExceptionHandlerExceptionResolver를 제공한다.
이 ExceptionResolver는 @ExceptionHandler 라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공한다.
무엇보다, 스프링이 기본으로 제공하는 ExceptionResolver 중에 우선순위가 가장 높다.
[@ExceptionHandler 사용 예]
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException 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) {
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;
}
}
ErrorResult는 String code와 String message로 이루어진 데이터 객체이다.
getMember는 id를 경로 변수로 받고 특정 id에 따라 예외를 발생시키는 컨트롤러이다.
getMember에서 에러가 발생하면 에러에 맞는 @ExceptionHandler 애노테이션이 붙은 메서드가 실행된다.
● @ExceptionHandler 애노테이션을 선언하고, 해강 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
해당 컨트롤러에서 예외가 발생하면 이 애노테이션이 붙은 메서드가 호출된다.
●다양한 예외를 한 번에 처리할 수 있다.
ex) @ExceptionHandler({AException.class, BException.class})
●@Exception에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.
●위 예제처럼 @ExceptionHandler에는 마치 스프링 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정할 수 있다.
ex) 응답으로 ErrorResult(사용자 지정 객체), ResponseEntity 등 사용 가능
[IllegalArgumentException 처리 - @ExceptionHandler]
ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
return new ErrorResult("BAD", e.getMessage());
}
● Accept : application/json으로 http://localhost:8080/api2/members/bad 를 요청하면 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
● 예외가 발생했으므로 ExceptionResolver가 작동하고 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
● ExceptionHandlerExceptionResolver는 해당 컨트롤러에 예외를 처리할 수 있는 @ExceptionHandler가 있는지 확인하고 illegalExHandle()을 실행한다.
● @RestController 이므로 응답이 JSON으로 반환된다.
[IllegalArgumentException 처리 결과 - @ExceptionHandler]
{
"code": "BAD",
"message": "잘못된 입력 값"
}
[UserException 처리 - @ExceptionHandler]
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler에 예외를 지정하지 않았기 때문에 해당 메서드의 파라미터 예외인 UserException이 사용된다.
@ExceptionHandler는 ResponseEntity를 반환할 수 있다.
ResponseEntity를 사용해서 HTTP 응답 코드를 동적으로 변경할 수 도 있다.
※ @ExceptionHandler는 ModelAndView도 반환할 수 있기 때문에 오류 화면(HTML)을 응답하는 데 사용할 수도 있다.
□ @ControllerAdvice
@ControllerAdvice 또는 @RestControllerAdvice를 사용해서 컨트롤러와 @ExceptionHandler 메서드를 분리할 수 있다.
[@RestControllerAdvice 사용 예]
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException 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) {
return new ErrorResult("EX", "내부 오류");
}
}
@RestControllerAdvice는 @RestController와 같이 반환 값을 응답 메시지 본문에 직접 반환한다.
@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다.
※ 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.
[대상 컨트롤러 지정 방법]
// 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 {}
위 예시처럼 특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정할 수 도있다.
패키지의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다.
물론 특정 클래스를 지정할 수 있다.
'Spring > 스프링 MVC 활용' 카테고리의 다른 글
#11 파일 업로드 (0) | 2021.08.03 |
---|---|
#10 스프링 타입 컨버터 (0) | 2021.08.03 |
#8 예외 처리와 오류 페이지 (0) | 2021.08.02 |
#7 로그인 처리 - 필터, 인터셉터 (0) | 2021.08.01 |
#6 로그인 처리 - 쿠키, 세션 (0) | 2021.07.30 |
댓글