백앤드(스프링)

API 예외 처리

유승혁 2022. 8. 21. 20:25

 이전 글처럼 간단한 오류와 간단하게 오류 페이지를 보여주는 것이 아닌 API 리턴해 주는 역할의 요청일 경우에는 json으로 응답을 해주어야 한다. 어떻게 해야할까? 으음..

 예를 들어 어떤 요청 파라미터 하나를 빼먹으면 null이 되니깐 이 경우 redirect를 해서 에러 json을 리턴하게 할까? 헐 굉장히 귀찮고 복잡하다.. 이렇게 하려면 막막해 보이지만, 스프링은 (당연하게도) 이런 상황에서 해결책을 제공해 준다.

 

0. BasicErrorController

  지난 에러 페이지 글에서 /resources/templates 밑에 그냥 error디렉터리 만들고 error/4xx.html 같은 파일 하나 만들어 주면 알아서 에러 페이지를 핸들링 해주는 녀석이 API 예외처리에도 많은 역할을 해준다. 요 녀석 코드를 보자.

@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);
   }
   ...
}

 errorHtml 메서드가 바로 html을 보여주게끔 해주는 코드이고 아래 보이는 error 메서드는 ResponseEntity 즉, body 부분에 데이터를 Map 형식으로 차곡차곡 끼워 넣는다. 그러하면 아래와 같은 응답이 나온다.

{
    "timestamp": "2022-08-21T07:48:31.939+00:00",
    "status": 404,
    "error": "Not Found",
    "path": "/bad"
}

 하지만 내가 원하는 건 이런 에러 뿐만 아니라 에러를 등록하고 에러 바디부분을 바꾸거나 http 상태 코드를 변경하는 등 자유롭고 세세한 예외처리를 하고 싶다는 것이다. 하나씩 알아보자

 

1. HandlerExceptionResolver(ExceptionResolver)

 먼저 상태 코드를 바꾸는 방법을 알아보자.

 예로 IllegalArgumnetException의 exception이 터지면, WAS는 구분 없이 500 에러를 리턴해 준다. 하지만 이건 솔직히 클라이언트가 잘못 요청 한 것이라서 400같은 에러를 리턴하고 싶은 것이다. 어떻게 할까?

 요청이 와서 Dispatcher Servlet이 동작 하면서 prehandle 호출하고[interceptor의 prehandle을 말하는 것] 핸들러 어댑터 통해서 핸들러 호출 했는데 거기서 에러가 터진 것이다. 이때 만약 ExceptionResolver가 있으면 에러를 해결해주고 afterCompletion 호출 이후 View 보여주고 정상응답으로 WAS에 보내준다. 이러면 WAS는 500 같은 처리를 안하게 되겠지. 

 추가로 에러가 터지고 정상 응답인 것 처럼 동작해도 postHandle()은 호출 되지 않는다.

public class MyHandlerExceptionReslover implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, 
    			HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                log.info("IllegalArgumentEception resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());//404랑 메시지 등록
                return new ModelAndView();
            }
        }catch(IOException e){
            log.error("resolver ex", e);
            e.printStackTrace();
        }
        return null;
    }
}

 보이다 시피 Exception ex를 인자 값으로 받을 수 있기에 ex가 IllegalArgumnetException이면 response.sendError를 통해 404 상태코드를 갖게 끔 설정했다. sendError 자체가 체크 예외로 등록 되어 있어서 IOException에 대해 try-catch로 감싸야 한다. 체크/언체크 예외에 대해서 추후에 정리하도록 하겠다.

 또한 빈 ModelAndView를 return 한다. 어차피 json 이니깐 view 렌더링 할게 없기도 하고 dispatcherServlet에게 ModelAndView를 리턴해주어야 하기 때문이다.

 

 아무튼 이 후에는 등록!을 해야겠지. 등록은 지난번의 WebConfig클래스에 이어서 등록을 진행 하겠다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionReslover());
    }
}

 이렇게 Config class에 extendHandlerExceptionResolvers라는 메서드에 우리가 정의한 ExceptionResolver를 추가하면 된다.

 

 추가적으로 ModelAndView 정보가 저장 되어 있다면, View를 렌더링 해버린다. html 오류페이지에 사용 되겠지요. 놀라울 점은 밑에 보면 null을 리턴하게 되는데, 코드 상에서 보이다 시피 해당 exception을 처리하지 못할 경우 null을 리턴한다. 그렇게 되면 다른 ExceptionResolver를 계속 돌아보다가 결국 없으면 WAS에게 전달 되어 500 에러가 발생된다.

 만약 이 상태에서 JSON으로 응답을 하고 싶다면? 아직까지 알고 있는 지식으로는 HttpServletResponse가 있으니 response.getWriter()를 통해서 값을 채워 넣으면 된다. 하지만.. 이건 너무 귀찮은 작업이 되기에 다른 방법을 곧 소개하겠다. 

 

