- Published on
Spring Boot 3에서 401 반복? JWT 필터 순서 정리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 JWT 인증을 붙였는데 로그인(토큰 발급)은 되는데 이후 요청이 계속 401로 떨어지는 경우가 있습니다. 특히 Spring Boot 3(= Spring Security 6)로 올라오면서 설정 방식이 바뀌고, SecurityFilterChain을 직접 구성하는 패턴이 일반화되며 필터 순서를 잘못 넣어 “인증이 된 것 같은데도 매번 401” 같은 증상이 자주 발생합니다.
이 글은 “JWT 파싱은 했는데 SecurityContext에 인증이 안 남는다”, “Authorization 헤더가 있는데도 Anonymous로 처리된다”, “예외가 다른 필터에서 삼켜져 계속 401만 나온다” 같은 케이스를 필터 체인 관점에서 해부하고, Spring Boot 3에서 안전하게 고치는 방법을 코드로 정리합니다.
> 운영 환경에서 401이 반복될 때는 애플리케이션 코드뿐 아니라 Ingress/NLB, 프록시 헤더, TLS 문제도 함께 얽힙니다. 다만 이 글은 애플리케이션 내부(필터 체인) 원인에 집중합니다. 인프라 레벨의 연결/헬스체크 이슈가 의심된다면 EKS에서 NLB 타겟 Unhealthy - 헬스체크·Pod·SG 같은 체크리스트도 같이 확인해보세요.
Spring Boot 3에서 401이 반복되는 전형적인 증상
다음 중 하나라도 해당하면 필터 순서/설정 문제일 확률이 큽니다.
Authorization: Bearer ...를 보내는데도SecurityContextHolder.getContext().getAuthentication()이null또는anonymousUser- 컨트롤러에서
@AuthenticationPrincipal이 계속null - 디버그 로그에
AnonymousAuthenticationFilter가 항상 동작 - 토큰이 만료/서명 오류일 때 401이 아니라 403 또는 500으로 튐(예외 처리 위치가 꼬임)
/login,/auth/**같은 공개 엔드포인트도 JWT 필터가 가로채며 실패
핵심은 하나입니다.
> JWT 필터가 “Authentication을 만들어 SecurityContext에 넣는 시점”이 Spring Security의 인증/인가 흐름보다 앞서야 하고, 동시에 예외 처리는 ExceptionTranslationFilter 흐름과 충돌하지 않게 설계되어야 합니다.
Spring Security 6 필터 체인에서 “JWT는 어디에 끼워야 하나?”
일반적으로 JWT는 세션이 아니라 요청마다 헤더를 확인해 인증을 구성합니다. 따라서 다음 성격을 가집니다.
OncePerRequestFilter로 구현하는 것이 보편적UsernamePasswordAuthenticationFilter(폼 로그인)보다 앞에서 실행되어야 함AnonymousAuthenticationFilter가 Authentication을 채우기 전에 실행되어야 함
실무에서 가장 흔한 정답은 아래 중 하나입니다.
addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)- 또는
addFilterBefore(jwtFilter, AnonymousAuthenticationFilter.class)
보통은 첫 번째가 가장 흔하고 안정적입니다. “왜 401 반복이 필터 순서로 생기냐”를 이해하려면, SecurityContext가 채워지는 타이밍을 생각해야 합니다.
- JWT 필터가 너무 뒤에 있으면, 이미
AnonymousAuthenticationFilter가anonymousUser를 넣어버리고 이후 인가에서 막히거나, 커스텀 인가 로직이 기대와 다르게 동작합니다. - 반대로 JWT 필터가 너무 앞에 있고 예외를 직접
response.sendError로 끝내버리면, Spring Security가 예외를 번역하는 표준 흐름(ExceptionTranslationFilter+AuthenticationEntryPoint)를 타지 못해 “계속 401만 보이는” 형태로 디버깅이 어려워집니다.
정석 구성: Stateless + JWT + 올바른 필터 순서
아래는 Spring Boot 3 / Spring Security 6에서 가장 흔한 형태의 구성입니다.
1) SecurityFilterChain 설정 예시
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**", "/actuator/health").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"message\":\"unauthorized\"}");
})
)
// 핵심: JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 둔다
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
여기서 포인트는 세 가지입니다.
STATELESS로 세션을 끊어야 “요청마다 JWT로 인증”이라는 모델이 일관됩니다.- 공개 엔드포인트를
permitAll()로 명시해야 JWT 필터가 불필요하게 실패를 만들지 않습니다. - JWT 필터는
UsernamePasswordAuthenticationFilter보다 앞에 둡니다.
2) JWT 필터 구현 예시 (OncePerRequestFilter)
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
JwtPrincipal principal = jwtTokenService.parse(token);
Authentication authentication = new UsernamePasswordAuthenticationToken(
principal,
null,
principal.authorities()
);
// 핵심: SecurityContext에 넣어야 이후 인가가 통과한다
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtExpiredException e) {
// 여기서 response를 직접 닫아버리면 디버깅이 어려워질 수 있음
// 표준 흐름을 타고 싶다면 예외를 던지고 EntryPoint에서 처리
SecurityContextHolder.clearContext();
throw new BadCredentialsException("JWT expired", e);
} catch (JwtInvalidException e) {
SecurityContextHolder.clearContext();
throw new BadCredentialsException("JWT invalid", e);
}
}
filterChain.doFilter(request, response);
}
}
여기서도 반복 401을 만드는 흔한 실수가 있습니다.
- SecurityContext에 Authentication을 안 넣음: 토큰 파싱만 하고 끝내면 항상 401입니다.
filterChain.doFilter()를 호출하지 않고 반환해버림: 일부 경로에서 요청이 끊기며 이상한 401/403이 납니다.- 예외를 잡아서
sendError(401)로 끝내고return: Spring Security의 예외 번역 흐름과 충돌하여 “항상 401만” 보이거나, CORS 프리플라이트까지 실패하는 형태가 나올 수 있습니다.
“필터 순서” 때문에 생기는 401 루프의 실제 원인들
1) JWT 필터가 너무 뒤에 붙어 Anonymous가 먼저 채워짐
인가 단계에서 authenticated()만 걸어도, 인증이 anonymousUser이면 통과하지 못합니다. JWT가 뒤에서 들어와도 이미 인가에서 컷 나거나, 인가 예외가 먼저 발생해버립니다.
해결:
addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)- 또는 더 확실히
addFilterBefore(jwtFilter, AnonymousAuthenticationFilter.class)
2) ExceptionTranslationFilter 흐름을 깨서 “계속 401만” 보이는 경우
JWT 필터에서 예외를 직접 처리하면(예: response.sendError(401) 후 return) 다음 문제가 생깁니다.
- 어떤 요청은 Spring Security가 401 JSON을 내려주고
- 어떤 요청은 필터가 내려주고
- 어떤 요청은 CORS/프록시 환경에서 브라우저가 재시도하며 “무한 401처럼” 보입니다.
가급적 예외는 AuthenticationException 계열로 던지고, exceptionHandling().authenticationEntryPoint(...)에서 일관되게 응답을 만드는 편이 운영에서 안전합니다.
3) /auth/** 같은 공개 경로에서 JWT 필터가 실패를 만들어버림
토큰이 없는 요청은 그냥 통과시키고(인증을 만들지 않고) 다음 필터로 넘겨야 합니다. 그런데 "토큰이 없으면 401"로 만들어버리면, 로그인/토큰 재발급 API 자체가 막힙니다.
해결:
- 토큰이 없으면
filterChain.doFilter()로 패스 - 또는
shouldNotFilter로 공개 경로 스킵
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/auth/") || path.equals("/actuator/health");
}
4) SecurityContext 저장소(Repository) 오해: 세션 없이 유지된다고 착각
STATELESS에서는 요청이 끝나면 컨텍스트가 유지되지 않습니다. 즉, JWT는 매 요청마다 다시 파싱해서 SecurityContextHolder에 넣어야 합니다. “첫 요청에서 인증했는데 다음 요청이 401”은 정상 동작일 수도 있고, 프론트가 토큰을 다음 요청에 안 실어 보내는 것일 수도 있습니다.
이때는 서버 로그만 보지 말고, 실제 네트워크 캡처에서 다음 요청의 Authorization 헤더를 확인하세요.
디버깅 체크리스트: 어디서 끊기는지 빠르게 보는 법
1) Spring Security 디버그 로그 켜기
application.yml
logging:
level:
org.springframework.security: DEBUG
로그에서 아래 키워드를 찾으면 원인 추적이 빨라집니다.
SecurityContextHolder에 무엇이 들어갔는지AnonymousAuthenticationFilter가 언제 동작했는지ExceptionTranslationFilter가 어떤 예외를 번역했는지
2) 필터 체인 순서 확인하기
필터가 여러 개면 “내 필터가 진짜 원하는 위치에서 실행되나?”가 중요합니다. 아래처럼 현재 체인에 등록된 필터를 출력해 확인할 수 있습니다(테스트/로컬에서만 권장).
@Bean
ApplicationRunner runner(FilterChainProxy filterChainProxy) {
return args -> filterChainProxy.getFilterChains().forEach(chain -> {
System.out.println("--- Security Filter Chain ---");
chain.getFilters().forEach(f -> System.out.println(f.getClass().getName()));
});
}
3) 프록시/로드밸런서 환경에서 헤더가 사라지는지 확인
EKS/NLB/Ingress를 거치면서 Authorization 헤더가 누락되면 서버는 매번 “토큰이 없는 요청”으로 보고 401을 반환합니다. 애플리케이션 필터 순서만으로는 절대 해결되지 않습니다.
특히 운영에서 “로컬에서는 되는데 배포하면 401 반복”이라면, 네트워크 구간 점검도 병행하세요. 인프라 레벨에서 통신 자체가 불안정하면 인증 실패처럼 보이는 경우도 있어, 장애 징후를 분리하는 습관이 중요합니다. 예를 들어 외부 HTTPS 자체가 실패하는 케이스는 EKS에서 Pod DNS는 되는데 외부 HTTPS만 실패할 때 같은 증상과도 혼동될 수 있습니다.
권장 패턴: JWT 필터는 “인증만” 하고, 인가/응답은 Security에 맡긴다
정리하면, Spring Boot 3에서 401 반복을 줄이는 설계 원칙은 아래와 같습니다.
- JWT 필터는 토큰 파싱 + Authentication 구성 + SecurityContext 주입까지만 책임진다.
- 토큰이 없으면 조용히 통과(공개 경로/미인증 허용 경로는 Security 설정으로 통제).
- 예외 응답은
AuthenticationEntryPoint/AccessDeniedHandler로 일원화한다. - 필터는
UsernamePasswordAuthenticationFilter보다 앞(보통addFilterBefore)에 둔다.
이 원칙대로 구성하면, “토큰은 보냈는데도 계속 401” 같은 문제의 대부분이 사라지고, 남는 이슈는 실제로 토큰이 누락되었거나(클라이언트/프록시) 토큰 검증 로직 자체가 틀린 경우로 좁혀집니다.
마무리
Spring Boot 3에서 JWT를 붙일 때 401이 반복된다면, 먼저 토큰 검증 로직을 의심하기보다 필터 체인에서 내 JWT 필터가 어디에 위치해 실행되는지, 그리고 SecurityContext에 Authentication이 실제로 들어가는지부터 확인하는 것이 가장 빠릅니다.
필터 순서를 바로잡고(대개 UsernamePasswordAuthenticationFilter 앞), 예외 처리를 Spring Security의 표준 흐름으로 정리하면, 같은 코드라도 환경에 따라 들쭉날쭉하던 401 문제가 안정적으로 정리됩니다.