SecurityContext 전파 — 인증 정보는 요청 처리 동안 어떻게 유지될까
컨트롤러에서 서비스로, 서비스에서 리포지토리로 호출이 이어질 때, 인증 정보를 매번 파라미터로 전달하지 않아도 어디서든 접근할 수 있습니다. 어떻게 가능할까요?
이것이 가능한 이유는 ThreadLocal 기반의 SecurityContext 전파 메커니즘 덕분입니다. 같은 요청을 처리하는 동안 동일한 스레드가 사용되고, 그 스레드의 ThreadLocal에 인증 정보가 저장되어 있기 때문입니다.
개념 정의
SecurityContext 는 현재 인증된 사용자의 Authentication 객체를 담는 컨테이너입니다. SecurityContextHolder 는 이 SecurityContext를 저장하고 제공하는 정적 유틸리티입니다.
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
ThreadLocal 전략
기본 동작 (MODE_THREADLOCAL)
HTTP 요청 → 서블릿 스레드 (Thread-1)
│
├── SecurityContextHolderFilter: 세션에서 SecurityContext 복원
│
├── Controller: SecurityContextHolder.getContext() → Thread-1의 ThreadLocal
│ │
│ └── Service: SecurityContextHolder.getContext() → 같은 Thread-1
│ │
│ └── Repository: SecurityContextHolder.getContext() → 같은 Thread-1
│
└── SecurityContextHolderFilter: SecurityContext를 세션에 저장, ThreadLocal 정리
이 흐름에서 세 가지가 중요합니다.
- 요청 시작 시
SecurityContextHolderFilter가 세션에서 SecurityContext를 꺼내 ThreadLocal에 저장합니다. - ** 요청 처리 중** 같은 스레드에서 실행되는 Controller → Service → Repository가 모두 이 ThreadLocal에 접근합니다. 파라미터 전달이 필요 없는 이유입니다.
- ** 요청 종료 시** ThreadLocal을 정리하여 다른 요청의 인증 정보와 섞이지 않도록 합니다.
SecurityContextHolder 전략
SecurityContextHolder는 세 가지 저장 전략을 지원합니다.
| 전략 | 동작 | 사용 상황 |
|---|---|---|
| MODE_THREADLOCAL (기본) | 같은 스레드에서만 공유 | 일반적인 서블릿 환경 |
| MODE_INHERITABLETHREADLOCAL | 자식 스레드에도 전파 | new Thread() 사용 시 |
| MODE_GLOBAL | 애플리케이션 전역 공유 | 단일 사용자 시스템 (데스크톱 등) |
요청 간 SecurityContext 유지
HTTP는 무상태(Stateless)이므로, 요청 간에 SecurityContext를 유지하려면 별도 저장소가 필요합니다.
세션 기반 (기본)
첫 번째 요청 (로그인):
SecurityContextHolderFilter → 인증 성공 → SecurityContext를 세션에 저장
두 번째 요청:
SecurityContextHolderFilter → 세션에서 SecurityContext 복원 → ThreadLocal에 설정
요청 완료:
SecurityContextHolderFilter → ThreadLocal 정리
JWT 기반 (Stateless)
세션을 사용하지 않으므로, 매 요청마다 JWT를 검증하여 SecurityContext를 새로 생성합니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
비동기 환경에서의 전파
문제: 새 스레드에서 SecurityContext 유실
ThreadLocal은 스레드마다 독립된 저장소이기 때문에, @Async로 새 스레드에서 실행하면 SecurityContext가 전파되지 않습니다.
@Async
public void sendNotification(Long userId) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// auth == null! SecurityContext가 전파되지 않음
}
이 문제는 @Async, CompletableFuture.runAsync(), new Thread() 등 새 스레드를 생성하는 모든 경우에 발생합니다.
해결 1: DelegatingSecurityContextAsyncTaskExecutor (권장)
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
}
이 래퍼는 태스크 실행 시점의 SecurityContext를 복사하여 비동기 스레드에 설정합니다.
해결 2: MODE_INHERITABLETHREADLOCAL
@PostConstruct
public void init() {
SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
스레드 생성 시 부모의 SecurityContext를 자동 상속합니다. 하지만 ** 스레드 풀 환경에서는 주의 **가 필요합니다. 스레드가 재사용되면 이전 요청의 컨텍스트가 남을 수 있습니다.
해결 3: CompletableFuture에서 직접 전파
public CompletableFuture<Void> processAsync() {
SecurityContext context = SecurityContextHolder.getContext();
return CompletableFuture.runAsync(() -> {
try {
SecurityContextHolder.setContext(context);
doProcess();
} finally {
SecurityContextHolder.clearContext();
}
});
}
finally에서 반드시 clearContext()를 호출해야 합니다. 빠뜨리면 스레드 풀에서 이전 인증 정보가 남습니다.
WebFlux에서의 SecurityContext
리액티브 환경에서는 ThreadLocal을 사용할 수 없습니다. Reactor의 Context 를 사용합니다.
@GetMapping("/api/me")
public Mono<UserDto> getCurrentUser() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(auth -> UserDto.from(auth.getPrincipal()));
}
주의할 점
MODE_INHERITABLETHREADLOCAL + 스레드 풀 = 인증 정보 오염
MODE_INHERITABLETHREADLOCAL은 스레드 생성 시점에 부모의 SecurityContext를 복사합니다. 스레드 풀에서 스레드가 재사용되면 처음 생성될 때의 인증 정보가 남아있습니다. ** 사용자 A의 인증 정보로 사용자 B의 비동기 작업이 실행되는** 심각한 보안 문제가 발생할 수 있습니다. 스레드 풀 환경에서는 DelegatingSecurityContextAsyncTaskExecutor를 사용해야 합니다.
clearContext()를 빠뜨리면 메모리 누수와 보안 문제
커스텀 필터에서 SecurityContextHolder.setContext()를 호출한 뒤 clearContext()를 하지 않으면, 서블릿 컨테이너의 스레드 풀에서 이전 요청의 인증 정보가 남습니다. Spring Security의 기본 필터는 자동으로 정리하지만, 직접 SecurityContext를 설정하는 경우 finally 블록에서 반드시 정리해야 합니다.
CompletableFuture에서 캡처한 SecurityContext가 이미 정리된 경우
요청 처리가 완료되면 SecurityContextHolderFilter가 ThreadLocal을 정리합니다. 비동기 작업이 요청 완료 ** 이후 **에 실행되면, 캡처한 SecurityContext 객체는 남아있지만 세션과의 연결이 끊어져 있을 수 있습니다.
정리
| 항목 | 설명 |
|---|---|
| 저장 방식 | ThreadLocal 기반 — 같은 스레드 내에서 SecurityContext 공유 |
| 요청 간 유지 | 세션(기본) 또는 JWT(Stateless)로 처리 |
| 비동기 전파 (권장) | DelegatingSecurityContextAsyncTaskExecutor |
| MODE_INHERITABLETHREADLOCAL | 스레드 풀 환경에서 컨텍스트 오염 위험 |
| WebFlux | ThreadLocal 대신 ReactiveSecurityContextHolder 사용 |
| 정리 필수 | 직접 setContext() 호출 시 반드시 finally에서 clearContext() |