1.1 예외 처리 간소화 하기

 지금 잘 생각해보면 결국 예외 발생 시 (예외 처리를 했건 안했건) sendError를 사용했기 때문에 WAS에게 어떠한 오류인지 보고를 했고 그 덕에 WAS는 에러를 보여주게끔 한다. 이렇게 두 번의 WAS 판단 과정이 있는데 이 복잡한 과정을 HandlerExceptionResolver(ExceptionResolver)가 ExceptinoResolver 부분에서 끝내버릴 수 있다. 간략하게 이야기 하자면 sendError를 사용하는 것이 아니라, response.getWriter()로 json 내용물을 채워 넣고 modelAndView를 리턴하면 된다. 아래 코드 처럼.

@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;
}

 이처럼 json을 ObjectMapper를 통해 문자열로 바꾸어서 body에 내용을 담아 ModelAndView를 반환 함으로서 정상 흐름이 되었다. response.sendError를 사용하지 않았기 때문에 에러가 전달 될 일이 없이 이 부분에서 잘 처리하게 된 것이다.

 

 하지만 지금 보이듯이 ExceptionResolver 구현이 만만치 않다. try catch 부터 json을 objectMapper로 변환을 한다고는 하지만 만드는 과정 순간순간이 고통이다. 이러한 단점을 스프링이 제공하는 ExceptionResolver들을 통해 쉽게 설정 할 수있다. 알아보자.

 

2. 스프링이 제공하는 ExceptionResolver

 부트가 제공하는 ExceptionResolver 목록은 아래와 같다.

1. DefaultHandlerExceptionResolver: 스프링 내부 기본 예외를 처리한다.

2. ResponseStatusExceptionResolver: Http상태 코드를 지정한다. 즉, 상태 코드를 쉽게 바꾸어 줄 수 있다.

3. ExceptionHandlerExceptionResolver: 대부분의 예외처리를 요 녀석으로 한다.

 우선순위는 3번이 제일 높고 3번에서 처리 안되면 2번, 2번도 안되면 1번이 처리하는 식으로 간다.

 

2.1 ResponseStatusExceptionResolver

 @ResponseStatus 어노테이션이 붙어있는 예외이거나 ResponseStatusException 을 직접 던지는 예외들에 대해 동작하는 resolver이다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

 이 코드처럼 정말 간단하게도 어노테이션에 code= "" 을 통해 상태 코드를 지정해 줄 수 있다. 내부 로직은 이미 구현해 본대로 ResponseStatusExceptionResolver에 있는 applyStatusAndReason 메서드가 동작해서 지정된 상태 코드와 reason에 저장된 메시지를 response.sendError에 담고 빈 ModelAndVeiw를 반환해준다. 어노테이션은 정말 고맙달까.

 추가적으로 메시지 같은 경우는 application.properties에서

server.error.include-message=always

같은 설정을 통해서 전달 가능하다. 일일이 메시지 명을 적기 귀찮다면 resource 밑에 messages.properties에 코드 명을 정의해서 가져다 사용할 수 있다.

 

 추가적으로 보이다 싶이 어노테이션을 붙인다는 것은 조건에 따라 다른 상태코드를 리턴하게 하거나, 라이브러리에는 쉽사리 적용하지 못한다. 이때는 ResponseStatusException 예외를 사용하면 된다.

@GetMapping("/api/response-status-ex2")
  public String responseStatusEx2() {
      throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
  								IllegalArgumentException());
  }

 위처럼 직접 상태코드 지정하고 메시지 정해주고 exception 정의 해주면 ResponseStatusException 얘가 설정 해준 대로 코드를 진행되어서 반환해준다.

 

2.2 DefaultHandlerExceptionResolver

 이름 답게 스프링이 기본적으로 제공해주는 exception resolver이다. 예시로 TypeMismatchException 같은 경우 WAS는 당연스럽게도 500 오류가 리턴 되어야 하지만, 사실상 이건 클라이언트 문제이므로 HTTP상태 코드를 400으로 리턴해 준다.

 실제로 이 코드를 까보면 type mismatch 뿐만 아니라 헤더 정보가 요청과 다른 경우, 메서드가 지정 값과 다른 경우 등등 다양한 에러들을 걸어 주었다.

 

 아무쪼록 API 예외처리의 경우에는 ModelAndView가 필요하지 않고 body 내용이 필요한 것이고, 같은 회원에 대한 예외라 할지라도 어느 컨트롤러에서 발생한 것이냐에 따라 다른 방식으로 예외를 처리한다고 하면 위 방식으로는 적절하지 않다. 이를 해결해주는 ExceptionHandlerExceptionResolver에대해 알아보자.

 

