예외 응답 처리 과정 Servlet VS Spring [API 예외 응답 편]

김용현·2024년 1월 31일
0

Spring

목록 보기
12/13

본 포스트는 김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 토대로 작성하였습니다.

이전 포스트에서 서블릿의 예외 페이지 처리 방식과 이를 자동으로 제공하는 Spring Boot의 방식에 대해 알아보았다.

이번 포스트에서는 API에 대한 예외 응답을 보내는 방식을 알아보자.

API 예외 처리

서블릿 오류 페이지 생성 방식

@Component
public class WebServerCustomizer implements
	 WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    
    @Override
     public void customize(ConfigurableWebServerFactory factory) {
         ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-
		 page/404");
         ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR,
		 "/error-page/500");
         ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-
		 page/500");
         factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
     }
}

다음과 같이 ErrorPage를 등록하여 특정 예외 발생 시 지정한 경로로 이동하도록 한다.
이후 해당 경로로 오는 요청을 처리할 컨트롤러를 구현하면 된다.

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

@RequestMapping의 produces에 요청 타입을 지정할 수 있다. 지금의 경우 application/json만 받도록 설정했다.

정리하면 이 방식은 서블릿이 예외 페이지를 처리하는 것처럼 동일한 과정으로 등록한 예외가 발생하면 특정 경로로 예외 페이지에 대한 요청을 보낸다.

달라진 점은 예외 페이지를 만드는 과정이 JSON 응답을 만드는 과정으로 바뀌었을 뿐이다.

Spring Boot 기본 예외 처리

스프링 부트에서 제공하는 BasicErrorController를 이용해서 API 예외 응답을 처리할 수 도 있다.

//BasicErrorController 코드
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
 public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse
 response) {}
 @RequestMapping
 public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}

실제 정의된 코드를 보면 errorHtml 함수가 요쳥이 text/html 일 경우 처리하고 있다.
또한 error은 이외의 요청에 대해 ResponseEntity 타입으로 응답을 내보내는 것을 알 수 있다.

하지만 이 방법은 잘 사용하지 않는다. 일단 이런게 있다 정도만 알아두자!

HandlerExceptionResolver

컨트롤러에서 예외가 발생하고 이를 밖으로 넘기게 되면 이 에러를 잡아서 해결을 시도하는 곳이 있다. 그게 바로 HandlerExceptionResolver 이다.

다음 그림을 통해 좀 더 이해해보자.
즉 중간에 예외를 해결할 기회를 한 번 더 제공하는 것이다.
사용하기 위해선,

public interface HandlerExceptionResolver {
   ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
    Object handler, Exception ex);
}

HandlerExceptionResolver 를 상속받는다.

package hello.exception.resolver;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.servlet.HandlerExceptionResolver;
 import org.springframework.web.servlet.ModelAndView;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 @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());
                 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();
             }
         } catch (IOException e) {
             log.error("resolver ex", e);
	}
         return null;
     }
}
/**
* 기본 설정을 유지하면서 추가 
*/
// WebMvcConfogurer에 아래 코드 추가
 @Override
 public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
     resolvers.add(new MyHandlerExceptionResolver());
}

예시 코드처럼 resolveException 함수를 오버라이딩 하여 해당 함수 안에서 Json 응답을 만들어 response에 추가하면 된다. 반환값으로는 빈 ModelAndView를 넘기면 되는데 이유는 바로 다음에서 알아보자.

반환 값에 따른 동작 방식

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

  • 빈 ModelAndView -> 뷰를 렌더링 하지 않고 정상 흐름으로 서블릿이 리턴된다.
  • ModelAndView 지정 -> 지정된 뷰를 렌더링한다.
  • null -> 다음 HandlerExceptionResolver 를 찾아 실행한다. 처리할 수 있는 Resolver가 없다면 그대로 예외를 서블릿 밖으로 넘긴다.

스프링이 제공하는 ExceptionResolver

스프링 부트에서는 기본적으로 제공하는 ExceptionResolver들이 있다.

HandlerExceptionResolverComposite 에 다음 순서로 등록된다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

하나씩 알아보자.

ResponseStatusExceptionResolver

예외에 따른 Http 상태 코드를 지정해주는 역할을 한다.

  • @ResponseStatus가 붙어있는 예외
  • ResponseStatusException 예외

다음 두가지를 처리하는 역할을 한다.

package hello.exception.exception;
 import org.springframework.http.HttpStatus;
 import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류") public class BadRequestException extends RuntimeException {
}

다음 예시와 같이 함수에 어노테이션으로 @ResponseStatus 가 붙어있다면 ResponseStatusExceptionResolver가 동작하여 상태 코드를 지정한 코드로 바꿔준다.

  • ResponseStatusExceptionResolver 코드 일부

내부적으로는 사실 response.sendError(statusCode, resolvedReason)을 호출하는 것이다. 따라서 sendError를 호출했기 때문에 WAS에서 다시 오류 페이지를 요청한다.

ResponseStatusException

