여러 문서들을 참고해서 예외 처리 방법을 확인해보자
public class FooController{
//...
@ExceptionHandler({ CustomException1.class, CustomException2.class })
public void handleException() {
//
}
}
이 방법은 특정 컨트롤러 레벨에서만 예외 처리가 가능하다는 단점이 있다. Base Controller를 만들어서, 컨트롤러들이 이를 상속하게 해서 이 단점을 우회할 수는 있다.
ExceptionHandlerExceptionResolver
스프링 3.0부터 도입됐고, DispatcherServlet에서 디폴트로 사용된다.
이 리졸버는 standard한 스프링 예외들을 처리할 때 사용된다. 다만, 응답 바디에는 어떠한 내용도 넣을 수 없다는 단점이 있다 (상태코드는 가능)
상태코드만으로는 클라이언트에 충분한 정보를 줄 수 없어서 상당히 큰 단점이다. 그래서 스프링 3.2부터 더 개선된 버전의 리졸버를 도입한다.
ResponseStatusExceptionResolver
스프링 3.0버전에서 도입됐고, DispatcherServlet가 디폴트로 사용한다. 커스텀 예외에서 @ResponseStatus 를 읽고 상태코드를 응답에 맵핑시켜주는 역항를 한다.
위 두 리졸버는 응답 바디 설정이 불가능하다.
@Component
public class RestResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver {
@Override
protected ModelAndView doResolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
return handleIllegalArgument(
(IllegalArgumentException) ex, response, handler);
}
...
} catch (Exception handlerException) {
logger.warn("Handling of [" + ex.getClass().getName() + "]
resulted in Exception", handlerException);
}
return null;
}
private ModelAndView
handleIllegalArgument(IllegalArgumentException ex, HttpServletResponse response)
throws IOException {
response.sendError(HttpServletResponse.SC_CONFLICT);
String accept = request.getHeader(HttpHeaders.ACCEPT);
...
return new ModelAndView();
}
}
이런식으로 바디에 대해서 조정이 가능하다.
accpet header를 고려할 수 있기 때문에, 클라이언트가 json 형식으로 응답을 요청하면 에러 응답이 json으로 가게끔 설정할 수 있다.
다만, 이 리졸버는 저수준의 HtttpServletResponse와 상호작용을 한다는 점이 단점이라고 한다.
스프링 3.2부터 글로벌 예외 처리를 가능하게 하는 the @ControllerAdvice annotation가 도입됐다.
에러 핸들러를 여러개 만들지 않고, 하나만 만들어서 관리가 가능하다는 점이 큰 이점이다.
스프링 5부터 도입된 방식이다.
@GetMapping(value = "/{id}")
public Foo findById(@PathVariable("id") Long id, HttpServletResponse response) {
try {
Foo resourceById = RestPreconditions.checkFound(service.findOne(id));
eventPublisher.publishEvent(new SingleResourceRetrievedEvent(this, response));
return resourceById;
}
catch (MyResourceNotFoundException exc) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "Foo Not Found", exc);
}
}
@ControllerAdvice를 전역적으로 사용하고,ResponseStatusExceptions를 지엽적으로 사용할 수도 있다.
하지만, 동일한 예외를 여러 관리 포인트로 관리하면 더 복잡해질 수 있다는 점에는 주의해야 한다.
100-level (Informational) – server acknowledges a request
200-level (Success) – server completed the request as expected
300-level (Redirection) – client needs to perform further actions to complete the request
400-level (Client error) – client sent an invalid request
500-level (Server error) – server failed to fulfill a valid request due to an error with server
이렇게 상태코드를 전달해줄 필요가 있다.
500에러는 요청을 처리하면서 어떠한 이슈가 서버에서 발생했다는 의미를 내포한다. 보통은, 클라이언트의 요청에 어떤 문제가 있다고 보긴 어렵다.
그렇기에 500에러는 가급적 보내지말고, 내부 에러를 처리해서 적절한 상태 코드를 보내주는 게 좋다. 물론 500 코드를 아예 보내지 말라는 건 아니다. 500 코드는 클라이언트에서 요청을 어떻게 변경해야 할지 파악하기 힘들기 때문에, 클라이언트 요청에서 문제가 있는거라면 이걸 400번대 코드로 변경해야 한다.
외부 서비스 장애나, 서버 리소스 부족 등의 상황에선 500 코드를 보내 상태를 알려야 한다.
상태 코드만으로는 error의 정확한 상태를 전달하지 못한다. 추가적인 정보를 바디에 담는 것도 필요할 때가 있다.
{
"error": "auth-0001",
"message": "Incorrect username and password",
"detail": "Ensure that the username and password included in the request are correct"
}
error filed는 가급적이면 응답 코드와는 일치시키지 않고, 서버 자체적인 코드로 하는 게 좋다.
{
"error": "auth-0001",
"message": "Incorrect username and password",
"detail": "Ensure that the username and password included in the request are correct",
"help": "https://example.com/help/error/auth-0001"
}
참고로 보안상의 이유로 응답 메시지에 어떤 부분이 잘못됐다고 명시하지 않는 게 좋을때도 있다.
가령, 로그인을 할 때 비밀번호가 잘못됐습니다라는 정보를 주는 것보다, 아이디나 비밀번호가 일치하지 않습니다처럼 모호하게 보내는 게 보안상으로는 더 좋다.