Spring Filter와 RestControllerAdvice

DanC·2025년 9월 12일
0

문제 상황

팀 프로젝트 회의를 하면서 프론트엔드 개발을 맡은 팀원에게 "토큰 검증에서 오류가 발생했을때 500으로 응답한다. 401로 응답해줬으면 좋겠다." 라는 피드백을 받았다. 그래서 다음과 같이 수정해서 확인해달라고 요청했다.

	public boolean validateToken(String token) {
		try {
			JWTVerifier verifier = JWT.require(Algorithm.HMAC512(jwtProperties.secret())).build();
			verifier.verify(token);
			return true;
		} catch (JWTVerificationException e) {
			log.info("토큰 검증 실패: {}", e.getMessage());
			return false;
		}
	}

토큰 검증에 실패하는 예외들은 모두 JWTVerificationException을 상속받기 때문에 해당 예외가 발생하면 false를 반환하고, CustomFilter에서 validateToken의 응답에 따라 우리 프로젝트에서 정의한 GlobalException을 던지는 방향으로 수정한 것이다.

그런데 팀원으로부터 아직 해결되지 않았다는 답변을 받았다.

분명 내가 생각한대로라면 해결되어야 하는데, 해결되지 않았다. 그래서 Spring 요청 처리의 동작 흐름을 다시 살펴보았다.

동작 흐름

Spring의 요청 동작 흐름은 다음과 같다.

보면 바로 알 수 있지만, Filter와 Spring MVC는 영역이 달라서 Spring MVC에 있는 GlobalExceptoinHandler는 FIlter에서 발생한 GlobalException을 처리하지 못한다. 따라서 컨트롤러 안에서 발생한 예외를 처리하는 RestControllerAdvice가 처리하지 못하게 된다.

해결 방법

찾아보니 여러 해결방법이 있었다.
1. 필터 안에서 직접 처리
2. Spring Security의 AuthenticationEntryPoint 활용
3. OncePerFilterChain 활용

필터 안에서 직접 처리하는건 코드가 너무 더러워질 것 같고, 현재 상황에서 적은 수정으로 해결할 수 있는 방안은 OncePerFilterChain을 활용하는 것이라 생각했다.

OncePerFilterChain에 대해 간단히 찾아보았는데, 하나의 요청이 REQUEST → ERROR와 같이 다시 디스패치될때 ALREADY_FILTERED 플래그를 사용하서 다시 필터를 거치지 않는 기능이 추가되었다는 점이 기존의 Filter와 가장 큰 차이점으로 보였다.
또, 기존의 코드에서는 화이트리스트를 만들어 조건문으로 분기를 해줬는데, OncePerFilterChain에는 shouldNotFilter가 있어서 화이트리스트를 처리하는 로직을 별도의 메서드로 분리할 수 있었다.

if (isWhitelisted(path)) {
	chain.doFilter(request, response);
	return;
}

if (path.contains(".well-known") || path.contains("com.chrome.devtools.json")) {
	response.setStatus(HttpServletResponse.SC_OK);
	response.setContentType("application/json");
	response.setCharacterEncoding("UTF-8");
	response.getWriter().write("{\"status\": \"ok\", \"message\": \"Chrome DevTools auto request ignored\"}");
	return;
}

위의 코드를 보면 직접 처리하는 부분도 있었는데, 이 부분도 깔끔하게 제거할 수 있었다.

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
	String path = request.getRequestURI();
	return WHITELIST.stream().anyMatch(p -> pathMatcher.match(p, path))
		|| path.contains(".well-known")
		|| path.contains("com.chrome.devtools.json");
}

결론

이번엔 적은 수정으로 해결하고 싶어서 OncePerFilterChain에 간단히 알아보고 적용해 보았다. 나중에 OncePerFilterChain와 AuthenticationEntryPoint에 대해 자세히 찾아보고 정리해봐야겠다는 생각이 들었다.
이번 문제를 해결하면서 필터에 대한 테스트를 추가했다. 서비스 로직에 대한 필요성만 느끼고 있었는데, 이번 기회에 테스트 코드의 필요성을 다시 체감했다.
수정 전과 수정 후 전체 코드를 소개하고 마무리하겠다.

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends GenericFilter {

	private final JwtTokenProvider jwtTokenProvider;
	private final AntPathMatcher pathMatcher = new AntPathMatcher();

	private static final List<String> WHITELIST = List.of(
		"/favicon.ico",
		"/api/v1/sign-in",
		"/api/v1/auth/redirect",
		"/error",
		"/h2-console/**"
	);

	private boolean isWhitelisted(String path) {
		return WHITELIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
	}

	@Override
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
		throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest)req;
		HttpServletResponse response = (HttpServletResponse)res;
		String token = resolveToken(request);
		String path = request.getRequestURI();

		if (isWhitelisted(path)) {
			chain.doFilter(request, response);
			return;
		}

		if (path.contains(".well-known") || path.contains("com.chrome.devtools.json")) {
			response.setStatus(HttpServletResponse.SC_OK);
			response.setContentType("application/json");
			response.setCharacterEncoding("UTF-8");
			response.getWriter().write("{\"status\": \"ok\", \"message\": \"Chrome DevTools auto request ignored\"}");
			return;
		}

		if (!StringUtils.hasText(token) || !jwtTokenProvider.validateToken(token)) {
			throw new MementoException(TOKEN_NOT_VALID);
		}
        ...
		chain.doFilter(request, response);
	}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

	private final JwtTokenProvider jwtTokenProvider;
	private final AntPathMatcher pathMatcher = new AntPathMatcher();

	private static final List<String> WHITELIST = List.of(
		"/favicon.ico",
		"/api/v1/sign-in",
		"/api/v1/auth/redirect",
		"/h2-console/**"
	);

	private boolean isWhitelisted(String path) {
		return WHITELIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
	}

	@Override
	protected boolean shouldNotFilter(HttpServletRequest request) {
		String path = request.getRequestURI();
		return WHITELIST.stream().anyMatch(p -> pathMatcher.match(p, path))
			|| path.contains(".well-known")
			|| path.contains("com.chrome.devtools.json");
	}

	@Override
	public void doFilterInternal(
		@NotNull HttpServletRequest request,
		@NotNull HttpServletResponse response,
		@NotNull FilterChain chain
	) throws IOException, ServletException {
		String token = resolveToken(request);

		if (!StringUtils.hasText(token) || !jwtTokenProvider.validateToken(token)) {
			SecurityContextHolder.clearContext();
			response.sendError(SC_UNAUTHORIZED, "토큰이 없거나 검증에 실패했습니다.");
			return;
		}
        ...
		chain.doFilter(request, response);
	}
}

0개의 댓글