2. Spring Security JWT Filter 구현기

yoon·2026년 3월 29일

스프링 인증/인가

목록 보기
2/6

JwtFilter가 하는 일

모든 HTTP 요청이 컨트롤러에 도달하기 전에 가로채서 토큰을 검증하는 필터예요.

HTTP 요청
    ↓
JwtFilter ← 토큰 검증
    ↓
유효한 토큰 → SecurityContext에 인증 정보 저장 → 컨트롤러 통과 ✅
유효하지 않은 토큰 → SecurityContext 비워둠 → SecurityConfig가 401 반환 ❌
토큰 없음 → 그냥 통과 (로그인, 회원가입 같은 공개 API) ✅

OncePerRequestFilter

JwtFilter는 OncePerRequestFilter를 상속받아요.

필터가 요청당 딱 한 번만 실행되도록 보장해주는 클래스예요. JWT 필터 만들 때 표준 패턴이에요.

스프링이 내부적으로 요청을 여러 번 처리하는 경우가 있는데, 그때도 필터가 중복 실행되지 않아요.


SecurityContext가 뭐냐

스프링 시큐리티가 "지금 요청한 사람이 누구냐" 를 저장하는 공간이에요.

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을 던져요. 서명 검증 + 만료 검증을 동시에 해줘요. 따로 만료 체크하는 건 중복이에요.


SecurityContext 저장 방식 두 가지

방법 1. 이메일 직접 저장 (우리 방식)

Authentication authentication = new UsernamePasswordAuthenticationToken(
        email,   // principal
        null,    // credentials
        List.of(new SimpleGrantedAuthority(role)) // 권한
);
SecurityContextHolder.getContext().setAuthentication(authentication);

컨트롤러에서 꺼낼 때

@AuthenticationPrincipal String email

DB 조회 없이 간결해요. 단순히 이메일만 필요한 경우 충분해요.

방법 2. CustomUserDetails 저장

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가 새로 초기화되기 때문에 이미 인증 정보가 있을 일이 없어요. 그냥 덮어써도 돼요.


예외처리 방식

방법 1. 필터에서 직접 response 작성

catch (Exception e) {
    response.sendError(SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
    return;
}

필터는 스프링 컨텍스트 밖에서 동작해서 @ExceptionHandler가 잡아주지 않아요. 그래서 response에 직접 써줘야 해요.

방법 2. SecurityConfig에 위임 (우리 방식)

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를 구현하는 과정을 다룰 예정이에요.

0개의 댓글