API 예외처리하기

허정현·2024년 10월 10일

Spring boot

목록 보기
1/4

목표

  • API 예외를 처리할 때 JSON으로 응답 스펙을 정의할 수 있다.
  • HTML 오류 페이지 처리, JSON형식의 API 예외 처리를 수행

HTTP

화면과 달리 오류 응답 스펙을 정의해야하고, JSON으로 데이터를 처리해야함.

1XX: Informational(정보 제공)

임시 응답으로 현재 클라이언트의 요청까지는 완료, 계속 진행하라는 의미

2XX: Success(성공)

클라이언트의 요청이 서버에서 성공적으로 처리되었다는 의미

3XX: Redirection(리다이렉션)

완전한 처리를 위해서 추가 동작이 필요한 경우, 주로 서버의 주소 또는 요청한 URI의 웹 문서가 이동되었으니 그 주소로 다시 시도하라는 의미

4XX: Client Error(클라이언트 에러)

없는 페이지를 요청하는 등 클라이언트의 요청 메시지 내용이 잘못된 경우를 의미

5XX: Server Error(서버 에러)

서버 사정으로 메시지 처리에 문제가 발생한 경우로, 서버의 부하, DB 처리 과정 오류, 서버에서 익셉션이 발생하는 경우를 의미

📌 기본적인 예외처리

@GetMapping(("/api/members/{id}"))
    public MemberDTO getMember(@PathVariable("id") String id) {
        if (id.equals("RuntimeExcecption")) {
            throw new RuntimeException();
        }
        return new MemberDTO(id, "welcome to " + id); }

성공시 다음과 같은 정상적인 JSON형태의 데이터가 처리됨.
또한 실패시 기존의 WebServerCustomizer 나, 기본 Spring의 오류 제공 기능 덕분에 templates/error/경로에 오류 페이지 HTML이 있다면, 해당 오류 화면을 제공해준다.
하지만, API에서 우리가 클라이언트에 처리해줘야 할 데이터는 JSON 형태의 값이므로 이것을 공부한다.

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

코드를 보면 Request= /error-page/500, produces가 JSON으로 되어 있다. 즉 클라이언트에서 요청하는 Accept의 값이 application/json이면 해당 메서드가 호출되는 것으로 API 통신일 경우 처리 되는 컨트롤러다.위의 작성된 코드를 보면 RuntimeException이라는 아이디 값이 입력된 경우 예외를 던진다. 해당 RuntimeException이 발생하면 ErrorPageerrorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500"); 에러 페이지로 등록해둔 이 URL의 경로로 호출하게 되는데, 바로 이때, 에러 페이지 컨트롤러에서 작성한produces=MediaType.APPLICATION_JSON_VALUE 가 있는 컨트롤러를 통해 해당 요청이 JSON 인걸 파악하고 이 컨트롤러가 호출된다. 그러면 클라이언트에게 HTML이 아닌 JSON 형태로 응답을 처리할 수 있게 된다.

📌 BasicErrorController의 스프링 부트 기본 오류 처리

@RequestMapping(produces=Media.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
@RequestMapping
public ResponseEntity<Map<String,Object>> error(HttpServletRequest request) {} 
  • errorHtml() : produces = MediaType.TEXT_HTML_VALUE : 클라이언트 요청의 Accept 해더 값이 text/html 인 경우에는 errorHtml() 을 호출해서 view를 제공한다.
  • error() : 그외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환한다.
    BasicErrorController를 사용하면, 기본적으로 예외발생시 페이지를 처리해주는데
    이 때의 경로인templates/error를 기본으로 받음.
  • BasicErrorController 자체에서 오류 HTML 페이지를 제공하는 경우는 매우 편리하지만, API 오류는 회원, 상품등의 관련된 API스펙을 정의하기도 까다롭기 때문에, @ExceptionHandler를 사용하는 것이 좋음.

📌 직접 작성 HandlerExceptionResolver

  • 발생하는 예외에 따라 다른 상태코드로 처리하기 위함
  • 오류 메시지나 형식을 API마다 다르게 처리하기 위함.
public interface HandlerExceptionResolver {
   ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
    Object handler, Exception ex);
}

handler : 핸들러(컨트롤러) 정보
Exception ex : 핸들러(컨트롤러)에서 발생한 발생한 예외

다음 코드를 보자

@RestController
@Slf4j
public class ExceptionController {
    @GetMapping("/controller/{id}")
    public MemberDTO getMember(@PathVariable("id") String id) {
        if (id.equals("wrongUser")) {
            throw new RuntimeException("잘못된 사용자");
        }
        if (id.equals("Types")) {
            throw new IllegalArgumentException("잘못 입력된 값 ");
        }
        if (id.equals("error")) {
            throw new UserException("사용자 오류 ");
        }
        return new MemberDTO(id, "welcome to" + id);
    }

해당 컨트롤러에서 오류를 설정해놨지만, 당연하게도 스프링 부트 기본 설정에 의해 /templates/error로 들어가 오류 페이지를 찾아서 제공했다. 하지만, 이 통신에서는 JSON으로 오류를 통신받기를 원하기 때문에 UserException 사용자 오류에 대한 핸들러를 작성해야한다.

@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/400");
                 }
             }
         } catch (IOException e) {
             log.error("resolver ex", e);
         }
         return null;
     }
}
  • WebConfig에 UserHandlerExceptionResolver 추가
 @Override
 public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver>
 resolvers) {
     resolvers.add(new MyHandlerExceptionResolver());
     resolvers.add(new UserHandlerExceptionResolver());
 }

