김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
예외가 발생한 경우, HTML 페이지를 보여주는 경우 단순 오류 페이지만 있으면 처리할 수 있었지만 API는 단순하지 않다
API는 서버 - 서버, 앱 - 서버 등과 같이 여러 상황에서 주고 받을 수 있기 때문에 예외가 발생하면 정확한 데이터를 전달해야한다
그러므로 API를 호출하는 쪽과 응답하는 쪽이 서로 합의하여 4XX나 5XX 오류처럼 어떤 오류가 발생하면 어떤 형식으로 보낼지, 어떤 데이터를 보낼지와 같은 것을 정해야한다
즉, API 예외 처리는 각 오류 상황에 맞는 오류 응답 스펙을 정하고 JSON으로 데이터를 내려주어야함
이전에 WebServerCustomizer를 이용해 오류 페이지를 전달했던 방식처럼 API 응답하기
기존 방식
WebServerCustomizer 생성하고 예외 종류에 따라 ErrorPage 추가
ErrorPage를 추가할 때 등록한 URL을 처리( 오류 페이지를 처리 )하는 Controller 작성
변화 내용
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(
HttpServletRequest request, HttpServletResponse response) {
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));
}
produces
미디어 타입 조건 매핑 ( Accept )
클라이언트가 보내는 Accept 타입에 따라서 어떤 메서드가 호출될 것인지를 지정
즉, 같은 URL을 처리하는 메서드가 있을 때, produces로 미디어 타입을 지정하면 Accept 헤더에 따라 호출되는 메서드가 다름
클라이언트가 요청하는 HTTP Header의 Accept 의 값이 application/json 일 때 해당 메서드가 호출
ResponseEntity
HTTP 응답 메세지의 body에 JSON 데이터를 직접 넣을 때 사용
ResponseEntity 를 반환하면 HTTP 메시지 컨버터를 통해서 JSON 형식으로 변환되어서 반환
MVC 1편 6번 게시글의 8-2번 참고
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
// html 오류 페이지 반환
...
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity<>(status);
}
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity<>(body, status);
}
}
BasicErrorController는 스프링부트가 제공하는 기본 오류 처리 방식
위의 두 메서드는 /error
라는 동일한 경로를 처리
오류가 발생했을 때 Accept가 text/html이면 errorHTML()을 호출해 오류 페이지 반환
그 외의 경우에는 error()를 호출하면 Http 메세지 컨버터가 동작해 ResponseEntity로 JSON 데이터를 반환
BasicErrorController는 기본 정보들을 활용해서 오류 API를 생성해서 반환
위에서 말하는 기본 정보란 MVC 2편 8번 게시물의 5-4에서 언급한 정보
마찬가지로 application.properties에서 오류 정보 포함 여부를 설정 가능
BasicErrorController는 오류 페이지를 제공하는 경우에 편리하지만 API는 각 상황에 따라 다른 응답 결과를 출력해야할 수도 있기 때문에 @ExceptionHandler
를 사용하는 것이 더 좋은 방법
Controller 내부에서 Exception이 발생하고 WAS까지 전달되면 서버 내부 오류로 판단하여 HTTP 상태코드 500으로 처리하는데
이렇게 500으로 처리하지 않고 발생하는 예외에 따라서 다른 상태코드로 처리하거나 오류 메시지, 형식 등을 API마다 다르게 처리하도록 할 수 있음
➡️HandlerExceptionResolver ( 줄여서 ExceptionResolver )
Controller에서 발생한 예외를 잡아서 정상적으로 처리할 수 있도록 해줌
but> 예외를 해결해도 postHandle()
은 호출되지 않는다
예외 상태 코드 변환 ( 500 ➜ 4xx )
ModelAndView에 값을 채워 예외에 따른 새로운 오류 화면을 렌더링 해서 제공
response.getWriter().println();
처럼 HTTP 응답 메세지의 body에 직접 데이터를 넣는 것도 가능하며, JSON으로 응답하면 API 응답 처리를 할 수 있다
빈 ModelAndView를 반환 : 뷰를 렌더링하지 않고 WAS까지 정상적인 흐름으로 서블릿이 반환
ModelAndView에 정보를 지정해서 반환 : ModelAndView에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링한다
null 반환 : null 을 반환하면 다음 ExceptionResolver 를 찾아서 실행, 없으면 기존에 발생한 예외를 서블릿 밖으로 던진다
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;
}
}
// WebConfig에 만든 ExceptionHandler등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
Exception ex
: 핸들러( Controller )에서 발생한 예외
ExceptionResolver가 발생한 예외를 먹어버리고 sendError()로 400에러를 보냄
빈 ModelAndView를 반환했기 때문에 WAS까지 정상적인 반환값이 전달
➡️ sendError()로 400 에러가 넘어왔기 때문에 WAS가 500 에러가 아닌 400 에러로 인식
이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출
ex> 스프링부트가 기본으로 설정한 /error
호출
지금까지는 예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error
를 호출
ExceptionResolver를 활용하면 WAS까지 예외가 전달되지 않고 ExceptionResolver에서 예외를 처리할 수 있다
ExceptionResolver에서 예외를 처리하기 때문에 WAS 입장에서는 정상적인 처리가 이루어진 것
WAS가 /error 와 같은 경로를 다시 호출하는 일이 발생하지 않는다
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 {
return new ModelAndView("error/500"); // 에러 페이지 지정
}
}
}
...
}
}
지정한 예외가 발생하면 Accept 헤더를 보고 처리 방식을 나눈다 ( json과 html로 )
응답 메세지에 HTTP 상태 코드를 담는다
json인 경우, 데이터를 담고 objectMapper를 이용해 변환해서 응답 메세지의 body에 담는다 ( MVC 1편 참고 )
빈 ModelAndView를 반환하기 때문에 WAS까지 response가 전달되고, body에 담은 데이터가 클라이언트에게 전달된다
WebConfig에 등록 필요
resolvers.add(new UserHandlerExceptionResolver());
스프링이 제공하는 ExceptionResolver가 존재하기 때문에 여러 기능들을 편리하게 사용할 수 있다
HandlerExceptionResolverComposite에 아래 순서로 등록되어 있다
ExceptionHandlerExceptionResolver : @ExceptionHandler
를 처리
ResponseStatusExceptionResolver : HTTP 상태 코드를 지정
DefaultHandlerExceptionResolver : 스프링 내부 기본 예외를 처리
DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결
ex> 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생한 경우, 예외 처리를 하지 않고 두면 서블릿 컨테이너까지 오류가 올라가고 500 오류가 발생
but> 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이기 때문에 DefaultHandlerExceptionResolver가 500 오류가 아니라 400 오류로 변경해준다
ResponseStatusExceptionResolver는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다
@ResponseStatus
가 붙어 있는 예외나 ResponseStatusException
예외를 처리한다
Exception에 @ResponseStatus
어노테이션을 붙여 상태 코드를 지정할 수 있다
그럼 Exception이 발생했을 때 500으로 처리되는 것이 아닌 @ResponseStatus
에 명시된 상태 코드가 반환
이전에 sendError()를 통해 수동으로 상태 코드를 변환했던 과정을 어노테이션을 통해 손쉽게 구현 가능
@ResponseStatus
를 사용할 수 없는 상황에 ResponseStatusException
를 사용한다
@ResponseStatus(code= HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{ }
위의 BadRequestException을 터치면 Exception에 @ResponseStatus
가 있기 때문에 ResponseStatusExceptionResolver가 동작
어노테이션에 지정된 code와 reason 값을 꺼내서 response.sendError(code, reason)
를 실행하고 빈 ModelAndView를 반환
즉, ResponseStatusExceptionResolver는 내부에서 response.sendError()를 호출해서 상태 코드 변환을 수행한다
위에서 reason은 예외 메세지인데 이를 코드와 시켜서 예외 메세지를 MessageSource에서 찾게 할 수 있다
reason에 명시된 메세지 코드를 찾지 못하면 텍스트가 그대로 반환
messages.properties에 error.bad=잘못된 요청 오류입니다. 메시지 사용
가 있고
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
를 사용하면
messages.properties에서 error.bad라는 메세지 코드를 찾고, 찾아진 메세지가 반환
개발자가 직접 변경하거나 수정할 수 없는 예외에는 @ResponseStatus를 붙일 수 없다
어노테이션을 사용하기 때문에 예외 발생 시, 조건에 따라 동적으로 변경하는 것도 어렵다
➡️ ResponseStatusException을 사용
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
BasicErrorController나 ExceptionHandler를 직접 구현해서 API 예외 처리하는 것은 좋은 방식이 아님
BasicErrorControleller는 HTML 오류 페이지를 제공할 때 더 좋은 방식
ExceptionHandler를 구현하기에는 ModelAndView를 반환하기 때문에 response를 이용해 message body에 직접 데이터를 넣어야함
서로 다른 Controller에서 발생하는 동일한 예외를 다른 방식으로 처리하기 ( 다른 형식의 데이터를 응답하기 ) 어렵다
➡️ @ExceptionHandler
를 사용
@ExceptionHandler
를 사용하면 ExceptionHandlerExceptionResolver가 동작한다
@ExceptionHandler
는 Controller 내부에서만 적용된다
ExceptionHandler에서 처리하고 WAS까지 오류가 전달되지 않는다
호출 과정
Controller에서 예외가 발생하면 DispatcherServlet까지 전달
DispatcherServlet이 ExceptionHandler로 예외 해결 시도
ExceptionHandlerExceptionResolver가 실행됨
Controller 내부에 발생한 예외를 처리하는 @ExceptionHandler
가 있는지 찾아본다
있으면 @ExceptionHandler
가 붙은 메서드를 호출한다
동작 이후
ExceptionResolver에서 예외 처리가 끝이 난 것이기 때문에 서블릿 컨테이너로 예외가 전달되지 않는다
ExceptionResolver에서 정상적으로 JSON을 만들어 반환했기 때문에 HTTP 상태 코드가 200이 되고 WAS가 재호출 하는 일이 발생하지 않는다
200이 아닌 다른 상태 코드를 반환하고 싶으면 해당 메서드 위에 @ResponseStatus
를 추가한다
ex> @ResponseStatus(HttpStatus.BAD_REQUEST)
상태 코드를 400이나 500으로 변경해도 WAS가 재호출하는 일은 발생하지 않는다
주의!!
@ResponseStatus로 상태 코드를 변경하는 것은 내부적으로 sendError()를 호출하기 때문에 WAS에서 /error로 다시 요청을 한다고 했는데 위에서는 WAS가 재호출하는 일이 없다고 했다
@ResponseStatus를 예외에서 사용할 때는 sendError()가 동작하여 WAS가 다시 요청하게 되는게 맞지만
Controller나 @ExceptionHandler에서 @ResponseStatus를 사용할 때는 단순히 상태 코드만 변하는 방식으로만 동작 ( WAS 재호출 없음 )
@RestController
public class ApiExceptionV2Controller {
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
return new ErrorResult("BAD", e.getMessage());
}
...
}
Controller 내부에서 IllegalArgumentException이 발생하면 어노테이션이 붙은 메서드가 수행된다
Controller가 @RestController
이기 때문에 JSON 형식으로 반환
IllegalArgumentException 뿐만 아니라 그 자식 예외들도 위의 메서드로 처리한다
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
메서드의 파라미터에 예외 종류를 적어주면 @ExceptionHandler
에서 생략할 수 있다
UserException 뿐만 아니라 그 자식 예외들도 위의 메서드로 처리한다
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandler(Exception e) {
return new ErrorResult("EX", "내부 오류");
}
Exception의 자식 예외들을 다 처리해준다
Exception이 최상위 예외이기 때문에 위의 두 가지 메서드에서 처리하지 못한 예외를 처리해준다
부모와 자식에 해당하는 예외가 둘 다 있으면 자식에 해당하는 예외가 먼저 호출된다 ( 자세한 것이 먼저 )
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
return new ModelAndView("error");
}
ModelAndView 를 사용해서 오류 화면( HTML )을 응답하는데 사용할 수도 있다
@RestController가 아닌 @Controller를 붙이고 메서드의 반환형을 String으로 view의 이름을 반환하면 뷰 템플릿을 찾아서 렌더링된다
@ExceptionHandler를 Controller 내부에서만 사용 가능해서 다른 곳에 사용할 수 없다
하나의 Controller에 정상 코드와 예외 처리 코드가 섞여있다
➡️ @ControllerAdvice
, @RestControllerAdvice
를 사용
@ControllerAdvice
는 대상으로 지정한 Controller에 @ExceptionHandler
, @InitBinder
기능을 부여하는 역할
대상을 지정하지 않으면 모든 Controller에 적용된다
@RestControllerAdvice
= @ControllerAdvice
+ @ResponseBody
// Target all Controllers annotated with @RestController
// 특정 어노테이션이 있는 Controller
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 { }
// Target all Controllers within specific packages
// 특정 패키지에 있는 Controller
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 { }
// Target all Controllers assignable to specific classes
// 부모 Controller를 지정하거나 특정 Controller를 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 { }