본문 바로가기
Spring/Security

[Spring Security] JwtAuthenticationFilter 해부: 인증 문지기의 설계와 역할 분리

by coding_whale 2026. 5. 9.
반응형

Spring Security에서 JWT를 연동할 때 가장 핵심이 되는 컴포넌트는 JwtAuthenticationFilter다.
매 요청마다 토큰을 검사하고 사용자를 식별하는 이 '문지기'가 어떻게 동작하고, 왜 즉시 에러를 내뱉지 않는지 그 설계 철학을 정리한다.

 

1. 핵심 개념 1: OncePerRequestFilter (중복 방어)

1) 개념 정의

Spring Security 필터 체인 내에서 "하나의 요청당 단 한 번"만 실행되도록 보장하는 필터다.

 

2) 왜 필요한가?

인증 로직은 토큰 파싱, 서명 검증, DB 조회 등 비용이 발생하는 작업이다. 서블릿 내부 포워딩이나 에러 페이지 이동 시 인증 필터가 여러 번 실행되면 불필요한 리소스가 낭비되고, 인증 상태의 일관성이 깨질 위험이 있다.

 

3) 실무 포인트

  • 헤더가 없거나 형식이 틀린 요청은 인증을 시도하지 않고 즉시 다음 필터로 넘긴다(Fast Pass).
  • 보호된 자원인지 아닌지는 이 필터가 판단하지 않는다. 그건 나중의 '인가(Authorization)' 단계에서 처리한다.

 

 

2. 핵심 개념 2: SecurityContext (인증 상태의 보관소)

1) 개념 정의

현재 실행 중인 요청 스레드의 인증 정보(Authentication)를 담는 저장소다.

 

2) 왜 필요한가?

  • 컨트롤러에서 @AuthenticationPrincipal이나 커스텀 @CurrentUser 어노테이션을 통해 현재 사용자 정보를 꺼내 쓰려면 반드시 이곳에 데이터가 있어야 한다.
  • SecurityConfig에서 설정한 권한 체크(hasRole(), authenticated()) 로직이 이 컨텍스트를 기준으로 동작한다.

 

3) 주의점

  • 오염 방지: 유효하지 않은 토큰이나 존재하지 않는 유저인데도 Authentication 객체를 채워 넣으면 보안 사고다.
  • 모든 검증(토큰+유저상태)이 완벽히 끝난 시점에만 데이터를 주입해야 한다.

 

 

3. 핵심 개념 3: Authorization 헤더 파싱

1) 개념 정의

Authorization: Bearer <token> 형식의 HTTP 헤더에서 순수 JWT 문자열만 추출하는 단계다.

 

2) 실패 시 정책

  • 헤더가 없거나 Bearer 로 시작하지 않는 경우: "인증 대상이 아님"으로 간주한다.
  • 이때 예외를 던지지 않고 chain.doFilter()를 호출해 요청을 다음 단계로 넘긴다.

 

 

4. 통합 코드 가이드: JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {

        // 1. 헤더 추출 및 최소 형식 확인
        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = header.substring(7); // "Bearer " 제거

        // 2. 토큰 유효성 검증 (서명, 만료, 구조)
        if (!jwtTokenProvider.validateToken(token)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 3. Subject 파싱 (provider:oauthId)
        String subject = jwtTokenProvider.getSubject(token);
        String[] parts = subject.split(":");
        if (parts.length != 2) {
            filterChain.doFilter(request, response);
            return;
        }

        OauthProvider provider = OauthProvider.valueOf(parts[0]);
        String oauthId = parts[1];

        // 4. 사용자 상태 재조회
        User user = userRepository.findByOauthProviderAndOauthId(provider, oauthId).orElse(null);
        if (user == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // 5. Authentication 생성 및 SecurityContext 주입
        CustomUserDetails userDetails = new CustomUserDetails(user);
        UsernamePasswordAuthenticationToken authentication =
            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 6. 다음 체인 진행
        filterChain.doFilter(request, response);
    }
}

 

 

5. 왜 필터에서 바로 401 에러를 주지 않는가?

필터 내부에서 인증 실패 시 즉시 에러 응답을 보내지 않고 다음 필터로 넘기는 이유는 '역할 분리' 때문이다.

  • JwtAuthenticationFilter: "인증을 시도"하는 역할. 성공하면 컨텍스트를 채우고, 실패하면 비운 채로 넘긴다.
  • SecurityConfig & ExceptionTranslationFilter: "인가 정책"을 결정하는 역할. 특정 API가 authenticated()를 요구하는데 컨텍스트가 비어있다면, 여기서 최종적으로 401(Unauthorized)을 판단한다.

이 구조 덕분에 로그인 없이도 접근 가능한 공개 API(permitAll)는 토큰 없이도 필터를 무사히 통과해 정상 작동할 수 있다.

 

 

6. 시나리오별 요청 흐름 정리

시나리오 필터 동작 인가(Authorization) 결과 최종 응답
공개 API + 토큰 없음 인증 미설정 후 통과 permitAll이므로 통과 200 OK
보호 API + 토큰 없음 인증 미설정 후 통과 authenticated() 위반 401 Unauthorized
보호 API + 만료된 토큰 검증 실패 후 통과(미설정) authenticated() 위반 401 Unauthorized
보호 API + 유효한 토큰 인증 설정(Context 주입) 인가 조건 충족 200 OK

 

 

7. 운영 보안 고도화 포인트

  1. 실패 원인 Enum 코드화: 필터 내부 로그나 메트릭을 위해 INVALID_SIGNATURE, TOKEN_EXPIRED 등 실패 원인을 상세히 관리한다.
  2. shouldNotFilter 정책: /health, /v3/api-docs 등 인증이 전혀 필요 없는 경로는 필터의 shouldNotFilter를 오버라이드하여 성능을 최적화한다.
  3. 메트릭 수집: 필터 통과/실패 카운트를 수집하면 운영 환경에서 비정상적인 인증 시도(브루트 포스 등)를 모니터링할 수 있다.

 

 

최종 요약

JwtAuthenticationFilter는 인증을 강제하는 곳이 아니라, 인증을 '시도하고 증명'하는 곳이다.
provider:oauthId 규약을 통해 신뢰할 수 있는 사용자임을 확인하고 이를 SecurityContext에 담아주는 것만으로도 컨트롤러와 서비스 레이어는 보안 걱정 없이 비즈니스 로직에 집중할 수 있다.

반응형