Spring 기반 애플리케이션에서 예외를 핸들링하는 전략들에 대해 알아보고자 한다.
Servlet 기반 예외 처리
서블릿 기반 예외 처리는 발생한 예외를 WAS(Tomcat) 까지 전달한다. 그 이후 매핑된 예외 종류에 따라 Dispatcher Servlet에 다시 요청하며 예외를 핸들링한다. (forward와 비슷한 개념)
@Component
public class WebServerCustomizer implements
WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
List<ErrorPage> errorPages = List.of(
new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"),
new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500"),
new ErrorPage(RuntimeException.class, "/error-page/500")
);
errorPages.forEach(factory::addErrorPages);
}
}
위의 코드처럼 예외와 예외를 핸들링할 url을 매핑해두면 WAS에서 이를 확인하고 Dispatcher Serlvet으로 매핑된 url을 다시 요청한다.
처리 프로세스
Dispatcher Servlet 에서 발생한 예외는 요청과 동일하게 여러 요소들을 거쳐 WAS까지 전달되고 WAS는 매핑된 정보에 따라 다시 여러 요소들을 거쳐 Dispathcer Servlet에게 요청한다.
미리 만들어 둔 필터와 인터셉터를 통해 확인해본 결과 아래와 같이 필터와 인터셉터를 다시 거쳐 예외 페이지가 요청됨을 확인할 수 있다.
문제점
이러한 예외 처리 방식은 오류 페이지 재 요청시 Servlet Filter 와 Spring Interceptor를 다시 거쳐야 한다는 문제점이 존재한다. 매 요청마다 로그인 정보를 체크하는 필터 혹은 인터셉터를 구현했다고 가정했을 때, 이미 검증된 사용자의 요청이 다시 로그인 체크 필터 혹은 인터셉터에서 동작을 수행하는 과정에서 오버헤드가 발생한다.
문제점 해결 방안 - Servlet Filter
결국 클라이언트에서의 요청인지, WAS에서 내부적으로 호출한 요청인지 구분할 수 있어야 한다. Servlet은 이러한 문제를 해결하기 위해 DispatherType이라는 추가 정보를 제공한다.
DispatherType은 FORWARD, INCLUDE, REQUEST(클라이언트 요청), ASYNC(서블릿 비동기 호출), ERROR(오류 요청) 값이 존재한다.
Filter에서 DispatcherType을 통해 요청 정보를 필터링하여 동작하게끔 설정할 수 있다.
@Bean
public FilterRegistrationBean loginCheckFilter() {
// ...
// 기본 설정은 REQUEST
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
return filterRegistrationBean;
}
문제점 해결 방안 - Spring Interceptor
Spring Interceptor도 Servlet Filter와 동일하게 DispatcherType 정보를 제공 받으나 DispatcherType 으로 Requset 를 필터링하는 기능은 제공하지 않는다.
따라서 Spring Interceptor의 강력한 기능인 excludePathPatterns 를 사용하여 해결한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error", "/error-page/**");
}
2. Spring Boot 기반 예외 처리 - View
Spring Boot는 /error 라는 경로에 ErrorPage를 자동으로 등록하며, BasicErrorController를 등록하여 해당 url의 요청을 자동으로 처리한다.
3. API 예외 핸들링
json으로 통신하는 API 서버에서 예외 발생 시 위의 예시들처럼 html 파일을 랜더링해서 보여주면 안된다. API 서버는 예외 정보를 json 형태로 응답해야 하는데 해당 방법으로 예외를 핸들링하는 예시를 들어보려 한다.
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> error500Api(HttpServletRequest request) {
//..
return new ResponseEntity();
}
유의할 점은 ErrorPage 매핑이 선행되어야 하고, HTTP Request Header의 Accept 값이 Application/json 이어야 정상적으로 작동한다는 것이다. 만약 ErrorPage 매핑이 선행되지 않았다면, BasicErrorController의 처리 로직을 따른다.
HandlerExceptionResolver
기본적으로 서버에서 예외가 발생하여 WAS까지 전달되면 statusCode는 500이다. 400, 401, 403과 같은 더 다양한 statusCode 처리하고 싶을 때, HandlerExceptionResolver(이하 ExceptionHandler)를 사용하면 된다.
public interface HandlerExceptionResolver {
@Nullable
ModelAndView resolveException(
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
}
ExceptionHandler는 resolveException 이라는 추상 메서드를 가지는 Functional Interface이다. DispatcherServlet에서 발생한 예외를 해결하거나, 예외에 대한 동작을 새로 정의하는 방법을 제공한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add((request, response, handler, ex) -> {
try {
if (ex instanceof IllegalArgumentException) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView(); // 1
}
} catch (IOException ignored) { }
return null; // 2
});
}
}
HandlerExceptionResolver는 Functional Interface이기에 위의 코드처럼 람다로 구현할 수 있다.
- 빈 ModelAndView를 반환할 시, 뷰를 렌더링하지 않는다.
- null을 반환할 시, 다음 ExceptionResolver를 호출하고 예외를 처리하지 못한 경우 기존에 발생한 예외를 WAS로 전달한다.
HandlerExceptionResolver 개선
이러한 방법은 Servlet 기반 예외 핸들링처럼 WAS까지 예외가 전달되고 다시 요청하여 Servlet Filter와 Spring Interceptor가 호출되어야 한다는 단점이 존재하는데 이를 해결해보자.
@Slf4j
@RequiredArgsConstructor
@Component
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
private final ObjectMapper objectMapper;
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
try {
if (ex instanceof UserException) {
log.info("UserException resolver to 400");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
String acceptHeader = request.getHeader(HttpHeaders.ACCEPT);
if (MediaType.APPLICATION_JSON_VALUE.equals(acceptHeader)) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
response.getWriter().write(objectMapper.writeValueAsString(errorResult));
return new ModelAndView();
}
return new ModelAndView("error-page/500");
}
} catch (IOException exception) {
log.error("resolver exception", exception);
}
return null;
}
}
Dispatcher Servlet에서 UserExceptiond이 발생하여 UserHandlerExceptionResolver가 catch했을 때, Accept가 JSON이라면 뷰를 랜더링하지 않고 HTTP Response 객체를 반환한다. response.sendError()를 호출하던 기존의 방식을 변경했다.
문제점
보일러 플레이트 코드를 반복해서 작성해야 한다는 단점과 API서버에서는 사용하지도 않을 ModelAndView 객체를 매번 생성해야 한다는 단점이 존재한다. 즉, 개발자의 실수로 서버에 오류가 발생할 확률이 올라간다. 이러한 문제점은 Srping에서 제공하는 ExceptionResolver를 사용하여 해결할 수 있다.
// ResonseStatusExceptionResolver 사용
// 단점: 사용자가 변경할 수 없는 예외에는 적용할 수 없음, 조건에 따라 동적으로 변경하기 어려움
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
4. API 예외 핸들링 - @ExceptionHadler
ExceptionHadler를 사용하면 예외 응답 코드를 신경쓸 필요가 없어진다. 또한 ResonseStatusExceptionResolver의 변경할 수 없는 예외에 적용할 수 없다는 단점도 보완한다.
@RestController
public class ErrorHandleController {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult catchIllegalArgumentException(IllegalArgumentException exception) {
return new ErrorResult(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class, //... )
public ErrorResult catchIllegalArgumentException(IllegalArgumentException exception) {
return new ErrorResult(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@Data
@AllArgsConstructor
static class ErrorResult {
private HttpStatusCode code;
private String message;
}
}
위의 코드를 @RestControllerAdvice 어노테이션을 사용하여 깔끔하게 Contoller 코드와 분리할 수 있다. 또한 대상 Controller도 적용할 수 있다.
'Spring' 카테고리의 다른 글
주문관리 애플리케이션 S.A (1) | 2024.11.07 |
---|---|
Spring Interceptor vs Servlet Filter (1) | 2024.03.05 |
SLF4J 와 Log Level 간단 정리 (0) | 2024.03.03 |
[Spring Boot] JPA - MySql 연결 (0) | 2023.03.02 |
[Spring Boot] ThymeLeaf 템플릿 사용 (0) | 2023.03.02 |