결과를 보면 다음과 같이 JSON형태로 codemessage를 처리함.

  • 결론을 보면, ExceptionResolver에서 예외를 처리해버리기 때문에, 서블릿 컨테이너까지 예외가 전달되지 않아 불필요한 추가 프로세스를 진행하지 않고, Spring MVC자체에서 예외처리가 끝나버림.

📌 Spring boot ExceptionResolver

  • Spring boot가 기본 제공하는 ExceptionResolver
  • ExceptionHandlerExceptionResolver
    @ExceptionHandler를 처리함.
  • ResponseStatusExceptionResolver
    HTTP 상태 코드를 지정
  • DefaultHandlerExceptionResolver
    스프링 내부 기본 예외를 처리

ResponseStatusExceptionResolver

  • @ResponseStatus

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

이렇게 어노테이션을 사용하여 해당 코드의 Exception을 지정해두면됨.

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

@GetMapping을 통해 예외가 던져지면, 해당 ResponseStatusExceptionResolver에서 ResponseStatus어노테이션을 찾아 해당 예외를 처리함.
추가로 messages.properties에 에러를 작성하고, reason = "error.bad"등의 코드를 작성하면, 메시지소스를 찾아 제공해주는 기능도 있다.

  • @ResponseStatusException

@GetMapping("~~~")
public String responseStatus2(){
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
 IllegalArgumentException()); }

위의 ResponseStatusException의 HttpStatus와 메시지, 예외를 호출하여 조건에 따라 예외를 변경하고 처리해줄 수도 있음.

DefalutHandlerExceptionResolver

  • 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생. 결과적으로 500오류가 발생한다.
  • 오류가 발생하면, 클라이언트가 파라미터에 타입을 잘못 입력한 값이므로, 500오류가 발생하여, 서블릿 컨테이너까지 오류가 전달됨.
    문제는 서블릿 컨테이너까지 오류가 전달되면 다시 추가적인 프로세스가 늘어나면서 처리에 있어서 소요가 크다. 따라서,DefalutHandlerExceptionResolver 는 HTTP 상태 코드 400오류로 변경해준다.
  • 결국 response.sendError() 를 통해서 문제를 해결한다.
    sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다

@ExceptionHandler

  • 우선, ExceptionHandler는 restful API에서 예외처리를 할 때 위의 코드들보다 훨씬 편리함.
  • 컨트롤러 클래스에 대해 지정해서 관리도 가능.
  • 동일한 예외 처리 로직을 여러 컨트롤러에서 재사용할 수 있으며,@ControllerAdvice와 함께 사용하여, 애플리케이션 전역에서 공통된 예외 처리 방식을 정의할 수 있어 일관성을 유지
  • 오류를 관리할 때도 개별적으로 상태코드, 응답 메시지, 헤더등을 자유롭게 설정할 수 있음.

코드

예외 발생시 API 응답 객체를 따로 만들어줬을 경우

 @Data
 @AllArgsConstructor
 public class ErrorResult {
     private String code;
     private String message;
 }
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
	log.error("[exceptionHandle] ex", e);
	return new ErrorResult("BAD", e.getMessage());
     }
  • 기본적으로,JSON형태로 반환해주기 때문에 편리하다.
  • 서블릿에 예외가 도달하지 않기 때문에, 예외를 컨트롤러에서 처리하고 정상흐름으로 200이 나오기 때문에, @ResponseStatus를 통해 예외 상태 코드를 바꿔준다. (어찌됐건, 오류기 때문)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
    log.error("[exceptionHandle] ex", e);
	return new ErrorResult("EX", "내부 오류"); }
  • 위의 코드는 다른 예외 코드를 작성했는데도 불구하고, 예외를 처리하지 못했을 경우 최상위인 Exception에서 예외를 다 잡아버려서 처리해준다.
    public ErrorResult exHandle(Exception e)
    @ExceptionHandler(Exception.class) 로 지정해줘도 OK

ControllerAdvice

  • @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에
  • @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 한다.
  • @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
  • @RestControllerAdvice@ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다.
  • @Controller , @RestController 의 차이와 같다.

코드

@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api") 
public class ExControllerAdvice {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }
    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity(errorResult, HttpStatus.BAD_REQUEST);
    }

지정 방법

 // Target all Controllers annotated with @RestController
 @ControllerAdvice(annotations = RestController.class)
 public class ExampleAdvice1 {}
 // Target all Controllers within specific packages
 @ControllerAdvice("org.example.controllers")
 public class ExampleAdvice2 {}
 // Target all Controllers assignable to specific classes
 @ControllerAdvice(assignableTypes = {ControllerInterface.class,
 AbstractController.class})
public class ExampleAdvice3 {}

프로젝트에서 예외처리를 일일이 해줬던 과거의 나를 반성합니다.

profile
지식 정리를 잘 할 수 있을 때까지

0개의 댓글