Spring에서 필터(Filter) 는 서블릿 기반 애플리케이션에서 요청(Request)와 응답(Response)를 가로채어 특정 작업을 수행할 수 있다.
Filter는 애플리케이션에서 요청과 응답을 가로채어 특정 작업을 수행할 수 있다. 하지만 Interceptor 또한 비슷한 동작을 하는 것 같다. 그럼 이 둘의 차이는 무엇이고 언제 사용할까?
Interceptor : Spring MVC 레벨에서 작동하며, 요청이 **DispatcherServlet
을 통해 컨트롤러로 라우팅되기 전에 동작한다.
Spring 필터와 인터셉터의 차이
기능 | Spring 필터(Filter) | Spring 인터셉터(Interceptor) |
---|---|---|
적용 범위 | 서블릿(Servlet) 레벨 | Spring MVC 레벨 |
작동 위치 | 서블릿 컨테이너가 요청을 처리하기 전에 작동 | DispatcherServlet이 요청을 컨트롤러로 보내기 전후에 작동 |
대상 | 모든 서블릿 요청 (정적 자원 포함) | Spring MVC 요청 (동적 자원) |
전후 처리 | 가능 (요청 전후에 로직 추가 가능) | 가능 (preHandle , postHandle , afterCompletion 메서드) |
순서 결정 | 필터 체인의 등록 순서에 따름 | Order 인터페이스나 WebMvcConfigurer 설정 |
주요 사용 사례 | 보안, 로깅, CORS 설정, 데이터 압축 | 인증, 권한 체크, 로깅, 데이터 전처리 |
쉽게 생각해서 Filter
는 큰 문이고 Interceptor
는 그 안에 작은 문이라고 생각 하면 된다.
이제 Filter를 알았으니 JWTFilter를 만들어 보자.
package com.team29.ArtifactV2.global.security.jwt;
import ...
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 헤더에서 access키에 담긴 토큰을 꺼냄
String accessToken = request.getHeader("access");
// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null) {
filterChain.doFilter(request, response);
return;
}
// 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
try {
jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e) {
//response body
PrintWriter writer = response.getWriter();
writer.print("access token expired");
//response status code
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// 토큰이 access인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(accessToken);
if (!category.equals("access")) {
//response body
PrintWriter writer = response.getWriter();
writer.print("invalid access token");
//response status code
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// username, role 값을 획득
String username = jwtUtil.getUsername(accessToken);
String role = jwtUtil.getRole(accessToken);
Member userEntity = new Member();
userEntity.setUsername(username);
userEntity.setRole(role);
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null,
customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
기능을 설명하기 전에 JWTFilter가 상속 받는 OncePerRequestFilter 는 뭘까?
스프링에서 디스패처 서블릿이 서블릿 컨테이너 앞에 모든 요청을 컨트롤러에 전달한다. 서블릿은 요청마다 서블릿을 생성하여 메모리에 저장한 뒤 같은 클라이언트의 요청이 들어올 경우 생성해둔 서블릿 객체를 재활용한다.
그런데 만약 서블릿이 다른 서블릿으로 dispatch하게 되면, 다른 서블릿 앞단에서 filter chain을 한번 더 거치게 된다. 이 차이때문에 OncePerRequestFilter를 사용한다.
쉽게 말해서, 클라이언트가 보기에 새로운 요청이 발생하지는 않지만, 서버 내부적으로는 요청이 다른 서블릿으로 전달(포워딩)된다. 이때, 서블릿 컨테이너는 이를 새로운 서블릿 요청으로 간주하기 때문에, 서블릿 앞단의 필터 체인이 다시 적용될 수 있다. 클라이언트가 새롭게 요청을 보내는 것과 유사하게 작동하게 되는 셈이다.
OncePerRequestFilter
추상 클래스를 살펴 보면 GenericFilterBean
를 상속 받고 있다. 이는 GenericFilterBean
의 모든 기능을 그대로 활용하면서 추가적인 기능을 제공한다는 의미이다.OncePerRequestFilter와 GenericFilterBean 차이
기능/구조 | Filter (서블릿 API) | GenericFilterBean (Spring) | OncePerRequestFilter (Spring Security) |
---|---|---|---|
상속 구조 | 최상위 인터페이스 | Filter 를 구현한 추상 클래스 | GenericFilterBean 을 상속한 추상 클래스 |
주요 역할 | 서블릿 필터 구현의 기본 인터페이스 | Spring 빈으로 쉽게 등록할 수 있는 서블릿 필터 | 한 요청당 한 번만 실행되는 특수 서블릿 필터 |
Spring 통합 | 없음, 서블릿 컨테이너에 의해 직접 관리 | Spring 빈으로 관리 가능, DI와 라이프사이클 활용 | Spring Security와 통합된 필터, 중복 실행 방지 |
구현 방식 | doFilter 메서드 구현 필요 | doFilter 메서드를 간단히 오버라이드 | doFilterInternal 메서드를 오버라이드하여 구현 |
중복 실행 방지 | 없음, 직접 로직 구현 필요 | 없음, 직접 로직 구현 필요 | 있음, 내부적으로 동일 요청에 대해 한 번만 실행 보장 |
적용 사례 | 일반적인 서블릿 필터 | Spring 서비스와의 통합이 필요한 필터 | JWT 인증, 보안 검증, 로그인 등 보안 필터에 사용 |
// 헤더에서 access키에 담긴 토큰을 꺼냄
String accessToken = request.getHeader("access");
// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null) {
filterChain.doFilter(request, response);
return;
}
❓토큰이 없는 경우 다음 필터로 넘기는 이유
JWTFilter에서 토큰이 없는 경우 요청을 다음 필터로 넘긴다고 해서 모든 요청이 검증 없이 통과하는 것은 아니다. 인증이 필요한 요청은 JWTFilter에서 SecurityContext에 인증 정보가 설정되지 않았다면, Spring Security 설정에 따라 접근이 차단됩니다.ex) 로그인, 회원가입, Home 화면과 같은 요청은 인증이 필요하지 않기 때문에, 토큰 없이도 접근이 가능해야 한다.
토큰 만료 여부 확인
try {
jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e) {
//response body
PrintWriter writer = response.getWriter();
writer.print("access token expired");
//response status code
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
jwtUtil.isExpired(accessToken);
를 호출하여 토큰이 만료되었는지 확인ExpiredJwtException
이 발생하며, 이 경우 클라이언트에게 401 Unauthorized
응답을 반환하고 필터 체인을 종료토큰의 유형(category) 확인
String category = jwtUtil.getCategory(accessToken);
if (!category.equals("access")) {
PrintWriter writer = response.getWriter();
writer.print("invalid access token");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
jwtUtil.getCategory(accessToken);
를 사용하여 토큰의 유형을 확인.401 Unauthorized
응답을 반환.토큰에서 사용자 정보(username, role) 추출
String username = jwtUtil.getUsername(accessToken);
String role = jwtUtil.getRole(accessToken);
jwtUtil
객체는 JWT를 파싱하여 토큰의 페이로드에서 이러한 정보를 가져온다. 사용자 정보로 인증 객체 생성 및 설정
Member userEntity = new Member();
userEntity.setUsername(username);
userEntity.setRole(role);
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null,
customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
다음으로 JWT 생성하는 JWTUtil에 대해서 알아보자