로그인이라는 공통 관심사 로직을 처리하기 위해 서블릿 필터를 만들어 HTTP 세션을 확인하는 로직을 작성했다. 이 과정에서 세션 값이 없으면 인증이 필요하다는 RuntimeException을 상속받은 커스텀 예외를 던지도록 했다.
그러나 응답값을 확인해 보니 500 에러가 발생했다. 콘솔 로그에는 내가 원하는 예외 메시지가 출력되었지만, 클라이언트에는 제대로 전달되지 않았다.
예외를 핸들링하는 가장 큰 이유는 클라이언트에게 어디서 어떤 이유로 잘못되었는지 명확하게 전달하기 위함이다. 그런데 예외가 제대로 처리되지 않으면 의미 있는 예외 처리가 아니다.
@RestControllerAdvice
public class GlobalExceptionHanlder {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponseDto> customException(CustomException ex){
return new ResponseEntity<>(new ErrorResponseDto(ex), ex.getHttpStatus());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponseDto> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
ErrorResponseDto dto = new ErrorResponseDto(errors.toString(), ErrorCode.INVALID_INPUT_VALUE.getHttpStatus(), ErrorCode.INVALID_INPUT_VALUE.getCode());
return new ResponseEntity<>(dto, dto.getHttpStatus());
}
}
Spring MVC의 @RestControllerAdvice를 사용하여 전역적으로 발생하는 예외를 처리하도록 설정했다. 그러나 필터 내부에서 발생한 예외는 @ControllerAdvice에서 처리되지 않는다.

Spring의 @ControllerAdvice는 DispatcherServlet 내부에서 발생하는 예외만 잡을 수 있다. 하지만 필터에서 발생한 예외는 DispatcherServlet이 실행되기 전에 발생하기 때문에 @ControllerAdvice가 예외를 잡지 못하는 것이다.
따라서 필터 내부에서 발생한 예외는 따로 처리해 주어야 한다.
private void sendErrorResponse(HttpServletResponse response, MyException ex) throws IOException {
response.reset();
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(ex.getHttpStatus().value());
ErrorResponseDto errorResponse = new ErrorResponseDto(ex);
ObjectMapper objectMapper = new ObjectMapper();
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
response.getWriter().flush();
}
필터 내부에서 response 객체를 사용하여 직접 JSON 형태로 에러 응답을 보내도록 했다.
로그인을 처리하는 필터 이전에, 필터 내부에서 발생하는 예외를 처리하는 필터를 추가했다.
public class ExceptionFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
try{
filterChain.doFilter(servletRequest, servletResponse);
} catch (MyException e) {
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.sendError(e.getHttpStatus().value(), e.getMessage());
}
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean loginFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new AuthFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
@Bean
public FilterRegistrationBean exceptionFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new ExceptionFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}

0번 필터가 예외를 처리하는 필터, 1번 필터가 로그인을 처리하는 필터이다.
이렇게 하면 로그인 필터는 비즈니스 로직에만 집중할 수 있고, 예외 필터는 예외 처리에만 집중할 수 있다는 장점이 있다.
나는 처리해야 할 예외가 하나뿐이었기 때문에, 로그인 필터 내에서 직접 응답을 처리하는 방법을 선택했다. 하지만 예외가 많아진다면 예외 처리를 전담하는 필터를 별도로 추가하는 것이 유지보수에 더 유리할 것이다.