최근 프로젝트를 진행하면서 Spring Security를 사용하고 로깅 모듈을 개발하면서 알게된 Filter와 Interceptor 개념을 정리하려 한다.
서블릿 요청 시 Filter, Interceptor, AOP 순으로 전달이 되며 처리 후 반대 순서로 응답이 전달된다.
위 그림에서 알 수 있듯이 Filter는 요청 처리 흐름 중 제일 먼저 실행되며 Spring 영역에 포함되지 않는다. 따라서, 필터에서 발생한 예외는 AOP를 활용한 @ExceptionHandler
로 처리할 수 없다.
Spring Security는 필터를 사용하여 인증/인가 과정을 처리하는데 이 때 필터에 대한 개념이 부족하여 예외 처리를 하는데 애를 먹었다.
만약, 아래와 같이 @ExceptionHandler
가 구성되있다 가정해보자.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(MemberException.class)
protected ResponseEntity<?> handleMemberException(MemberException e) {
// 로깅
return ResponseEntity.notFound()
.build();
}
...
}
Spring Security를 활용한 인증/인가 과정에서 의도적으로 MemberException
을 발생시키면 Dispatcher Servlet은 예외가 발생했다 정도만 알고 500 응답 코드를 반환한다.
Filter 단에서 아래 메서드를 통해 JWT를 검증을 수행하면서 예외를 발생시킨다.
public void validateToken(final String token) {
try {
getJwtParser().parseClaimsJws(token);
} catch (SecurityException | MalformedJwtException e) {
throw new JwtException(INVALID);
} catch (ExpiredJwtException e) {
throw new JwtException(EXPIRED);
} catch (UnsupportedJwtException e) {
throw new JwtException(UNSUPPORTED);
} catch (IllegalArgumentException e) {
throw new JwtException(NOT_FOUND);
}
}
AOP로 예외 처리가 불가능하므로 Filter에서 try/catch
를 활용하여 예외 처리를 하였다.
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String TOKEN_PREFIX = "Bearer ";
private final String jwtExceptionAttributeName;
private final JwtProvider jwtProvider;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(@Value("${spring.jwt.exception-request-attribute-name}") final String jwtExceptionAttributeName,
final JwtProvider jwtProvider,
final UserDetailsService userDetailsService) {
this.jwtExceptionAttributeName = jwtExceptionAttributeName;
this.jwtProvider = jwtProvider;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(final HttpServletRequest request,
final HttpServletResponse response,
final FilterChain filterChain) throws ServletException, IOException {
final String accessToken = getAccessToken(request);
try {
jwtProvider.validateToken(accessToken);
SecurityContextHolder.getContext()
.setAuthentication(getAuthentication(accessToken));
} catch (JwtException e) {
request.setAttribute(jwtExceptionAttributeName, e); // TODO 6/2 인증이 필요없는 API 요청을 대비하여 요청 attribute에 현재 예외 저장
}
filterChain.doFilter(request, response);
}
...
}
Spring Security의 경우 위와 같은 인증 예외 시 최종적으로 AuthenticationEntryPoint
를 거치게 되므로 Filter 단에서 request
에 발생한 예외 객체를 저장했다.
@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final String LOG_EXCEPTION_FORMAT = "Response Code = {}, Message = {}";
private static final String LOG_URI_FORMAT = "Request Uri = {}";
private final String jwtExceptionAttributeName;
private final ObjectMapper objectMapper;
public CustomAuthenticationEntryPoint(@Value("${spring.jwt.exception-request-attribute-name}") final String jwtExceptionAttributeName,
final ObjectMapper objectMapper) {
this.jwtExceptionAttributeName = jwtExceptionAttributeName;
this.objectMapper = objectMapper;
}
@Override
public void commence(final HttpServletRequest request,
final HttpServletResponse response,
final AuthenticationException authException) throws IOException {
final JwtException jwtException = (JwtException) request.getAttribute(jwtExceptionAttributeName); // TODO 6/2 JwtAuthenticationFilter 에서 인증 오류 발생 시 request Attribute에 담긴 JwtException 객체를 꺼낸다.
if (jwtException == null) {
return;
}
printLog(jwtException, request);
final ErrorCode errorCode = jwtException.getErrorCode();
response.setStatus(errorCode.getHttpStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(CharEncoding.UTF_8);
final ErrorResponse errorResponse = ErrorResponse.from(errorCode);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
...
}
AuthenticationEntryPoint
에서 앞서 Filter에서 request
에 저장한 예외 객체를 가져와 처리한다.
핵심은 Filter에서 발생한 예외는 Spring의 도움을 받을 수 없기에 Spring Security의 인증 처리 흐름을 활용하여 적절히 처리해야 된다는 것이다!
Spring Security는 Filter를 사용하므로 Filter와 Interceptor 둘 중 어떤걸 써야되는지 고민할 필요가 없었지만 이번에 로깅 모듈을 구현하면서 이 둘의 차이를 잘 알게되었다.
결과적으로 로깅 모듈을 구현할 때 Interceptor를 사용했는데 이유는 아래와 같다.
스프링 빈 의존성
내가 구현한 로깅 모듈에는 요청 ~ 응답 시간을 체크하는 타이머, SQL 횟수를 세는 카운터 등 여러 가지 빈이 사용된다. 따라서, Interceptor가 더 유리하다 판단했다.
관심사
요청 ~ 응답까지 흐름 대부분은 Spring MVC에서 결정된다 판단했다. 특히, 응답 시간을 계산할 때 서블릿에 도달하기 전까지 과정을 포함시킬 필요는 없다 생각했다.
또한, Filter는 인증/인가와 같은 서블릿에 도달할 수 있는지 여부를 판단할 때 사용하는게 적합하지만 로깅은 이와는 다른 느낌이라 생각한다. (다른 관심사)
비용
로깅이라는 작업이 인증/인가와 같은 상대적으로 중요한 과정에 영향을 준다는게 좋아보이지 않았다.
요청, 응답 단순 조회
Filter의 경우 파라미터로 전달받는 요청, 응답을 수정하는게 가능하다. 그러나, 로깅의 경우 요청, 응답을 수정할 필요가 없다.
나는 preHandle()
에서 타이머를 시작하고 afterCompletion()
에서 타이머를 종료한 후 요청, 응답 값을 로깅하도록 구현했다.
@Component
@Slf4j
public class LoggingInterceptor extends WebRequestHandlerInterceptorAdapter {
private final StopWatch apiTimer;
private final ApiQueryCounter apiQueryCounter;
@Autowired
public LoggingInterceptor(final WebRequestInterceptor requestInterceptor,
final StopWatch apiTimer,
final ApiQueryCounter apiQueryCounter) {
super(requestInterceptor);
this.apiTimer = apiTimer;
this.apiQueryCounter = apiQueryCounter;
}
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws Exception {
apiTimer.start();
return true;
}
@Override
public void afterCompletion(final HttpServletRequest request,
final HttpServletResponse response,
final Object handler,
final Exception ex) throws Exception {
apiTimer.stop();
// 요청, 응답값 로깅
}
...
}
참고
Interceptor의postHandle()
은 현재 많이 사용하는 Rest API 방식에서는 잘 사용하지 않는다.