@Slf4j
@Component // 서블릿이 제공하는 에러페이지 만들기
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
}
}
@PathValiable을 사용하여 id Argument를 받았다. > > 2. Client는 **Accept**를 **application/json**로 지정하였고
서버에서는 @RequestMapping에 **produces = MediaType.APPLICATION_JSON_VALUE**를 지정함으로써 같은 @RequestMapping으로 지정된 URL일지라도 **errorPage500Api** 메소드로 매핑되게된다.
만약 WebServerCustomizer.java @Component를 주석처리한 뒤,
http://localhost:8080/api/members/ex 해당 URL로 날리면 어떻게 될까?
요청할 경우 Accept를 application/json으로 날렸기때문에
BasicErrorController가 자동으로 응답시에 JSON코드로 리턴해준다.
그럼 요청할 경우 Accept를 text/html로 날리면?
이전에 우리가 만들었던 500 Page를 표시해준다.
이를 이해하기 위해서는 **HandlerExceptionResolver**부터 알아야한다.
HandlerExceptionResolver
ApiExceptionController
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable String id){
if(id.equals("ex")){
throw new RuntimeException("잘못된 사용자");
}
// 추가
if(id.equals("bad")){
throw new IllegalArgumentException("잘못된 입력 값");
}
return new MemberDto(id, "hello " + id);
}
}
> ExceptionResolver를 적용하기 전에는id가 bad로 넘어올 경우 IllegalArgumentException로 return 한다.
1. Controller IllegalArgumentException이 발생하고 Servlet -> WAS까지 타고 올라가서 500에러 발생
. - Error가 발생하였으니 postHandle은 실행X > ExceptionRosolver 적용 후에는
Error가 발생하였으니 postHandle은 실행X은 똑같지만
**HandlerExceptionResolver**을 상속받아 **오류 잡아먹는 코드**를 넣어줄 것이다.
public interface HandlerExceptionResolver {
ModelAndView resolveException(
HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex);
}
IllegalArgumentException 오류잡아먹는 코드 만들기
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);
return new ModelAndView();
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
http://localhost:8080/api/members/bad 로 요청 시에
REQUEST -> Filter -> Servlet -> Intercepter -> controller까지 와서
Exception IllegalArgumentException이 터진다.그럼 Exception 500이 발생하여 위로 타고 올라가는데 이때 3번째 단계인 Servlet에서 HandlerExceptionResolver이 만들어져 있는것을 보고
만약 response.sendError(HttpServletResponse.SC_BAD_REQUESET);를 주석처리히면
아래와 같이 Exception을 잡어먹었고, return new ModelAndView를 하였기때문에
Servlet -> WAS까지 정상 처리되어 빈 화면을 리턴해준다.만약 response.sendError(HttpServletResponse.SC_BAD_REQUESET);를 주석을 풀면
Exception을 집어먹긴 하였지만 sendError를 400으로 지정해주었기 때문에
Servlet에서 WAS까지 타고 올라가서 BasicErrorController에 의해
Client가 보낸 Accept에 의해 Page로 보낼지, json으로 보낼지 판단하여 return 한다.
내용 정리
반환 값에 따른 동작 방식
HandlerExceptionResolver 의 반환 값에 따른 DispatcherServlet 의 동작 방식은 다음과 같다.
빈 ModelAndView
new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
ModelAndView 지정
ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
null:
null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다.
만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고,
기존에 발생한 예외를 서블릿 밖으로 던진다.
ExceptionResolver 활용 예외 상태 코드 변환
예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임 이후
WAS는서블릿오류페이지를 찾아서 내부호출, 예를들어서스프링부트가기본으로설정한 / error 가 호출됨
뷰 템플릿 처리 ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
API 응답 처리 response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다. 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다.
HandlerExceptionResolver를 상속받은 MyHandlerExceptionResolver에서는
**response.sendError(HttpServletResponse.SC_BAD_REQUEST);**를 통해 상태코드를 지정할 순있지만
ExceptionResolver에서 sendError로 Exception을 지정하여
Exception을 발생시켜 WAS까지 타고 올라가
BasicErrorController를 타고 내려가야한다.
이건 너무 복잡하므로
UserHandlerExceptionResolver를 하나 만들어서
WAS에서 타고 다시 리턴 되는 방식이 아닌, HandlerExceptionResolver에서 끝내고 WAS에서는 정상처리인것처럼 만들어보자
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable 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);
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
// 추가
resolvers.add(new 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) {
if (ex instanceof UserException) {
log.info("UserException resolver to 400");
String accept = request.getHeader("accept");
// response.sendError가 아닌
// response.setStatus를 사용하여 상태코드 저장
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
try {
if ("application/json".equals(accept)) {
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 {
return new ModelAndView("error/500");
}
} catch (Exception e) {
e.printStackTrace();
}
}
return null;
}
}
HandlerExceptionResolver을 하나하나 모두 만들기에는 우리가 너무 힘들다.
그래서 Spring에서 제공해주는 HandlerExceptionResolver를 사용해보자
- ExceptionHandlerExceptionResolver
- @ExceptionHandler 을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다. 조금 뒤에 자세히 설명한다.
- ResponseStatusExceptionResolver
- HTTP 상태 코드를 지정해준다.
예) @ResponseStatus(value = HttpStatus.NOT_FOUND) - ResponseStatusException을 던지기
- HTTP 상태 코드를 지정해준다.
- DefaultHandlerExceptionResolver
- 스프링 내부 기본 예외를 처리한다.
2. ResponseStatusExceptionResolver
우리는 우선 2. ResponseStatusExceptionResolver를 활용해볼것이다.
ApiExceptionController.java
@GetMapping("/api/response-status-ex1")
public String reponseStatusEx1(){
throw new BadRequestException();
}
@ResponseStatus를 확용하는 방법이 있고
BadRequestException.java
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}
ResponseStatusException를 retrun 하는 방법이 있다.
@GetMapping("/api/response-status-ex2")
public String reponseStatusEx2(){
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "error.bad", new IllegalArgumentException());
}
@ResponseStatus을 실행하면 HandlerExceptionResolver를 상속받은
2. ResponseStatusExceptionResolver가 BadRequestException을 잡아먹고 WAS에서는 정상처리 해준다
ResponseStatusExceptionResolver를 까보면 HandlerExceptionResolver를 상속받았기에
resolverException메소드를 구현해야하고
그 안에는 doResolveException ModelAndView로 return 하였다
그럼 좀더 소스를 들여다보면
if (status != null) {
return resolveResponseStatus(status, request, response, handler, ex);
}
protected ModelAndView resolveResponseStatus(ResponseStatus responseStatus, HttpServletRequest request,
HttpServletResponse response, @Nullable Object handler, Exception ex) throws Exception {
// 상태코드와 reason선언된 값을 가져와서
int statusCode = responseStatus.code().value();
String reason = responseStatus.reason();
// applyStatusAndReason에 보낸다.
return applyStatusAndReason(statusCode, reason, response);
}
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response)
throws IOException {
if (!StringUtils.hasLength(reason)) {
response.sendError(statusCode);
} else {
// messageSource에서도 활용할 수 있다!!
String resolvedReason = (this.messageSource != null ?
this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
reason);
// sendError를 만들어 보낸다.
response.sendError(statusCode, resolvedReason);
}
return new ModelAndView();
}
reason을 보낼 때 messageSource를 확인해보기 때문에
messages.properties의 파일에 reason을 명시하면 사용할 수 있다.
3. DefalutHandlerExceptionResolver
@GetMapping("/api/default-handler-ex2")
public String defaultException(@RequestParam Integer data){
return "ok";
}
Client가 http://localhost:8080/api/default-handler-ex2?data=qqq로 전송할 경우
MethodArgumentTypeMismatchException이 발생하는데, 이는 원래대로라면 500을 뱉어야하지만
아래와 같이 400을 뱉어준다. 어떻게 된 것일까?
DefaultHandlerExceptionResolver.java에 들어가면
@Override
@Nullable
protected ModelAndView doResolveException(
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
/// 생략 ////
else if (ex instanceof TypeMismatchException) {
return handleTypeMismatch(
(TypeMismatchException) ex, request, response, handler);
}
protected ModelAndView handleTypeMismatch(TypeMismatchException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
response.sendError(HttpServletResponse.SC_BAD_REQUEST); // 400으로 던져준다.
return new ModelAndView();
}
지금까지 다음 ExceptionResolver 들에 대해 알아보았다.
1. ExceptionHandlerExceptionResolver -> 다음 시간에
2. ResponseStatusExceptionResolver -> HTTP 응답 코드 변경
3. DefaultHandlerExceptionResolver -> 스프링 내부 예외 처리
지금까지 HTTP 상태 코드를 변경하고, 스프링 내부 예외의 상태코드를 변경하는 기능도 알아보았다.
그런데 HandlerExceptionResolver 를 직접 사용하기는 복잡하다.
API 오류 응답의 경우 response.sendError 에 직접 데이터를 넣어야 해서 매우 불편하고 번거롭다. ModelAndView 를 반환해야 하는 것도 API에는 잘 맞지 않는다.
1. ExceptionHandlerExceptionResolver
@ExceptionHandler 라는 어노테이션을 사용하면 예외처리를 쉽게 처리할 수 있다.
@Slf4j
@RestController
public class ApiExceptionV2Controller {
/**
해당 Controller안에 생긴 IllegalArgumentException를 잡는다.
return ErrorResult를 Json Data로 날린다.
안에 @RestController로 선언하였기에
@ResponseBody도 있기에 자동으로 JSON 가능한것.
정상적으로 Return한것으로 처리하기때문에 WAS에서 에러를 안던지고
@ResponseStatus(HttpStatus.BAD_REQUEST)를 안붙이면 200코드가 표시된다.
**/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult iIIegalExHandler(IllegalArgumentException e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
// @ExceptionHandler(UserException.class)
// Parameter로 UserException받으니까 생략 가능
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e){
log.error("[exceptionHandler] 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 exHandler(Exception e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable 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;
}
}
자, 그럼 소스 코드를 하나씩 들여다보자
iIIegalExHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult iIIegalExHandler(IllegalArgumentException e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
/api2/members/bad
컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
예외가 발생했으로 ExceptionResolver 가 작동한다.
가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행된다.
ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException 을 처리할 수 있는 @ExceptionHandler 가 있는지 확인한다.
illegalExHandle() 를 실행한다.
@RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다.
따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.
@ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드 400으로 응답한다.
만약 지정하지 않았다면 200을 던진다.
// 응답
{
"code": "BAD",
"message": "잘못된 입력 값"
}
UserException
@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);
}
@ExceptionHandler 에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다.
여기서는 UserException 을 사용한다.
ResponseEntity 를 사용해서 HTTP 메시지 바디에 직접 응답한다. 물론 HTTP 컨버터가 사용된다.
ResponseEntity 를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다.
앞서 살펴본 @ResponseStatus 는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다.
Exception
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
thrownewRuntimeException("잘못된 사용자")이코드가실행되면서,
컨트롤러밖으로 RuntimeException 이 던져진다.
RuntimeException 은 Exception 의 자식 클래스이다. 따라서 이 메서드가 호출된다.
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 로 HTTP 상태 코드를 500으로 응답한다.
ControllerAdvice
ExControllerAdvice를 만들어서
ApiExceptionV2Controller안에 선언된 @ExceptionHandler 내용을 복사해 넣자
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult iIIegalExHandler(IllegalArgumentException e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e){
log.error("[exceptionHandler] 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 exHandler(Exception e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
위에처럼 선언하면 모든 Controller의 Exception을 받아들어주는데
@ControllerAdvice
@ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에
@ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 한다.
@ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
@RestControllerAdvice 는 @ControllerAdvice 와 같고,
@ResponseBody 가 추가되어 있다. @Controller , @RestController 의 차이와 같다.
대상 Controller를 지정할 수도 있다.
// 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 {}