그런데 API의 경우에는 생각할 내용이 더 많다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만..
API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.👍🤔
WebServerCustomizer
의 @Component
주석을 다시 제거
- 그랬더니... HTML 문서가 뜬다..
//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));
}
"message": "잘못된 사용자",
"status": 500
}
produces = MediaType.APPLICATION_JSON_VALUE
Accept
의 값이 application/json
일 때 해당 메서드가 호출된다.ResponseEntity
를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이그러면 스프링부트는 일 안해요,,?
일 한다..
{
"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"
}
application/json
타입을 통해서...=> 결과적으로 이렇게 너무 많은 정보를 제공하는 것은 보안상 위험...
=> Best는 간결한 메시지만 노출하자👍👍✨
+) 이건 HTML 로 만들어주는 코드 참조 _BasicErrorController 발췌
+)HTML 페이지 vs API 오류
BasicErrorController 를 확장하면 JSON 메시지도 변경할 수 있다.
- 그러나 조금 뒤에 설명할
@ExceptionHandler
가 제공하는 기능을 사용하는 것이 더 나은 방법이므로- 현재는...
BasicErrorController
를 확장해서JSON 오류 메시지를 변경할 수 있다 정도로만 이해해두자
@ BasicController 만으로는 API 오류 처리에 한계가 있다.
HTML 요청의 오류페이지는 단순하게 404, 500 코드에 따라 다른 오류페이지를 반환해주면 되었다.
클라이언트, 서버마다 API 스펙이 다를수도있고, 같다고 하더라도 [Item API, Order API]등
스프링 MVC는 컨트롤러 밖으로 예외가 발생한 경우, 이 예외를 처리할 수 있는 HandlerExceptionResolver
인터페이스를 제공해준다.
-> 만약 예외처리를 WAS로 던지지 않고 중간에 처리하고 싶다면 이를 구현하면 된다.
=> ExceptionResolver
를 사용하면 굳이 WAS를 거쳐서 돌아올 필요가 없다. 그냥 예외를 try-catch처럼 잡으면 된다.
ExceptionResolver
로 예외를 해결해도postHandle()
은 호출되지 않는다.
먼저 이를 쓰기 전에 어떤 인터페이스인지 잠깐 보자
public interface HandlerExceptionResolver {
ModelAndView resolveException(
HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex);
}
handler
: 핸들러(컨트롤러) 정보
Exception ex
: 핸들러(컨트롤러)에서 발생한 발생한 예외
[결과]
여기서는 IllegalArgumentException 이 발생하면 response.sendError(400) 를 호출해서 HTTP
상태 코드를 400으로 지정하고, 빈 ModelAndView 를 반환한다.
@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;
ExceptionResolver
가 ModelAndView
를 반환하는 이유는HandlerExceptionResolver 의 반환 값에 따른 DispatcherServlet 의 동작 방식은 다음과 같다.
/error
가 호출됨response.getWriter().println("hello");
[UserException] _코드 참조
@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) {
log.error("resolver ex", e);
}
return null;
}
}
/**
* 기본 설정을 유지하면서 추가
*/
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
resolvers.add(new UserHandlerExceptionResolver());
}
예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이 난다.
결과적으로 WAS 입장에서는 정상 처리가 된 것이다. 이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다.
스프링 부트가 기본으로 제공하는 ExceptionResolver
는 다음과 같다.
HandlerExceptionResolverComposite
에 다음 순서로 등록
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver
로.... 우선 순위가 가장 낮다.
ExceptionHandlerExceptionResolver
ResponseStatusExceptionResolver
@ResponseStatus(value = HttpStatus.NOT_FOUND)
DefaultHandlerExceptionResolver
다음 두 가지 경우를 처리
1. @ResponseStatus
가 달려있는 예외
2. ResponseStatusException
예외
[BadRequestException]
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
[ApiExceptionController 추가]
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
- ResponseStatusExceptionResolver 코드를 확인해보면 결국
response.sendError(statusCode, resolvedReason)
를 호출하는 것을 확인sendError(400)
를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청
+) 메시지 기능
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
[ApiExceptionController 추가]
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
IllegalArgumentException());
}
- 컨트롤러에서 한 번에 다 써준다.... 상태코드랑, 메시지 전부 다!!!
대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면
TypeMismatchException
이우리 서버의 잘못이 아니란 것이다..!!
HTTP 에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다!
TypeMismatchException
이 발생한다. -> 타입 오류!!!그러나 결과는 500이 아닌 400에러가 떴다!!
이것이 바로 스프링의 힘 아니겠느냐!!!! 😁✌✌✌
지금까지 HTTP 상태 코드를 변경하고, 스프링 내부 예외의 상태코드를 변경하는 기능도 알아보았다.
그런데
HandlerExceptionResolver
를 직접 사용하기는 복잡하다.
- API 오류 응답의 경우 response 에 직접 데이터를 넣어야 해서 매우 불편하고 번거롭다.
ModelAndView
를 반환해야 하는 것도 API에는 잘 맞지 않는다.- 스프링은 이 문제를 해결하기 위해
@ExceptionHandler
라는 매우 혁신적인 예외 처리 기능을 제공한다. ✨✨
API의 경우...Item-API, Order-API등 서비스 종류에 따라 오류 메시지를 다르게 처리해줘야 하고..
심지어는 클라이언트에 따라 API의 스펙 자체가 달라지기도 한다.
물론 필요하다면 HTML 화면도 리졸버로 처리해도 된다.
HandlerExceptionResolver
를 떠올려 보면 ModelAndView
를 반환해야 했다.HttpServletResponse
에 직접 응답 데이터를 넣어주었다.😥1) 회원을 처리하는 컨트롤러에서 발생하는 RuntimeException 예외와 2) 상품을 관리하는 컨트롤러에서 발생하는 동일한 RuntimeException 예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야할까?
스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler
라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공
@ExceptionHandler란..
1. 스프링은ExceptionHandlerExceptionResolver
를 기본으로 제공
2. 기본으로 제공하는ExceptionResolver
중에 우선순위도 가장 높다
@ExceptionHandler
를 사용하는 메서드가 있는지 확인
- 만약 다른 HTTP 오류코드를 반환하고 싶다면 예외처리 메서드에
@ResponseStatus
를 추가로 사용
@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());
}
//UserExcpeiton 을 예외하는 곳임.
//매개변수에 들어오는 애 확인
@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
애노테이션을 선언하고,@ExceptionHandler
에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다.다음과 같이 다양한 예외를 한번에 처리할 수 있다.
@ExceptionHandler
에 예외를 생략할 수 있다.
=> (생략하면) 메서드 파라미터의 예외로 지정된다.
IllegalArgumentException
예외가 컨트롤러 밖으로 던져진다.예외가 발생했으로 ExceptionResolver 가 작동한다.
-> 가장 우선순위가 높은
ExceptionHandlerExceptionResolver
가 실행된다.
ExceptionHandlerExceptionResolver
는 '해당 컨트롤러'에 IllegalArgumentException
을 처리할
수 있는 ' @ExceptionHandler
'가 있는지 확인한다.
illegalExHandle()
를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody
가 적용된다.
따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 'JSON'으로 반환된다.
-> @ResponseStatus(HttpStatus.BAD_REQUEST)
를 지정했으므로 HTTP 상태 코드 400으로 응답한다.
@ExceptionHandler
를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다.
-> @ControllerAdvice
또는 @RestControllerAdvice
를 사용하면 둘을 분리할 수 있다.
@Slf4j
@RestControllerAdvice//(basePackages = "hello.exception.api")
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", "내부 오류");
}
}
@Slf4j
@RestController
public class ApiExceptionV2Controller { //이제 더 예외에 대해 처리한 부분이 x
@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;
}
}
@RestControllerAdvice
는 @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다.@Controller
, @RestController
의 차이와 같다.