정적 리소스 제공의 경우 지금까지 설명했던 것 처럼 4xx, 5xx와 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있다.
그런데 API의 경우에는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.(기업 간 통신,MSA의 서비스 간 통신)
특정 id를 보내면 예외를 처리하는 컨트롤러를 만들어보자.
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id){
if(id.equals("ex")){
throw new RuntimeException("잘못된 사용자");
}
return new MemberDto(id,"hello"+id);
}
@Data
@AllArgsConstructor
static class MemberDto{
private String memberId;
private String name;
}
}
Postman에서 http://localhost:8080/api/members/ex 요청을 보내면 아래와 같이 html 본문이 그대로 내려온다.
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>500 오류 화면</h2>
</div>
<div>
<p>오류 화면 입니다.</p>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
문제를 해결하려면 오류 페이지 컨트롤러에서 JSON 응답을 할 수 있도록 응답해야 한다.
JSON 응답 컨트롤러
@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));
}
같은 url 호출이더라도 클라이언트 측에서 Accept가 application/json으로 되어있다면 produces = MediaType.APPLICATION_JSON_VALUE 의 우선순위 설정으로 인해 해당 메서드가 호출이 된다.
API 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다. 스프링 부트가 제공하는 BasicErrorController 에서 클라이언트 헤더의 Accept 요청에 맞게 데이터를 내려준다.
대부분 BasicErrorController는 HTML 화면을 처리할 때 사용하고, API 오류 처리는 뒤에서 설명할 @ExceptionHandler를 사용한다.
Exception은 서버에서 발생하기 때문에 기본적으로 HTTP 상태 코드 500(Internal Server Error)로 응답한다.(처리되지 않은 예외) 그러나 클라이언트가 잘못된 요청을 보낸 경우, 이를 400(Bad Request) 오류로 처리하고 싶다면 HandlerExceptionResolver(정상 응답)를 활용할 수 있다.
HandlerExceptionResolver 인터페이스를 구현하면 특정 예외에 대해 커스텀 로직을 정의하여 HTTP 상태 코드와 메시지를 제어할 수 있다.
ExceptionResolver를 적용하지 않으면 WAS에 예외가 전달되지만 중간에 예외를 해결하여 정상 응답으로 반환할수 있다.(해결해도 postHandle은 처리되지 않는다.)
@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;
}
}
ModelAndView를 return하면 예외를 처리한것으로 서버가 정상적으로 작동한다.
WebConfig 등록
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
resolveException 적용 결과
활용
예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error를 호출하는 과정은 생각해보면 너무 복잡하다. ExceptionResolver를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다.
사용자 정의 Exception
if(id.equals("user-ex")){
throw new 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());
}
이렇게 등록하고 http://localhost:8080/api/members/user-ex 에 요청을 보내면 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이 난다. 결과적으로 WAS 입장에서는 정상 처리가 된 것이다. 이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다.
BasicErrorController를 사용하여 HTML 화면을 제공하는 방식은 API에 맞지 않고 HandlerExceptionResolver를 직접 구현하는 방식으로 다루기는 쉽지 않다. 또한 다음과 같은 불편한 점이 있다.
HandlerExceptionResolver은 ModelAndView를 반환해야 했다. 이것은 API 응답에는 필요하지 않다.
다른 도메인에서 같은 예외 클래스를 사용하지만 예외 처리를 각각 다르게 해야할때는 어떻게 처리할까?(ex.회원가입,상품처리 == 400오류)
스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver이다.
컨트롤러 @ExceptionHandler 추가
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(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","내부오류");
}
@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;
}
}
ExceptionHandlerExceptionResolver가 해당 컨트롤러에서 발생한 예외와 동일한 예외가 설정된 @ExceptionHandler의 메서드를 실행하게 된다. 이렇게 되면 200 OK로 반환하게 된다. 만약 상태 코드도 변경하고 싶다면, @ResponseStatus을 적용해주면 된다. 결과적으로 WAS에 정상 흐름 반환으로 끝이나게 된다.
마지막 @ExceptionHandler에서 파라미터 Exception으로 받게 되면 최상위 클래스이기 때문에 상부에 정의한 모든 @ExceptionHandler에서 처리하지 못한 예외를 처리하게 된다.
@RestControllerAdvice를 붙여 @ExceptionHandler을 정의하면 전역적으로 모든 컨트롤러에서 발생하는 예외에 대한 처리가 가능하다. 특정 컨트롤러에 적용하고 싶다면, 아래를 참고한다.
// 특정 애노테이션
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// 패키지
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// 특정 컨트롤러 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class,
AbstractController.class})
public class ExampleAdvice3 {}
HTTP 상태 코드를 지정해준다.
사용자 정의 Exception
@ResponseStatus(code= HttpStatus.BAD_REQUEST,reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}
컨트롤러 등록
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1(){
throw new BadRequestException();
}
@ResponseStatus 는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. (외부 라이브러리의 예외 코드에는 적용할 수 없다.)
추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.
@GetMapping("/api/response-status-ex1")
public String responseStatusEx2(){
throw new ResponseStatusException(HttpStatus.NOT_FOUND,"error.bad",new IllegalArgumentException());
}
IllegalArgumentException를 400(Bad Request)으로 매핑한다. 또한 조건에 따라 다른 상태 코드를 반환 가능하다.
Spring에서 파라미터 바인딩 중 타입이 맞지 않을 경우 TypeMismatchException이 발생한다. 기본적으로 이 예외는 서블릿 컨테이너까지 전달되어 500 내부 서버 오류로 처리된다. 그러나, 이는 클라이언트의 잘못된 요청에서 발생하는 문제이므로 HTTP 400 오류로 처리하는 것이 적절하다.
Spring의 DefaultHandlerExceptionResolver는 이러한 상황에서 예외를 처리하여 HTTP 상태 코드 400을 반환하도록 설계되어 있다. 이는 스프링 내부에 다양한 예외 처리 규칙 중 하나로 정의되어 있다.
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data){
return "ok";
}
postman으로 http://localhost:8080/api/default-handler-ex?data=aaa 로 보낸다면 500에러가 아닌 400에러가 발생한다. 이는 Spring의 DefaultHandlerExceptionResolver가 요청 파라미터 타입 변환 오류를 클라이언트의 잘못된 요청으로 간주하여 HTTP 400 (Bad Request) 상태 코드로 처리하기 때문이다.