이거는 그냥 예외이다. 즉 다음과 같이 사용할 수 있다.

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

이처럼 그냥 예외를 발생시키면 ResponseStatusExceptionResolver가 동작한다.

DefaultHandlerExceptionResolver

이 리졸버는 스프링 내부에서 발생하는 스프링 예외를 해결한다.

다음과 같이 스프링 내부에서 사용하는 예외들이 발생했을 경우 이를 해결하기 위해 만들어졌다.
실제로 다양한 예외를 잡아서 정상 흐름과 예외 응답을 만들어내는 것을 알 수 있다.

ExceptionHandlerExceptionResolver

드디어 마지막! 가장 많이 사용하고 대부분의 예외는 이걸로 처리한다고 봐도 무방한 @ExceptionHandler 처리 리졸버이다.

@ExceptionHandler(IllegalArgumentException.class)
 public ErrorResult illegalExHandle(IllegalArgumentException e) {
 log.error("[exceptionHandle] ex", e);
 return new ErrorResult("BAD", e.getMessage());
 }

다음과 같이 처리하고자 하는 예외에 대한 함수를 @ExceptionHandler(처리하고자 하는 예외 클래스) 를 붙여서 만들면 된다. 참고로 지정한 예외의 자식 클래스는 모두 잡을 수 있다.

@ExceptionHandler({AException.class, BException.class})
 public String ex(Exception e) {
     log.info("exception e", e);
 }

다음과 같이 여러가지 에러를 한 번에 잡을 수도 있다.

@ExceptionHandler
 public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

@ExceptionHandler에 처리할 예외를 생략해도 된다. 함수 인자에 들어간 예외가 자동으로 잡히게 된다.

참고 ❗️
@ExceptionHandler가 붙은 예외 처리 함수에서 받을 수 있는 인자가 정해져 있다.
이거때문에 사이드 프로젝트를 하며 꽤나 고생했던 기억이 있다,,,
정해지지 않은 인자가 들어오면 ExcetpionHandlerExceptionResolver에서 해당 함수를 호출하지 않는다.
처리 가능한 인자는 여기서 확인하자

진행 흐름

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
 public ErrorResult illegalExHandle(IllegalArgumentException e) {
     log.error("[exceptionHandle] ex", e);
     return new ErrorResult("BAD", e.getMessage());
 }

예를 들어 다음과 같은 예외 처리 함수가 등록되어 있다고 가정하자.
코드의 실행 흐름은 다음과 같다.

  1. 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.

  2. 예외가 발생했으로 ExceptionResolver 가 작동한다. 가장 우선순위가 높은
    ExceptionHandlerExceptionResolver 가 실행된다.

  3. ExceptionHandlerExceptionResolver 는 해당 컨트롤러에 IllegalArgumentException 을 처리 할 수 있는 @ExceptionHandler 가 있는지 확인한다.

  4. illegalExHandle() 를 실행한다.

  5. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.

  6. @ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드 400으로 응답한다.

위 과정과 같이 예외를 처리하는 방법이 훨씬 간단해졌다. 그러나 예외를 처리하는 @ExceptionHandler가 붙은 함수와 일반 컨트롤러 함수가 혼재하여 깔끔하지 못하다.

이에 스프링에서는 이를 분리할 방법을 제공한다.

@ControllerAdvice, @RestControllerAdvice

package hello.exception.exhandler.advice;
 import hello.exception.exception.UserException;
 import hello.exception.exhandler.ErrorResult;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 @Slf4j
 @RestControllerAdvice
 public class ExControllerAdvice {
     @ResponseStatus(HttpStatus.BAD_REQUEST)
     @ExceptionHandler(IllegalArgumentException.class)
     public ErrorResult illegalExHandle(IllegalArgumentException e) {
         log.error("[exceptionHandle] ex", e);
         return new ErrorResult("BAD", e.getMessage());
     }
     @ExceptionHandler
     public ResponseEntity<ErrorResult> userExHandle(UserException e) {
         log.error("[exceptionHandle] ex", e);
         ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
         return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
     @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
     @ExceptionHandler
     public ErrorResult exHandle(Exception e) {
         log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류"); }
}

@RestControllerAdvice와 @ControllerAdvice의 차이는 @RequestBody가 있나 없냐의 차이이다. 즉 @RestControllerAdvice는 API 예외 처리를 할 때 사용하면 된다.

위 예시처럼 @ExceptionHandler만 모아둔 클래스를 만들고 클래스에 @RestControllerAdvice만 붙이면 된다.

이후 예외가 발생하면 스프링 부트에서 @ControllerAdvice가 붙어있는 클래스에서 발생한 예외를 처리할 수 있는 함수가 있는지 찾는다.

@ControllerAdvice에 특정 패키지를 직접 적용할 수도 있다. 패키지를 적용하지 않으면 모든 컨트롤러에 대해 작동한다.

profile
평생 여행 다니는게 꿈 💭 👊 😁 🏋️‍♀️ 🦦 🔥

0개의 댓글

관련 채용 정보