- Published on
Spring Boot 3에서 가끔 401? SecurityContext 누락 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 정상인데 가끔씩만 401 Unauthorized가 떨어지고, 로그를 보면 분명 JWT(혹은 세션) 인증이 성공한 것 같은데 Controller에 들어올 때 SecurityContext가 비어 있는 상황이 있습니다. Spring Boot 3(=Spring Framework 6, Spring Security 6)로 올라오면서 필터 체인/비동기 처리/리액티브 전환/관측(Observability) 구성 등이 함께 바뀌며 이런 “간헐적” 문제가 더 잘 드러나기도 합니다.
이 글은 다음 질문에 답하는 형태로 정리합니다.
- 왜 항상이 아니라 가끔 401이 발생하는가?
- 어떤 로그/지표를 보면
SecurityContext누락을 빠르게 확정할 수 있는가? - 원인별로 어떤 설정/코드 수정이 필요한가?
> 운영 환경이 EKS/ALB 등 인프라를 끼고 있다면, 애플리케이션 문제가 아닌데도 인증이 깨져 보일 수 있습니다. readiness/프록시 이슈도 함께 점검하세요: EKS에서 Readiness 실패인데 로그는 정상일 때
증상 패턴: “필터에서는 인증됐는데, 컨트롤러에서는 없다”
대표적인 증상은 아래 중 하나로 나타납니다.
OncePerRequestFilter(예: JWT 필터)에서SecurityContextHolder.getContext().getAuthentication()이 채워진 로그가 찍히는데, 컨트롤러/서비스에서 다시 조회하면null또는AnonymousAuthenticationToken.- 특정 API만, 혹은 특정 트래픽 패턴(동시성 증가/비동기 호출/타임아웃 직전)에서만 401.
- 같은 요청을 재시도하면 성공.
간헐적이라는 점은 보통 스레드 전환, 요청 경계 밖 실행, 프록시/로드밸런서가 요청을 변형, 세션/쿠키가 일부 요청에서만 누락 같은 “조건부” 이벤트가 있다는 뜻입니다.
1) 가장 흔한 원인: @Async/CompletableFuture로 스레드가 바뀜
Spring Security의 SecurityContext는 기본적으로 ThreadLocal 기반입니다. 즉, 인증 정보는 “현재 스레드”에 묶입니다. 요청 스레드에서 인증을 완료해도, 이후 로직이 다른 스레드에서 실행되면 SecurityContext가 사라진 것처럼 보입니다.
재현 예시
@RestController
@RequiredArgsConstructor
public class DemoController {
private final DemoService demoService;
@GetMapping("/me")
public CompletableFuture<String> me() {
return demoService.asyncWork();
}
}
@Service
public class DemoService {
@Async
public CompletableFuture<String> asyncWork() {
var auth = SecurityContextHolder.getContext().getAuthentication();
// 여기서 auth == null 또는 anonymous가 될 수 있음
return CompletableFuture.completedFuture(String.valueOf(auth));
}
}
해결: DelegatingSecurityContextAsyncTaskExecutor 사용
@Async가 사용하는 TaskExecutor를 SecurityContext 전파가 되는 Executor로 감싸야 합니다.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor();
delegate.setCorePoolSize(10);
delegate.setMaxPoolSize(50);
delegate.setQueueCapacity(1000);
delegate.setThreadNamePrefix("app-");
delegate.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
@Override
public Executor getAsyncExecutor() {
return applicationTaskExecutor();
}
}
DelegatingSecurityContextAsyncTaskExecutor는 작업 제출 시점의SecurityContext를 캡처하고 실행 스레드에 주입합니다.CompletableFuture.supplyAsync(...)를 직접 쓴다면,Executor를 명시적으로 주입해 같은 방식으로 감싸세요.
대안: SecurityContextHolder 전략 변경(주의)
MODE_INHERITABLETHREADLOCAL로 바꾸면 자식 스레드에 상속되지만, 스레드풀 재사용 환경에서는 오염/누수 위험이 있어 권장하지 않습니다.
2) WebFlux/Reactive에서 “컨텍스트가 다른 곳에 있음”
Spring WebFlux(리액티브)에서는 보안 컨텍스트가 ThreadLocal이 아니라 Reactor Context로 흐릅니다. WebFlux에서 SecurityContextHolder를 직접 조회하면 “가끔”이 아니라 “대체로” 비어 있게 됩니다. 다만 MVC와 WebFlux를 혼용하거나, 특정 경로만 리액티브로 처리하는 경우 간헐적으로 보일 수 있습니다.
해결: ReactiveSecurityContextHolder 사용
@GetMapping("/me")
public Mono<String> me() {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> ctx.getAuthentication())
.map(auth -> auth.getName());
}
또는 @AuthenticationPrincipal/Principal 파라미터 바인딩을 사용하면 프레임워크가 적절한 컨텍스트에서 꺼내줍니다.
3) 커스텀 필터 순서 문제: 인증보다 먼저 예외/리다이렉트가 발생
Spring Security 6에서 필터 체인을 구성할 때, 커스텀 JWT 필터를 잘못된 위치에 끼우면 아래가 생깁니다.
- 어떤 요청은 정상적으로 필터를 타지만
- 어떤 요청은 예외 처리/인증 엔트리포인트가 먼저 실행되어 401이 반환
점검: 필터 순서를 명시
JWT 인증 필터는 보통 UsernamePasswordAuthenticationFilter 이전에 둡니다.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/actuator/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
그리고 디버깅 시에는 Security 로그를 올려 “요청이 어떤 체인/필터를 탔는지”를 확인하는 것이 핵심입니다.
logging.level.org.springframework.security=TRACE
4) 프록시/로드밸런서가 Authorization 헤더를 일부 요청에서만 누락
간헐적으로만 401이 뜨고, 애플리케이션 로그에서 보면 인증 헤더 자체가 없는 요청이 섞여 있다면 인프라 계층을 의심해야 합니다.
- ALB/Ingress/프록시가 특정 조건에서 헤더를 제거
- HTTP/2 ↔ HTTP/1.1 변환 과정에서 헤더 크기/정책 문제
- WAF/보안 장비가 특정 토큰 패턴을 차단
특히 헤더/쿠키가 커질 때 간헐적 실패가 나올 수 있습니다(사용자별 쿠키 크기 차이, A/B 실험 쿠키 등). 이 경우 431/400/413으로도 튈 수 있어 함께 점검하세요: EKS에서 HTTP 431 해결 - 헤더·쿠키 과다 진단
빠른 확인 방법
- 애플리케이션 진입 직후(가장 앞단 필터)에서
Authorization헤더 존재 여부를 로깅 - ALB/Ingress 액세스 로그에서 해당 요청의 헤더/타겟 상태 확인(가능한 범위 내)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class HeaderProbeFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String auth = request.getHeader("Authorization");
logger.info("{} {} AuthorizationPresent={}", request.getMethod(), request.getRequestURI(), auth != null);
filterChain.doFilter(request, response);
}
}
> 토큰 값 자체를 로그에 남기지 마세요(보안/개인정보 사고).
5) 세션 기반인데 인스턴스가 여러 대 + Sticky Session/Session Store 미구성
JWT가 아니라 세션 기반 로그인이라면, 간헐적 401은 아주 전형적으로
- A 인스턴스에서 로그인(세션 생성)
- 다음 요청이 B 인스턴스로 라우팅
- B에는 세션이 없어 401
패턴으로 발생합니다.
해결 방향
- 로드밸런서 Sticky Session 활성화(단, 장애/스케일 전략에 따라 부작용)
- Spring Session + Redis 같은 외부 세션 스토어 사용
- 가능하면 API는
STATELESS + JWT로 전환
또한 쿠키 SameSite, Secure, 도메인 설정이 환경별로 달라 “가끔 쿠키가 안 실리는” 경우도 있습니다(특히 크로스 도메인/서브도메인).
6) 비동기 서블릿(DeferredResult/SseEmitter)에서 컨텍스트 유실
MVC에서도 DeferredResult, Callable, SseEmitter 같은 비동기 응답을 쓰면 컨텍스트 전파 이슈가 생길 수 있습니다. Spring Security는 일부를 자동 지원하지만, 커스텀 Executor를 쓰거나 체인 밖에서 작업을 만들면 누락됩니다.
해결: WebAsyncManager에 전파 설정(필요 시)
대부분은 DelegatingSecurityContext* 계열 Executor로 해결됩니다. 그래도 재현된다면 WebMvcConfigurer에서 async 설정을 점검하세요.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AsyncTaskExecutor applicationTaskExecutor;
public WebConfig(AsyncTaskExecutor applicationTaskExecutor) {
this.applicationTaskExecutor = applicationTaskExecutor;
}
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
configurer.setTaskExecutor(applicationTaskExecutor);
}
}
7) 진단 체크리스트: “401의 종류”를 분리하라
간헐적 401을 잡을 때는 먼저 “어떤 401인지”를 구분해야 합니다.
(1) 인증 자체가 안 됨: 헤더/쿠키가 없음
- 인프라/클라이언트 문제 가능성이 큼
- 앞단 필터에서 헤더 존재 여부 로깅
(2) 인증은 됐는데 컨트롤러에서 사라짐
- 스레드 전환(@Async, CompletableFuture, 비동기 MVC)
- 리액티브 컨텍스트 접근 방식 오류
(3) 인증은 됐는데 권한 부족으로 401/403 혼동
- 실제로는 403이어야 하는데 EntryPoint/AccessDeniedHandler 설정이 꼬여 401로 나가는 경우도 있음
아래처럼 EntryPoint/DeniedHandler를 명확히 분리하면 “401 vs 403”가 선명해져 원인 파악이 빨라집니다.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"unauthorized\"}");
})
.accessDeniedHandler((req, res, e) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"forbidden\"}");
})
)
.build();
}
8) 운영에서 재현이 어렵다면: 상관관계 ID + SecurityContext 스냅샷
“가끔” 문제는 재현이 어려우니, 최소한 다음은 남겨야 합니다.
- 요청별 Correlation ID(예:
X-Request-Id)를 MDC에 넣고 전 구간 로그에 포함 - 인증 필터 통과 시점의
principal,authorities,isAuthenticated를 요약 로깅 - 컨트롤러 진입 시점에도 동일 요약 로깅
다만 MDC도 ThreadLocal이라 비동기에서 같이 깨집니다. 이 경우도 Executor 전파(Decorator)가 필요합니다.
결론: 해결 우선순위
Spring Boot 3에서 간헐적 401 + SecurityContext 누락은 대부분 아래 우선순위로 정리됩니다.
- 비동기/스레드 전환이 있는지 확인하고
DelegatingSecurityContext...Executor로 전파 - WebFlux라면
ReactiveSecurityContextHolder/파라미터 바인딩으로 접근 방식 수정 - 커스텀 필터 순서/예외 처리(401/403) 분리로 “진짜 원인”이 보이게 만들기
- 특정 요청에서만
Authorization/쿠키가 누락되는지 확인(프록시/ALB/헤더 크기) - 세션 기반이면 멀티 인스턴스에서 Sticky/세션 스토어 점검
인프라 계층에서 요청이 변형되는 케이스(헤더/쿠키 크기, readiness로 인한 라우팅 흔들림 등)도 간헐성의 단골 원인입니다. 위 체크리스트대로 “헤더 누락인지, 컨텍스트 전파 문제인지”부터 분리하면 해결까지의 시간이 크게 줄어듭니다.