API 예외처리

JIWOO YUN·2024년 3월 29일
0

SpringMVC2

목록 보기
25/26
post-custom-banner

API 예외처리

API 의 경우에는 각 오류 상황에 맞는 오류 응답 스펙을 정해서 JSON으로 내려줘야 오류를 대처할 수있다.

  • API 는 오류페이지 처럼 단순히 고객에게 오류화면을 보여주는걸로 끝나지 않기 때문에

api 예외 컨트롤러 추가

    @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;
    }
  • 정상 호출의 경우에는 api 설계대로 제대로 작동하게 되지만 , 예외 호출 발생시 우리가 만들어둔 오류 페이지 HTML 이 반환된다.
    • JSON이 반환되길 기대하고 있는데 HTML 이 반환되는 상황이 발생.
    • 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정을 진행.

ErrorPageController에 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));

    }
MediaType.APPLICATION_JSON_VALUE
-> HttpHeader의 Accept 값이 application/json 일때 해당 메서드 호출

Json 형식이 key : value 로 이루어져 있기 때문에 Map을 통해서 값을 할당해준다.

  • Jackson 라이브러리가 Map을 JSon 구조로 변환하는게 가능.
  • responseEntity를 사용해서 응답하기 때문에 메시지 컨버터가 동작 -> 클라이언트에 JSON을 반환

HandlerExceptionResolver

  • 예외가 발생해서 서블릿을 넘어 WAS 까지 예외가 전달되면 HTTP 상태코드가 500 으로 처리된다.
    • 서버에서 예외가 발생했기 떄문에 500 에러가 발생함.
    • 발생하는 예외에 따라서 400,404 등등 다른 상태코드로 처리하고 싶을때 사용됨.

IllegalArgumentException 을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태코드를 400으로 처리하고 싶다.

  • 스프링 MVC 는 컨트롤러 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공 -> 컨트롤러 밖으로 던져진 예외를 해결하고 동작방식을 변경하고 싶다면 HandlerExceptionResolver를 사용하자.

ExceptionResolver 적용전

  • 예외 발생시
  • DispatcherServlet -> prehandle -> 핸들러 어댑터 -> 컨트롤러(예외발생) -> 예외가 전달 -> afterCompletion -> WAS 에 예외 전달
    • postHandle이 호출되지 않음.

적용 후

  • DispatcherServlet -> prehandle -> 핸들러 어댑터 -> 컨트롤러(예외발생) -> 예외 전달 -> ExceptionResolver(예외 해결 시도)
    • 예외 해결 -> render 호출 -> afterCompletion -> 정상응답
      • postHandle은 예외가 해결되도 발생하지않는다.
@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;

    }

ExceptionResolver가 ModelAndView를 반환하는 이유

  • try,catch 하듯이 Exception을 처리해서 정상 흐름처럼 변경하는것이 목적이기 때문에.
    • Exception을 resolver하는 것이 목적
  • IllegalArgumentException 으로 오는 경우 이걸 먹어버리고 sendError(400) 을보냄으로써 Http 상태코드를 400으로 지정하고 ModelAndView를 반환.

반환 값에 따른 동작 방식

  • 빈 ModelAndView : new ModelAndView() 처럼 빈 ModelAndView를 반환하면 뷰를 렌더링 하지 않고 ,정상 흐름으로 서블릿이 리턴
  • ModelAndView 지정 : ModelAndView에 view,Model등의 정보를 지정해서 반환하면 뷰를 렌더링한다.
  • null : null 반환시, 다음 ExceptionResolver를 찾아서 실행 -> 처리할 수 있는 ExceptionResolver가 없으면 예외처리가 안되고 , 기존에 발생한 예외를 서블릿 밖으로 던짐.
    • ExceptionResolver를 여러개 등록하는게 가능하기 때문에 맞는걸 찾아서 실행하게됨.

ExceptionResolver 활용

  • 예외 상태 코드 변환
    • 예외를 response.sendError(XXX) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임한다.
    • 이후 WAS 서블릿 오류 페이지를 찾아서 내부 호출
  • 뷰 템플릿 처리
    • ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링해서 고객에 제공.
  • API 응답처리
    • response.getWriter().println(); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능.
      • JSON 응답시 API 처리 가능.
WebConfing 를 통해서 등록 진행
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new MyHandlerExceptionResolver());
}

configureHandlerExceptionResolvers 사용시 스프링이 기본으로 등록하는 ExceptionResolver가 제거되기 때문에 extend를 사용해서 등록해줘야함.

API 예외처리 - HandlerExceptionResolver 활용

  • ExceptionResolver를 사용해서 예외 발생시 /error를 호출하지 않고 여기서 처리가 가능해진다.

사용자 정의 예외 추가

public class UserException extends RuntimeException{

    public UserException() {
    }

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

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

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

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

ApiExceptionController 에 사용자 오류 추가

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

아직 예외처리를 등록하지 않았기 때문에 500오류가 발생

  • 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 {
                    return new ModelAndView("error/500");
                }
            }

        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}
  • Accept 값이 application/json 인 경우 JSON으로 오류를 내주고 아닌경우 error/500 에 있는 html 오류 페이지를 보여준다.

  • ModelAndView를 반환해야하기 때문에 response에 contentype과 encoding등을 다 넣어줘야한다.

  • objectMapper.writeValueAsString(errorResult);

    • 객체를 문자열로 변경해준다.
    • 이 값을 response에 넣어줌으로써 json형식으로 반환해줄 수 있게된다.

