3 분 소요


목적
예외가 서블릿을 넘어 WAS까지 전달되면 HTTP 상태코드가 500으로 처리된다.

  • 발생하는 예외에 따라 400, 404 등 다른 상태코드로 처리하고 싶다.
  • 오류 메시지, 형식등을 API마다 다르게 처리하고 싶다.

시작

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("잘못된 입력 값");
    }

    return new MemberDto(id, "hello " + id);
}

실행 결과

{
 "status": 500,
 "error": "Internal Server Error",
 "exception": "java.lang.IllegalArgumentException",
 "path": "/api/members/bad"
}

IllegalArgumentException이 컨트롤러 밖으로 넘어가면 상태코드 500으로 처리되지만, 이를 HTTP 상태코드가 400이 되도록 처리하고 싶다.

HandlerExceptionResolver

스프링 MVC는 컨트롤러 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.
위와 같은 방법을 사용하고 싶다면 HandlerExceptionResolver를 사용하면 된다.(줄여서 ExceptionResolver라 한다.)

적용 전

exceptionResolver1

적용 후

excceptionResolver2

[참고]
ExceptionResolver로 예외를 해결해도 postHandle()은 호출되지 않는다.

MyHandlerExceptionResolver

@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;
    }
}
  • ExceptionResolverModelAndView를 반환하는 이유는 마치 try - catch를 하듯이, Exception을 처리해서 정상 흐름 처럼 변경하는 것이 목적이다.

여기서는 IllegalArgumentException이 발생하면 response.sendError(400)를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView를 반환한다.

반환 값에 따른 동작 방식
HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식은 다음과 같다.

  • 빈 ModelAndView: new ModelAndView()처럼 빈 ModelAndView를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
  • ModelAndView 지정: ModelAndViewView, Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
  • null: null을 반환하면 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

활용

  • 예외 상태 코드 변환
    • 예외를 response.sendError()호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
    • 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출.
  • 뷰 템플릿 처리
    • ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면을 렌더링 해서 고객에게 제공
  • API 응답 처리
    • response.getWriter().println("hello")처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능.

WebConfig

WebMvcConfigurer를 통해 등록

@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new MyHandlerExceptionResolver());
}

[참고]
configureHandlerExceptionResolvers()를 사용하면 스프링이 기본으로 등록하는 ExceptionResolver가 제거되므로, extendHandlerExceptionResolvers를 사용.


활용

예외를 깔끔하게 마무리
예외가 발생하면 WAS까지 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 호출하는 과정은 너무 복잡하다.
ExceptionResolver를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 그 자리에서 바로 해결할 수 있다.

UserException

public class UserException extends RuntimeException {

    // ...
}

ApiExceptionController

@Slf4j
@RestController
public class ApiExceptionController {
    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {

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

/api/members/user-ex 호출시 UserException이 발생하도록 해두었다.

UserHandlerExceptionResolver

예외를 처리하는 UserHandlerExceptionResolver

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

HTTP 요청 해더의 Accept값이 application/json이면 JSON으로 오류를 내려준다.

WebConfig

UserHandlerExceptionResolver 추가

@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new MyHandlerExceptionResolver());
    resolvers.add(new UserHandlerExceptionResolver());
}

실행 결과

/api/members/user-ex

ACCEPT: application/json

{
    "ex": "hello.exception.exception.UserException",
    "message": "사용자 오류"
}

정리

ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver에서 예외를 처리해버린다.
따라서, 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이난다.
결과적으로 WAS 입장에서는 정상 처리가 된 것이고, 이렇게 예외를 한곳에서 모두 처리할 수 있다는 것이 핵심이다.

직접 ExceptionResolver를 구현하니 복잡하지만, 스프링이 제공하는 ExceptionResolver들을 사용하면 편하게 사용할 수 있다.


<출처 : 인프런 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술(김영한)>

댓글남기기