BackEnd/Spring MVC

[Spring] API 예외 처리 - 1

샤아이인 2022. 3. 14.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

 

1. 시작

API의 예외 처리는 기존과 다르다.

이전처럼 단순하게 고객에게 오류 페이지를 보여주는것으로 끝나지 않는다.

API 방식은 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.

 

● ApiExceptionController - API 예외 컨트롤러

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable 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;
    }
}
 

단순한 회원 조회하는 기능의 컨트롤러 이다. id값으로 ex를 넘기면 RuntimeException을 던지도록 구현하였다.

이제 작성한 컨트롤러가 어떻게 동작하는지 Postman으로 테스트 해보자.

 

● 정상 호출

http://localhost:8080/api/members/spring 과 같이 호출

{
    "memberId": "spring",
    "name": "hello spring"
}
 

● 예외 발생 호출

http://localhost:8080/api/members/ex

위와 같이 호출하게 되면, 이전에 WebServerCustomizer에 등록해논 ErrorPage 등록 때문에 다음 컨트롤러 가 실행되어 버린다.

@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response){
    log.info("errorPage 500");
    printErrorInfo(request);
    return "error-page/500";
}
 

따라서 HTML 페이지를 반환받게 된다.

이것은 기대하는 바가 아니다. 클라이언트는 정상 요청이든, 오류 요청이든 JSON이 반환되기를 기대한다.

컨트롤러를 수정하여 JSON을 반환하도록 만들자.

 

 ErrorPageController - API 응답 추가

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

이번 컨트롤러에는 다음과 같이 추가되었다.

produces = MediaType.APPLICATION_JSON_VALUE
 

이는 클라이언트의 RequestHeader 에 Accept가 application/json일때 해당 메서드가 호출된다는 것 이다.

클라이언트가 받아야 하는 데이터의 형식이 JSON 이라면 이 컨틀롤러가 실행된다.

 

응답 데이터를 위해서 Map 을 만들고 status , message 키에 값을 할당했다. Jackson 라이브러리는 Map 을 JSON 구조로 변환할 수 있다.

 

ResponseEntity 를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다.

 

다시 포스트맨으로 실행해보면 다음과 같은 결과를 얻을 수 있다.

{
    "message": "잘못된 사용자",
    "status": 500
}
 

2. API 예외 처리 - 스프링 부트 기본 오류 처리

스프링부트가 제공하는 기본 오류처리 방식을 이용해보자.

스프링 부트가 제공하는 BasicErrorController 코드를 보면 다음과 같은 부분을 볼수 있다.

 

● BasicErrorController 코드

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
 

BasicErrorController 는 자동으로 ErrorPage를 등록해 준다. 에러는 "/error"을 호출하게 된다.

 

- errorHtml() 의 경우 produces = MediaType.TEXT_HTML_VALUE 이다.

따라서 클라이언트 요청의 Accept가 text/html 인 경우에만 이 컨트롤러가 실행된다.

 

- error() 은 그외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환한다.

 

스프링 부트는 기본적으로 오류 발생시 "/error"로 오류 페이지를 요청한다.

"/error" 로 요청하면 이를 BasicErrorController가 받게 되고, 위에서 보여준 것 처럼 Client의 Accept 헤더에 따라 알맞은 메서드를 선택하여 호출하게 된다.

 

예를 들어 다음과 같이 Accept를 application/json으로 변경 한 후 요청을 해보자.

postman 으로 실행결과 다음과 같은 JSON을 받아볼수 있었다. 이는 BasicErrorController가 생성해준 객체이다.

스프링 부트가 제공하는 BasicErrorController 는 HTML 페이지를 제공하는 경우에는 매우 편리하다.

개발자가 정해진 폴더에 HTML파일을 만들어주기만 하면 4xx, 5xx 등등 모두 잘 알아서 처리해준다.

 

그런데 API 오류 처리는 다른 차원의 이야기이다. API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다.

따라서 API의 예외 처리는 바로 뒤에서 설명할 @ExceptionHandler 를 사용하자.

 

3. HandlerExceptionResolver 시작

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

우리는 발생하는 예외의 종류에 따라 상태코드를 다르게 처리해주고 싶다. 이를 해결해 보자.

 

● ApiExceptionController - 수정

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable String id){
        if(id.equals("ex")){
            throw new RuntimeException("잘못된 사용자");
        }
        if (id.equals("bad")){
            throw new IllegalArgumentException("잘못된 사용자 입력");
        }

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

    @Data
    @AllArgsConstructor
    static class MemberDto{
        private String memberId;
        private String name;
    }
}
 

http://localhost:8080/api/members/bad 라고 호출하면 IllegalArgumentException 이 발생하도록 만들었다.

 

아직은 실행해보면 상태코드는 500으로 보여준다.

 

● HandlerExceptionResolver

스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.

컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver 를 사용하면 된다.

출처 - 인프런 김영한 스프링 MVC2

HandlerExceptionResolver 인터페이스를 구현한 우리만의 ExceptionResolver를 만들어 보자.

 

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

try문 안에서 ModelAndView를 반환하고 있다. 이는 Exception을 처리해서 정상 흐름으로 변경하기 위함이다.

이름 그대로 Exception을 Resolve 하는것이 목적이다.

 

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

 

● 반환 값에 따른 동작 방식

- 빈 ModelAndView: new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.

- ModelAndView 지정: ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.

- null: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다.

만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

 

그럼 ExceptionResolver를 통해 어떤 장점을 얻을수 있을까?

1) 예외 상태 코드 변환 : 위에서 봤듯 상태코드를 변경할수 있다.

2) 뷰 템플릿 처리 : 비어있는 ModelAndView 가 아닌, 값이 들어있는 ModelAndView를 반환하면 뷰를 렌더링하여 보여준다.

3) API 응답 처리 : response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다.

 

마지막으로 WebConfig에 등록해주면 된다.

@Component
public class WebConfig implements WebMvcConfigurer {

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

http://localhost:8080/api/members/bad 로 실행해본 결과는 다음과 같다.

 

4. HandlerExceptionResolver 활용

예외 발생 과정이 너무 복잡하다. 컨트롤러에서 예외가 터지면, WAS 까지 전달되고, 다시 WAS 에서 오류 페이지 정보를 찾아서 /error을 호출하는 과정은 상당하게 번거롭다.

 

ExceptionResolver 를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다.

 

RuntimeException을 상속한 UserException이 있다고 해보자!

 

● ApiExceptionController - 예외 추가

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable 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;
    }
}
 

http://localhost:8080/api/members/user-ex 호출시 UserException 이 발생하게 된다.

이제 이 예외를 처리하는 UserHandlerExceptionResolver 를 만들어보자.

 

한가지 명심할 점은 이 방식을 사용하면 예외가 처리되어 WAS 로 response가 정상 전달된다. 이후 다시 컨트롤러를 호출하는일이 없다.

 

● 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으로 변환
                    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;
    }
}
 

acceptHeadr의 값을 확인하여 application/json 인 경우와 그외 text/html 인 경우로 나뉘게 된다.

 

JSON인 경우에는 Map에 ErrorClass와 에러의 메시지를 담아서 String으로 변환후 response로 보낸다.

HTML의 경우 "error/500" 페이지로 이동하게 된다.

 

포스트맨으로 실행해 확인해보면 두 방식모두 잘 작동함을 알수있다.

 

● 정리

ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를 처리해버린다.

따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이난다.

 

또한 새로운 ModelAndView를 반환하기 때문에 WAS 입장에서는 정상처리되었다고 생각한다.

댓글