webConfig에 resolver를 추가해줌으로써 적용되게

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

스프링부트가 제공하는 ExceptionResolver

기본적으로 3가지가 등록되어있다.

  1. ExceptionHandlerExceptionResolver
    • @ExceptionHandler 를 처리한다.
    • API 예외 처리는 대부분 이 기능으로 해결
  2. ResponseStatusExceptionResolver
    • HTTP 상태 코드를 지정
  3. DefaultHandlerExceptionResolver -> 우선순위 가장 낮음.
    • 스프링 내부 기본 예외 처리

첫번째 처리가 안되면 두번째 가 처리 ,마찬가지로 두번째도 처리 못하면 마지막인 세번째가 처리

ResponseStatusExceptionResolver

  • 예외에 따라서 HTTP 상태코드를 지정해주는 역할

2가지 경우를 처리

  • @ResponseStatus 가 달려있는 예외
  • ResponseStatusException 예외

@ResponseStatus 어노테이션 적용

@ResponseStatus(code = HttpStatus.BAD_REQUEST,reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
    
}

-> BadRequsetException 예외가 컨트롤러 밖으로 넘어가게 되면 ResponseStatusExceptionResolver 예외가 해당 어노테이션을 발견하여 오류코드를 BAD_Request(400)으로 변경하고 메시지도 담아준다.

  • 전에 만들어본 UserException을 처리할 때 만들었던 것을 스프링이 만들어주는 것.

확인용 api 컨트롤러에 추가

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1(){
    throw new BadRequestException();
}

postMan을 통해서 제대로 작동하는 지 체크

  • 여기서 Exception 과 message가 안나오는 상황이 발생해서 뭘 잘못했는지 찾아보니 오류 정보 포함 여부를 yml 파일 설정에 추가를 해줘야 보이는걸 알게 되었다.
server:
  error:
    include-message: always
    include-exception: true
    whitelabel:
      enabled: false

include-exception : exception을 포함해서 보여줄 것인지

include-message : message를 포함해서 보여줄 것인지

whitelabel의 경우 오류 처리 화면을 못찾으면 스프링 whitelavel 페이지를 적용할것인지 체크하는 항목으로 현재는 false로 사용하지 않는 걸로 해놧다.

protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response)
      throws IOException {

   if (!StringUtils.hasLength(reason)) {
      response.sendError(statusCode);
   }
   else {
      String resolvedReason = (this.messageSource != null ?
            this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
            reason);
      response.sendError(statusCode, resolvedReason);
   }
   return new ModelAndView();
}
  • 이 resolver가 해주는게 결국 우리가 전에 만들어서 했던 reosolver랑 같이 response.sendError(statuscode,reson)을 호출한다는 것을 확인 해볼 수있다.
  • 내부를 쭉 따라 들어가다보면 마찬가지로 response.sendError를 호출해서 처리해준다.
    • sendError 를 호출했기 때문에 WAS 에서 다시 오류페이지(/error)를 내부 요청한다.
reason을 MessageSource에서 찾는 기능도 제공
  • properties에 공통적으로 사용되는 오류메시지를 넣어놓고 사용하는 게 가능하다.

Message.properties에 공통적으로 사용할 메시지 문구를 적어둔다.

error.bad=잘못된 요청 오류입니다.
  • 여기서 한글 깨짐이 발생할 수있으니 Editor에 fileEncoding이 UTF-8 인지 꼭 확인하자.

    • 한글을 지원하지 않게되면 ??로 도배되는 끔찍한 내용이 message에 포함되게 된다!

@ResponseStatus 의 경우 개발자가 직접 변경할 수 없는 예외에는 적용 할 수 없다.

  • 애노테이션을 직접 넣어야하기 때문에 내가 코드를 수정할 수없는 곳의 예외 코드 같은 곳은 사용 불가능.

  • 애노테이션이기 때문에 동적으로 사용이 불가능하다.

이런 두가지 경우에는 ResponseStatusException 예외를 사용해야 한다.

ResponseStatusException 사용 코드

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2(){
    throw new ResponseStatusException(HttpStatus.NOT_FOUND,"error.bad",new IllegalArgumentException());
}

DefaultHandlerExceptionResolver

  • 파라미터 바인딩 시점에 타입이 맞지않으면 TypeMissmatchException 발생
    • 예외가 발생했기 때문에 그냥 두게 될 될 경우 서블릿 컨테이너까지 올라가서 500 에러가 발생하게된다.
      • 대부분 파라미터 바인딩 오류의 경우 클라이언트가 잘못 호출해서 발생하는 문제기 때문에 이걸 400에러로 바꿔주는 역할 해주는게 DefaultHandlerExceptionResolver 다.

실험용 코드

@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam("data") Integer data){
    return "ok";
}
  • postMan을 통해서 data에 hello을 채워서 보내는 경우 파라미터 타입 오류기 때문에 400 에러가 발생.

profile
열심히하자
post-custom-banner

0개의 댓글