스프링/4. 스프링 MVC-2

70. HandlerExceptionResolver로 API 예외 처리

sdafdq 2023. 9. 13. 23:33

예외가 발생하면, 그 예외가 서블릿, WAS까지 올라갔다가 WAS는 그 예외에 대해 등록해놓은 ErrorPage를 호출하여 (아무것도 등록 안해도 BasicErrorController가 자동으로 등록되니, 그것을 호출한다.) 다시 그 ErrorPage에 있는 컨트롤러까지 갔다가 또 그게 쭉 WAS까지 갔다가 그제서야 응답이 간다.

 

근데 HandlerExceptionResolver는 명명 그대로 해결사이기 때문에 컨트롤러에서 에러를 뿜어도 정상동작이라고 ModelAndView를 만들어 저 Controller -> DispatcherServlet -> HandlerExceptionResolver(정상적인 ModelAndView를 만듦) -> DispatcherServlet -> WAS

이 흐름에서 정상동작이라고 WAS가 알게 만들 수 있다.

 

왜냐하면 HandlerExceptionResolver에서 WAS까지 올라갈 에러를 먹어버리고, 저기서 이상없는 ModelAndView를 만들어 return 시켜버리니까.

 

여튼, 에러가 발생하면 WAS까지 에러가 갔다가, 다시 에러컨트롤러를 찾아서 호출했다가, WAS까지 가서 그제서야 응답한다면,

HandlerExceptionResolver를 쓰면 그냥 에러 먹어버리고 이상없는 ModelAndView를 만들어 반환시켜주기 때문에 한 큐에 간다.

 

그래서 더 효율적이다.

 

 

여튼간에, HandlerExceptionResolver를 사용해 구현한 에러 정보를 Json형태로 반환해주는 예시를 보자.

먼저 에러를 내뿜게 만드는 컨트롤러는 이렇다.

@RestController
@Slf4j
public class ApiExceptionController {

    @GetMapping("/api/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;
    }
}

대충 uri에 따라서 여러가지 에러를 내뿜을 수 있게 만들었다.

저 UserException, 이 내가 만든 커스텀 에러이며, HandlerExceptionResolver로 처리하게 할 것이다.

 

public class UserException extends RuntimeException{
    public UserException() {
        super();
    }

    public UserException(String message) {
        super(message);
    }

    public UserException(String message, Throwable cause) {
        super(message, cause);
    }

    public UserException(Throwable cause) {
        super(cause);
    }

    protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

 

그냥 만들었다. 별다른 한 것은 없다.

걍 저렇게 생성자로 에러메시지만 넣어서 에러를 만들수도 있고, 여튼 .. 뭐 따로 한건 없다. 그냥 생성자들만 모두 자동완성 해 놓은 것이다. 걍 편하라고. 여러가지로 써야 하니까.

 

 

 

@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 resultJson = objectMapper.writeValueAsString(errorResult);

                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(resultJson);
                    return new ModelAndView();
                }else{
                    return new ModelAndView("error/500");
                }
            }
        }catch(IOException e){
            log.error("resolver ex", e);
        }

        return null;
    }
}

이게 HandlerExceptionResolver를  구현하여 만든 ExceptionResolver이다. 

저 인터페이스 안에 딸랑 resolveException 하나만 있는데, 저거 오버라이드 해서 구현하면 된다.

 

먼저, 우리는 객체를 Json형태로 변환하기 위해 ObjectMapper를 썼다.

 

만약 들어온 에러가 내가 만든 UserException의 구현체라면, 

먼저 요청의 컨텐츠 타입이 뭔지 확인한다. request.getHeader("accept")해서.

그리고 응답의 상태코드도 클라이언트에러로 바꾸고

 

그리고 사용자의 요청 컨텐츠 타입(Accept)이 application/json이면,

Map 컬렉션 하나 만들어 줘서 거기에 에러가 무슨 클래스인지, 또 에러 메시지만 담는다.

 

그리고 ObjectMapper로 objectMapper.writeValueAsString(object) 해 줘서 오브젝트의 값을 Json 형태의 String으로 변환하여 반환한다.

 

그 다음 response의 컨텐츠 타입을 application/json으로 해주고, 또 그 메시지 바디에 대한 문자 인코딩을 utf-8로 설정해 주고,

최종적으로 response.getWriter().write(resultJson)해서 오브젝트의 값을 Json형태의 String으로 바꾼것을 응답의 메시지 바디에 써 준다.

 

그리고 return new ModelAndView 해주면, 에러도 얘가 먹었고, ModelAndView도 정상적이므로, 이걸 받은 WAS는 그대로 response를 클라이언트에게 보낸다.

 

만약 요청의 컨텐츠 타입이 application/json이 아니면, ModelAndView에 논리 뷰이름, error/500을 넣어 저 템플릿을 render() <- (메시지바디에 넣어서) 해서 클라이언트에게 그대로 보낸다.

 

catch(IOException e) 이 부분은 저 로직을 수행 도중 내뿜는 에러가 있으면 잡는 것 이므로 일단은 넘어가고,

 

 

에러(ex)가 UserException이 아니면 그냥 null을 return 시켜 WAS가 에러를 먹을것이다.

그럼 WAS는 따로 ErrorPage를 등록한 게 아니라면 BasicErrorController에 따라 응답할 것이다.

(ErrorPage는 API에 대한 처리는 아무래도 안하는 건가? 생성할 때 뷰이름이랑 상태코드로 등록하기도 하고, 이름도 ErrorPage니까?)