@RestController
public class MyController {
@GetMapping("/exception-thrower")
public void throwException() {
throw new RuntimeException("there's error with server");
}
}
기존에 사용하던 예외 매핑 정보(WebServerFactoryCustomizer)
에 연결된 Handler는 HTML을 반환한다. API에 맞게 JSON을 반환해보자.
@Controller
public class ErrorPageController {
@RequestMapping(name = "/error")
public String handleError(
HttpServletRequest request,
HttpServletResponse response
) {
...
}
@RequestMapping(name = "/error", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String,Object>> handleError(
HttpServletRequest request,
HttpServletResponse response
) {
Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
Integer statusCode = (Integer) request.getAttribute(ERROR_STATUS_CODE);
Map<String, Object> result = new HashMap<>();
result.put("status", statusCode);
result.put("message", ex.getMessage());
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
}
1. Controller에서 발생한 RuntimeException이 WAS까지 전파된다.
2. WAS에서 예외 매핑 정보에 따라 `예외 처리 요청`을 발생시킨다.
3. 적절한 Handler가 선택되어 ResponseEntity를 반환한다.
(이전 포스트 참고)
Q: 예외 응답 처리 Handler가 2개 있는데, 어떤걸 사용할지 어떻게 결정할까?
A: request header에 accept: application/json
이 있는데,
handler에 produces = MediaType.APPLICATION_JSON_VALUE
이 있으므로, 더 specific한 handler가 trigger된다.
API 방식에도 Spring Boot의 BasicErrorController
을 사용할 수 있다.
@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) {
HttpStatus status = getStatus(request);
Map<String, Object> model = Collections
.unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = resolveErrorView(request, response, status, model);
return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
}
@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);
}
...
}
accept: text/html
인 경우 errorHtml
, 그 외에는 error
가 호출된다.
옵션을 통해 구체적인 오류 정보를 응답에 담을 수 있으나, 오류는 최대한 노출하지 말고, 로깅하는게 좋다.
BasicErrorController
는 4xx, 5xx 처럼 한 번에 비슷한 예외 응답을 보내기에 적합하다.
그런데 API마다, 상황마다 다른 예외 응답을 보내야하므로 사용의 한계가 존재한다.
지금까지(Servlet, BasicErrorController) 방식
App에서 발생한 예외를 따로 처리하지 않으면,
WAS는 모든 예외 상황을 전부 *서버의 문제*로 인식해서 500번대 예외 응답을 보냄
RuntimeException이든, IllegalArgumentException이든 전부 5xx
HandlerExceptionResolver
를 사용하면 예외 상황에 대한 응답을 구체적으로 다룰 수 있다.
package doodlin.greeting.test.exceptionResolver;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView();
}
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
반환값에 따라 다른 작업을 수행한다.
1. new ModelAndView() (empty ModelAndView)
2. ModelAndView(viewName, data)
3. null
package doodlin.greeting.test.exceptionResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
public class Config implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
WebMvcConfigurer
는 Resolver를 등록하기 위해 두 가지 메서드를 제공한다.configureHandlerExceptionResolvers
를 사용하면 Spring이 기본으로 등록하는 ExceptionResolver가 제거된다.extendHandlerExceptionResolvers
사용 권장지금까지 내용을 통해, 기존 방식과 달리 HandlerExceptionResolver
를 사용하면
1. 구체적인 status code 사용 가능
2. 다양한 Exception 처리 가능
할 수 있다는 HandlerExceptionResolver
의 장점을 알았다.
그런데 더 큰 차이점이 있는데, HandlerExceptionResolver
는 예외 처리 응답을 발생시키지 않는다는 점이다.
그러니까
기존 방식(?)
WAS ->
Controller (최초 요청에서 예외 발생) ->
WAS (예외 처리 요청 발생) ->
Controller (예외 처리 응답)
HandlerExceptionResolver 사용
WAS ->
Controller(최초 요청에서 예외 발생) ->
HandlerExceptionResolver (예외 처리 응답)
의 흐름으로 진행된다.
HandlerExceptionResolver
을 사용해서 직접 예외 응답을 하면, request header의 accept
의 값을 전부 고려해야 한다는 단점이 있다.
스프링은 여러 종류의 HandlerExceptionResolver
구현체를 제공한다.
해당 Resolver에서 두 예외를 처리한다.
@ResponseStatus
가 붙은 예외ResponseStatusException
public class MyException extends RuntimeException {
...
}
Controller에서 위 Exception이 터지면 WAS는 기본적으로 500을 응답한다.
import org.springframework.web.bind.annotation.ResponseStatus;
import og.springframework.http.HttpStatus;
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "You made Bad Request")
public class MyException extends RuntimeException {
...
}
@ResponseStatus
를 사용해서 status code와 error message를 지정할 수 있다.
예외가 발생할 상황에서 그냥 가져다 사용하면 된다.
@GetMapping("/throw-bad-request")
public void throwBadRequest() {
throw new MyException(); // 원래라면 500인데, @ResponseStatus에 설정한대로 400이 뜬다.
}
발생한 Exception에 @ResponseStatus
이 붙어있으면 ResponseStatusExceptionResolver
가 반응해서 설정한 status code, error message를 가지고 response를 만든다.
public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver implements MessageSourceAware {
...
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response)
throws IOException {
if (!StringUtils.hasLength(reason)) {
response.sendError(statusCode);
}
else {
String resolvedReason = (this.messageSource != null ?
this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
reason);
response.sendError(statusCode, resolvedReason);
}
return new ModelAndView();
}
}
ResponseStatusExceptionResolver
역시 response.sendError
를 호출 & ModelAndView
를 반환하는 것이다.
@ResponseStatus
의 reason
에 사용될 메시지를 messages.properties
로 분리할 수 있다. ResponseStatusExceptionResolver
가 처리해준다.// Custome Exception
@ResponseStatus(code = HttpStatus.BAD_REQUEST, code = "error.bad")
// messages.properties
error.bad = "잘못된 요청입니다."
내가 만든 예외 말고, 이미 존재하는 예외에는 @ResponseStatus
를 붙일 수 없다. 그런 경우에 ResponseStatusException
을 던진다.
@GetMapping("/response-status-exception")
public void throwEx() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "not found exception"); // messages.properties에 분리한 예외 메시지를 사용할 수 있다.
}
마찬가지로 ResponseStatusExceptionResolver
가 처리한다.
@GetMapping(...)
public String throwTypeMissmatchException(@RequestParam Integer data) {...}
파라미터 바인딩이 실패하면 TypeMissmatchException
이 WAS까지 전파된다.
이렇게 데이터 타입이 불일치 하는 경우는 주로 클라이언트의 실수일 경우가 많다. 이런 경우를 500으로 응답하면 안되므로, 스프링은 DefaultHandlerExceptionResolver
을 사용해서 400대로 응답을 보낸다.
이처럼 DefaultHandlerExceptionResolver
은 스프링 내부적으로 발생하는 Exception을 처리한다.
public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionResolver {
@Override
@Nullable
protected ModelAndView doResolveException(
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex)
{
...
}
}
HandlerExceptionResolver
는 ModelAndView를 반환해야 한다.이런 불편함을 해결하기 위해 스프링은 @ExceptionHandler
와 이를 처리하는 ExceptionHandlerExceptionResolver
을 제공한다.
@Data
@AllArgsConstructor
public class ErrorResult {
private String statusCode;
private String mssage;
}
예외가 발생하면 다음과 같은 객체를 JSON으로 넘기려고 한다.
@RestController
public class MyController {
@Getmapping("/throw-exception")
public void throwException() {
throw new RuntimeException();
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(RuntimeException.class)
public ErrorResult exceptionHandler(RuntimeException e) {
return new ErrorResult("BAD", e.getMessge());
}
}
@Getmapping("/throw-exception")
이 호출되면 예외가 발생한다.ExceptionHandlerExceptionResolver
을 통해서 @ExceptionHandler(class)
에 부합하는 메서드가 있는지 확인한다.따라서 정리하자면
ExceptionHandlerExceptionResolver
도 WAS까지 예외를 전파시키지 않는다. 따라서 WAS에서 Controller로 예외 처리 요청이 발생하지 않는다.@ExceptionHandler
은 예외를 던지는게 아니고, 요청을 처리하는 것이다. 따라서 @ResponseStatus를 통해 따로 설정하지 않으면, 기본적으로 200 OK 응답이 발생한다.위 코드를 다음처럼 개선 가능하다.
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
ErrorResult result = new ErrorResult("BAD", e.getMessage());
return new ResponseEntity(result, HttpStatus.BAD_REQUEST);
}
ResponseEntity<>
를 사용해서 일관적인 반환 타입을 유지 가능@ExceptionHandler
에 명시적으로 Exception 타입을 생략눈치껏 알 수 있듯, @ExceptionHandler
에서 처리하는 Exception은 상속 계층을 따른다. 즉, 부모 Exception을 잡으면 자식 Exception들도 동일한 ExceptionHandler에서 처리된다.
@ExceptionHandler
는 마치 @Controllr
처럼 다양한 파라미터를 사용할 수 있다. (공식문서)
@ExceptionHandler
은 자신이 위치한 Controller 내부에서 발생한 예외에만 반응한다.
@ExceptionHandler
은 자신이 위치한 Controller 내부에서 발생한 예외에만 반응한다.
이러한 특징 때문에(?) 다음의 문제가 발생한다.
이를 Spring boot의 @ControllerAdvice(@RestControllerAdvice)
로 해결할 수 있다.
참고: @RestControllerAdvice = @ControllerAdvice + @ResponseBody
// Controller
@RestController
public class MyController {
@Getmapping("/throw-exception")
public void throwException() {
throw new RuntimeException();
}
}
// ControllerAdvice
@RestControllerAdvice
public class MyControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(RuntimeException.class)
public ErrorResult exceptionHandler(RuntimeException e) {
return new ErrorResult("BAD", e.getMessge());
}
}
기본적으로 @ControllerAdvice
는 모든 Controller에서 발생한 예외에 전부 반응한다.
// 특정 Annotation에만 반응하기
@RestControllerAdvice(annotations = RestController.class)
// 특정 패키지 경로에만 반응하기
@RestControllerAdvice("org.example.controllers")
// 특정 컨트롤러에만 반응하기 (상속 관계 적용됨)
@RestControllerAdvice(assignableTypes = {MyController.class})
세 가지 방법으로 특정 컨트롤러에만 반응하도록 할 수 있다.
@ExceptionHandler
+ @ControllerAdvice
조합을 사용하자.