[1/2 : Exception다루기] (Spring없이) ExceptionHandler가 뭘까?

BlackBean99·2022년 9월 9일
1

SpringBoot

목록 보기
14/20
post-thumbnail

Exception 예외처리를 위한 다양한 방법들이 있죠 그 중에 Spring에서 주력으로 다루는 ExceptionHandlerExceptionResolver 를 이해하기 위해서 필요한 지식들을 다뤄보겠습니다.

코드로 같이 이해해봅시다.


Controller

일단 테스트를 위한 컨트롤러를 짜보겠습니다

특정 에러페이지가 나왔을때, 400. 500 에러페이지를 호출해주는 컨트롤러인

1. ErrorPageController

@Slf4j
@Controller
public class ErrorPageController {
    //RequestDispatcher 상수로 정의되어 있음
    public static final String ERROR_EXCEPTION = "javax.servlet.error.exception";
    public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type";
    public static final String ERROR_MESSAGE = "javax.servlet.error.message";
    public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri";
    public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name";
    public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";
    @RequestMapping("/error-page/404")
    public String errorPage404(HttpServletRequest request, HttpServletResponse
            response) {
        log.info("errorPage 404");
        printErrorInfo(request);
        return "error-page/404";
    }
    @RequestMapping("/error-page/500")
    public String errorPage500(HttpServletRequest request, HttpServletResponse
            response) {
        log.info("errorPage 500");
        printErrorInfo(request);
        return "error-page/500";
    }
    private void printErrorInfo(HttpServletRequest request) {
        log.info("ERROR_EXCEPTION: ex=", request.getAttribute(ERROR_EXCEPTION));
        log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
        log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE));
        //ex의 경우 NestedServletException 스프링이 한번 감싸서 반환
        log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
        log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
        log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
        log.info("dispatchType={}", request.getDispatcherType());
    }
}

다음은 임시로 테스트할 유저 컨트롤러를 만들어서?

@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으로 테스트를 해보면?

네 코드 예외처리한대로 안하고 HTML이 반환되버립니다 . JSON결과가 아니구요..

그래서 API에서는 HTML이 아니라 JSON으로 에러를 반환해주려면 처리를 따로 해줘야합니다

ErrorPageController에 이 500페이지에 대한 처리를 추가로 작성해줍니다.

여기서 produces = MediaType.APPLICATION_JSON_VALUE의 뜻은 클라이언트가 요청하는 HTTP Header의
Accept 의 값이 application/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));
}

이제 다시 호출해보면?

짜잔~ 설정해준 JSON에러가 정상적으로 발생했습니다.


Spring에서의 예외처리

Spring 기본 설정은 오류 발생시 /error 를 오류 페이지로 요청한다.
BasicErrorController 는 이 경로를 기본으로 받는다. ( server.error.path 로 수정 가능, 기본 경로 /
error )

좀더 자세히
BasicErrorController에서 제공하는 기본 필드값들로 아래 오류 API를 생성해준다

server.error.include-binding-errors=always
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always

그런데 이렇게 막 추가해서는 보안상 위험하니 적절히 사용하도록 하자.

아무튼 BasicErrorController를 사용하면 HTML을 반환하는 경우 아주아주 편리하다.

그럼 기존의 예외와 달리 서블릿을 넘어서 WAS까지 에러가 500으로 처리되는데 이를 400이나 404로 처리하고 싶다면?
다시 말해 던져진 에러를 해결하고 새롭게 정의해서 사용하는 방법이 있는데 이를 위해서
HandlerExceptionResolver를 사용한다. 줄여서 ExceptionResolver 라고 칭한다.

  • ExceptionResolver 적용 전

  • ExceptionResolver 적용 후

HandlerExceptionResolver Interface를 구현해서 사용합니다.

ExceptionResolver 가 ModelAndView 를 반환하는 이유는 마치 try, catch를 하듯이, Exception 을
처리해서 정상 흐름 처럼 변경하는 것이 목적입니다.
위 그림을 보면 나와있죠?

그럼 이를 이용해서 IllegalArgumentException 이 발생하면 400에러를 내보내구, 빈 ModelAndView를 반환하는 코드를 작성해보면

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

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

그럼 늘 빈 ModelAndView를 반환해줘야 하나요??
아니?

HandlerExceptionResolver 의 반환 값에 따른 DispatcherServlet의 동작과정

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

적용은 WebMvcConfigurer 에서

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

글로벌하게 적용시킬 수 있지만, 스프링이 기본으로 적용해주는 ExceptionResolver가 해제된다. 주의..


하지만 API를 개발하는 내 입장에서는 ModelAndView는 필요가 없는데..?
그럼 직접 해보자!

사용자 예외처리 적용

아까 작성해둔 ApiExceptionController에서 예외를 추가로 작성해봅시다.

        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }

사용자 예외도 작성하구요

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

HTTP 요청 해더의 ACCEPT 값이 application/json 이면 JSON으로 오류를 내려주고, 그 외 경우에는
error/500에 있는 HTML 오류 페이지를 보여주는 코드로 커스텀해보면

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

아! 참고로 이 방법도 WebMvcConfigure를 이용하면 Global로 적용가능합니다.

정리

ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를
처리해버린다. 따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이
난다.

그런데 이걸 ExceptionHandler를 이렇게 직접 구현하기가 참 쉽지가 않아서

Spring이 제공하는 방법을 다음 포스팅에서 다뤄보겠습니다!

profile
like_learning

0개의 댓글