Spring API Exception

강정우·2024년 1월 2일
0

Spring-boot

목록 보기
48/73

서블릿으로 API 예외처리하기

  • 굉장히 기본적으로 ApiExceptionController를 만들어보자.

ApiExceptionController

@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;
    }
}
  • 그리고 이제 http://localhost:8080/api/members/ex 이 url로 요청을 보내면 json으로 응답이 오는 것이 아닌 앞서 작성했던 WebServerCustomizer에 의하여 500 error page가 보이는 것을 확인할 수 있다. 이것을 이제 html이 아닌 json을 표출하도록 만들어보자.

ErrorPageController.java

@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로 html인지 아니면 application/json인지 선택할 수 있다.
    그래서 사용자 req가 applicaion/json이면 해당 함수가 우선적으로 실행된다.
  • 참고로 저기서 MediaType은 spring꺼를 import 해줘야한다.

spring boot로 예외 처리하기

BasicErrorController

바로 앞서 배웠던 BasicErrorController이다. 보면 기본 경로로 /error로 잡혀있다. 또한 @RequestMapping, produces = {"text/html"} 로 설정이 되어있다.
또한 return type이 ModelAndView로 되어있어서 View를 찾는다.
그리고 그 이외의 경우에는 ResponseEntity를 사용하여 http message body에 담아서 알아서 보내주기 때문에 따로 코드를 작성할 필요가 없다.

또한 application.properties에 옵션을 추가할 수 있다.

Html 페이지 vs API 오류

BasicErrorController 를 확장하면 JSON 메시지도 변경할 수 있다. 그런데 API 오류는 조금 뒤에 설명할 @ExceptionHandler 가 제공하는 기능을 사용하는 것이 더 나은 방법이므로 지금은 BasicErrorController를 확장해서 JSON 오류 메시지를 변경할 수 있다 정도로만 이해해두자.

스프링 부트가 제공하는 BasicErrorController 는 HTML 페이지를 제공하는 경우에는 매우 편리하다. 4xx, 5xx 등등 모두 잘 처리해준다.
그런데 API 오류 처리는 다른 차원의 이야기이다. API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다. 예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있다. 결과적으로 매우 세밀하고 복잡하다. 따라서 이 방법은 HTML 화면을 처리할 때 사용하고, API 오류 처리는 뒤에서 설명할 @ExceptionHandler 를 사용하자

HandlerExceptionResolver - 개념

@Slf4j
@RestController
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("잘못된 입력 값");
        }
        return new MemberDto(id, "hello " + id);
    }
    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}
  • 만약 이런 코드에서 사용자가 파라미터로 bad를 넘기면 바로 500 error가 난다. 왜냐하면 서버 내부에서 throw new Exception을 던졌기 때문이다.
    하지만 우리는 이게 400 bad-request 에러로 보내주고 싶다 왜냐하면 잘못된 argument니까.
    그래서 이런 코드를 변경할 수 있도록 해주는 것이 바로 HandlerExceptionResolver이다.

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

HandlerExceptionResolver - 인터페이스

public interface HandlerExceptionResolver {
 ModelAndView resolveException(
 HttpServletRequest request, HttpServletResponse response,
 Object handler, Exception ex);
}
  • handler : 핸들러(컨트롤러) 정보
  • Exception ex : 핸들러(컨트롤러)에서 발생한 발생한 예외

HandlerExceptionResolver - 구현 및 등록

resolver/MyHandlerExceptionResolver.java

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;
    }
}
  • 보면 빈 ModelAndView가 반환이 되는데 얘로 인하여 에러를 먹어버린다.
    그리고 sendError 메서드는 그대로 동작하여 오류페이지를 뒤지며 지정한 에러를 내뿜어주는 것이다.
    즉, ModelAndView 를 반환하는 이유는 마치 try, catch를 하듯이, Exception 을 처리해서 정상 흐름 처럼 변경하는 것이 목적이다. 이름 그대로 Exception 을 Resolver(해결)하는 것이 목적이다.

반환 값에 따른 동작 방식

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

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

HandlerExceptionResolver 활용

  • 예외 상태 코드 변환
    예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임.
    이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어서 스프링 부트가 기본으로 설정한 /error 가 호출됨
  • 뷰 템플릿 처리
    ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
  • API 응답 처리
    response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다.
    여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다.

WebConfig에 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
    }

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
    ...
  • 참고로 위 코드 처럼 extendHandlerExceptionResolvers를 오버라이드 해서 등록할 수 있고 configureHandlerExceptionResolvers를 등록할 수 있는데 이거를 사용하면 스프링이 기본으로 등록하는 ExceptionResolver 가 제거되므로 주의하자.

ExceptionResolver - 활용

문제점

  • 예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error 를 호출하는 과정은 생각해보면 너무 복잡하다. ExceptionResolver 를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다.

Exception 작성

UserException.java

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);
    }
}
  • RuntimeException를 상속받아 값들을 overide해주고
@RestController
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);
    }
    ...
  • 이번에도 간단하게 사용자 에러를 낼 수 있도록 추가해주고
@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 (acceptHeader.equals("application/json")) {
                    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;
    }
}
  • 여기서 response 객체의 write로 그냥 서블릿 컨테이너로 가서 끝인것이다.
    다시 WAS까지 올라가고 이런게 아니라
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new MyHandlerExceptionResolver());
    resolvers.add(new UserHandlerExceptionResolver());
}
  • WebConfig에 이렇게 추가해주면 된다.

정리

ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를 처리해버린다.
따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이 난다.
결과적으로 WAS 입장에서는 정상 처리가 된 것이다. 이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다.
서블릿 컨테이너까지 예외가 올라가면 복잡하고 지저분하게 추가 프로세스가 실행된다. 반면에 ExceptionResolver 를 사용하면 예외처리가 상당히 깔끔해진다.
그런데 직접 ExceptionResolver 를 구현하려고 하니 상당히 복잡하다. 지금 위에 코드를 보면 API 오류 1개를 처리하는데 드는 코드의 작업양이 어마어마하다.
그래서 다음엔 더 깔끔하게 오류처리를 할 수 있는 스프링이 제공하는 ExceptionResolver에 대해 포스팅하겠다.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글