모든 HTTP 요청이 컨트롤러에 도달하기 전에 가로채서 토큰을 검증하는 필터예요.
HTTP 요청
↓
JwtFilter ← 토큰 검증
↓
유효한 토큰 → SecurityContext에 인증 정보 저장 → 컨트롤러 통과 ✅
유효하지 않은 토큰 → SecurityContext 비워둠 → SecurityConfig가 401 반환 ❌
토큰 없음 → 그냥 통과 (로그인, 회원가입 같은 공개 API) ✅
JwtFilter는 OncePerRequestFilter를 상속받아요.
필터가 요청당 딱 한 번만 실행되도록 보장해주는 클래스예요. JWT 필터 만들 때 표준 패턴이에요.
스프링이 내부적으로 요청을 여러 번 처리하는 경우가 있는데, 그때도 필터가 중복 실행되지 않아요.
스프링 시큐리티가 "지금 요청한 사람이 누구냐" 를 저장하는 공간이에요.
JwtFilter에서 토큰 검증 후
↓
SecurityContext에 인증 정보 저장
↓
컨트롤러에서 @AuthenticationPrincipal로 꺼내 쓸 수 있음
JWT는 Stateless라서 매 요청마다 토큰을 검증하고 SecurityContext에 새로 저장해요. 요청이 끝나면 SecurityContext는 소멸해요.
요청 헤더에서 토큰을 꺼내요.
Authorization: Bearer eyJhbGc...
Bearer 이후의 토큰 문자열만 추출해요. 토큰이 없거나 형식이 다르면 null을 반환해요.
토큰 추출 로직을 resolveToken()으로 분리하는 게 좋아요. 인라인으로 작성하면 doFilterInternal() 메서드가 길어지고 가독성이 떨어지거든요.
// ❌ 이렇게 중복 체크할 필요 없음
if (jwtUtil.isTokenExpired(token)) {
response.sendError(SC_UNAUTHORIZED, "만료되었습니다.");
return;
}
parseSignedClaims() 자체가 토큰이 만료되면 ExpiredJwtException을 던져요. 서명 검증 + 만료 검증을 동시에 해줘요. 따로 만료 체크하는 건 중복이에요.
Authentication authentication = new UsernamePasswordAuthenticationToken(
email, // principal
null, // credentials
List.of(new SimpleGrantedAuthority(role)) // 권한
);
SecurityContextHolder.getContext().setAuthentication(authentication);
컨트롤러에서 꺼낼 때
@AuthenticationPrincipal String email
DB 조회 없이 간결해요. 단순히 이메일만 필요한 경우 충분해요.
User user = User.builder()
.username(username)
.password("N/A")
.role(role)
.build();
CustomUserDetails customUserDetails = new CustomUserDetails(user);
Authentication authToken = new UsernamePasswordAuthenticationToken(
customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
컨트롤러에서 꺼낼 때
@AuthenticationPrincipal CustomUserDetails userDetails
userDetails.getUser().getEmail()
유저 객체 자체를 꺼낼 수 있어서 다양한 정보 접근이 편해요.
// ❌ 이런 if문 불필요
if (SecurityContextHolder.getContext().getAuthentication() == null) {
SecurityContextHolder.getContext().setAuthentication(authToken);
}
JWT는 Stateless예요. 매 요청마다 SecurityContext가 새로 초기화되기 때문에 이미 인증 정보가 있을 일이 없어요. 그냥 덮어써도 돼요.
catch (Exception e) {
response.sendError(SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
return;
}
필터는 스프링 컨텍스트 밖에서 동작해서 @ExceptionHandler가 잡아주지 않아요. 그래서 response에 직접 써줘야 해요.
catch (CustomException e) {
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
SecurityContext를 비워두고 통과시키면 SecurityConfig가 인증 필요한 API에 대해 알아서 401을 반환해요.
예외처리 로직을 SecurityConfig에서 중앙 관리할 수 있어서 더 깔끔해요.
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = resolveToken(request);
if (token == null) {
filterChain.doFilter(request, response);
return;
}
try {
jwtUtil.validateToken(token);
String email = jwtUtil.getEmail(token);
String role = jwtUtil.getRole(token);
Authentication authentication = new UsernamePasswordAuthenticationToken(
email,
null,
List.of(new SimpleGrantedAuthority(role))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (CustomException e) {
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
다음 포스팅에서는 JwtFilter를 SecurityConfig에 등록하고, 로그인/회원가입 API를 구현하는 과정을 다룰 예정이에요.