3. ExceptionHandlerExceptionResolver

 스프링 예외처리의 꽃이랄까. 상태 코드도 맘대로 지정하고 원하는 입력 값(json)을 지정해 줄 수 있다. 사실상 이것만 쓰는데 내부 동작을 알기 위해 이 과정을 거쳐 왔다. 꽃을 피워보자.

@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message;
}

 우리가 바라던대로 우리만의 오류 json을 만들기 위한 DTO를 만들었다.

@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e){
    log.error("[exceptionHandler] ex", e);
    return new ErrorResult("BAD", e.getMessage());
}

@GetMapping("/members/{id}")
public MemberDto getMember(@PathVariable("id") String id){
    ...
    if(id.equals("bad")){
        throw new IllegalArgumentException("잘못된 입력 값");
    }
    ...
    return new MemberDto(id,"hello "+id);
}

 위와 같이 예외가 발생 되면 컨트롤러에서 @ExceptionHandler 어노테이션을 통해 json으로 에러를 반환하면 끝난다.

 물론 내부 과정으로는 컨트롤러에서 예외가 터져서 ExceptionHandler에게 에러 해결 가능? 이라 물어볼때 일빠따인 ExceptionHandlerExceptionResolver 요녀석에게 물어봐서 에러가 발생된 컨트롤러와 같은 컨트롤러에 해당 에러가 정의 되어 있다면( 해당 컨트롤러에서 벗어난 예외처리들은 적용 되지 않는다.) 메서드를 동작하게 한다.

 이때, 단지 json으로 리턴되기에 정상 로직으로 반환한다. 즉, 200이 리턴된다.이럴땐? 앞에서 다루었던, @ResponseStatus 어노테이션을 통해 상태코드를 변경해 버리면 된다.

 물론, 이 과정 덕에 WAS가 두 번일을 하지 않는다.

 

 추가적으로 위 코드 다른 버전들이 있는데 ResponseEntity<T>로 에러 객체를 감싸서 status를 지정 할 수도 있고 @ExceptionHandler 어노테이션에 인자값을 생략 할 수 도 있다. 뿐만 아니라 Exception 들은 각각 서로 상속 관계에도 적용 되기에 맨 최상위인 Exception을 선언을 해버리면 예외처리를 안한 모든 예외처리는 Exception에게 가서 resolve 동작을 수행하게 된다. 

 반대로 @ExceptionHandler에 여러 exception들을 나열하고 해당 exception들의 부모 exception을 함수 인자로 받아서 여러 예외처리 한번에도 할 수 있다.

 물론 ModelAndView 라던지 string으로 리턴해서 View를 렌더링 할 수도 있다.

 

4. @ControllerAdvice

 딱 봐도 불편한 점들이 있다. 해당 컨트롤러에만 적용 된다는 단점, 컨트롤러 코드가 불어난다는 단점. 이런 경우를 도와주는 마치 aop 같은 역할을 해주는 @ControllerAdvice 어노테이션이 존재한다.

@Slf4j
@RestControllerAdvice//ResponseBody + ControllerAdvice
public class ExControllerAdvice {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e){
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }
    ...
    다른 예외들
    ...
}

  위와 같이 ExceptionResolver들을 한 곳에 모아모아 대상으로 지정된 컨트롤러에 이 예외처리들을 적용 할 수 있다. 물론 디폴트 값은 모든 컨트롤러에 적용 시키는 것이다.

 지정하는 방법은 @RestControllerAdvice 인자 값으로 annotation= 과 같은 식으로 어노테이션 별로 지정하거나 basePackages= 을 통해 패키지와 해당 패키지의 하위 별로 지정해 줄 수 있다. 뿐만 아니라 assignableTypes={}를 통해 정해진 class들에게만 적용 할 수 도 있다. 

'백앤드(스프링)' 카테고리의 다른 글

Bean Validation 심화 내용  (0) 2022.09.04
로깅 남바 투  (0) 2022.08.22
예외 처리와 오류 페이지  (2) 2022.08.10
ㅅ,ㅍ,링 타입 컨버터와 포맷터  (0) 2022.08.09
서블릿 필터와 스프링 인터셉터  (0) 2022.08.01