시작하며
안녕하세요. 개발자 Stark입니다.
오늘은 스프링 시큐리티를 사용할 때 http 메인 호출 내부에서 비동기 메서드를 호출했을 때(한 호출에서 부모-자식 스레드가 함께 동작하는 경우)에 스프링 시큐리티에서는 SecurityContextHolder(ThreadLocal)의 데이터를 어떻게 처리하는지와 이 데이터를 비동기 스레드에 전달 가능한지에 대해 함께 알아봅시다.
저의 경우 업무를 하다 보니 일반 스레드풀을 사용하는 메서드 내부에서 비동기 메서드를 호출하게 되는 경우가 있었는데 이때 유저의 정보가 그대로 전달되어야만 했습니다. 근데 비동기 스레드의 기본 설정 스레드풀인 SimpleTaskExecutor와 일반 스레드에서 사용하는 ThreadPool은 각각 다른 스레드풀이기에 ThreadLocal기반으로 동작하는 SecurityContextHolder에 담겨있던 정보가 전달되지 않아서 예외가 발생하는 현상이 있었습니다.
저는 이 문제를 해결하기 위해 2가지 방법을 찾았는데 첫 번째는 Spring Security의 context 모드를 Inheritable로 지정하는 것입니다. 근데 이 방법은 문제가 있었습니다. 바로 스프링같이 스레드풀을 사용하는 경우에는 스레드가 풀로 돌아갈 때 ThreadLocal 청소를 안 해주면 수많은 요청이 들어올 때 계속 같은 ThreadLocal에 담긴 데이터를 사용하게 된다는 것입니다.
그러면 어떤 방법을 사용해야 문제없이 SecurityContext를 전달할 수 있을까요? 스프링 시큐리티 팀에서는 이 문제를 해결하기 위해 DelegatingSecurityContextExecutor을 사용하여 기존의 스레드풀을 wrap 할 수 있는 기능을 지원합니다. 이 기능을 사용하면 부모 스레드(동기)의 SecurityContextHolder를 자식 스레드(비동기)에 전달해 줍니다. 그리고 마지막에 스레드풀에 들어가기 전 ThreadLocal을 깔끔하게 청소까지 해주기 때문에 사용자들의 수많은 요청이 들어와도 전혀 문제가 생기지 않습니다.
그럼 지금부터 그 내용을 상세히 알아봅시다. Let's go!!
제가 작성한 예시코드는 하단의 github 주소에 있습니다. 편하게 실험해 보셔도 좋습니다!
https://github.com/wlsdks/security-context-async
GitHub - wlsdks/security-context-async: 스프링 시큐리티에서 @Async를 사용했을때 ContextHolder를 분석하는 예
스프링 시큐리티에서 @Async를 사용했을때 ContextHolder를 분석하는 예시 프로젝트 - wlsdks/security-context-async
github.com
문제사항 파악하기
제가 구성한 스프링부트 프로젝트의 코드를 간단히 설명해 드리겠습니다.
- 프로젝트는 Spring Security를 사용하고 있으며 Jwt인증을 적용하였습니다. http 요청을 보내게 되면 먼저 header에서 JWT를 꺼내서 인증객체를 만들어서 SecurityContextHolder에 세팅하는 작업을 Filter에서 진행하며 이렇게 세팅된 인증객체를 한 요청에서 모두 공유하며 사용하게 됩니다.
src
├── main
│ ├── java
│ │ └── com
│ │ └── example
│ │ └── demo
│ │ ├── DemoApplication.java
│ │ ├── config
│ │ │ ├── AsyncConfig.java
│ │ │ ├── JwtAuthenticationFilter.java
│ │ │ ├── SecurityConfig.java
│ │ │ └── SecurityContextConfig.java
│ │ ├── controller
│ │ │ ├── AsyncController.java
│ │ │ ├── AuthController.java
│ │ │ └── AuthRequest.java
│ │ ├── service
│ │ │ ├── AsyncService.java
│ │ │ └── CustomUserDetailsService.java
│ │ └── util
│ │ └── JwtUtil.java
컨트롤러
- Jwt를 생성하는 작업이 필요해서 AuthController(인증)을 만들었습니다. 여기서는 단순히 회원가입, 로그인을 구현했는데 사실상 db 없이 바로 토큰을 발급하기에 회원가입은 의미가 없긴 합니다. (단순히 모양새를 갖추고 싶어서 만들어두었습니다 ㅎㅎ)
- 하단에 있는 AsyncController를 통해 각 상황별로 테스트를 진행하였습니다. 이 또한 db 없이 바로 테스트가 가능하도록 구성해 두었으니 편하게 사용해 주시면 됩니다. (다만 상황별 세팅은 필요하니 글을 꼭 읽어주세요)
이 프로젝트에서는 db를 사용하지 않았습니다.
- db를 사용하지 않기에 repository 레이어는 존재하지 않으며 service 클래스 내부 필드에 ConcurrentHashMap을 선언하여 임시로 로그인된 데이터를 메모리에 저장하도록 했습니다. (메모리 db 구현)
@Service
public class CustomUserDetailsService implements UserDetailsService {
// 사용자 데이터를 메모리에 저장 (실제 환경에서는 DB 연동 필요)
private final Map<String, UserDetails> users = new ConcurrentHashMap<>();
public CustomUserDetailsService() {
// 테스트용 사용자 등록
users.put("testuser", User.withUsername("testuser")
.password("{noop}password123") // {noop}은 비밀번호 암호화를 생략 (테스트용)
.authorities("USER")
.build());
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = users.get(username);
if (user == null) {
throw new UsernameNotFoundException("User not found: " + username);
}
return user;
}
// 사용자 등록 메서드 (회원가입에서 사용)
public void saveUser(String username, String password) {
users.put(username, User.withUsername(username)
.password(password) // 실제로는 암호화된 비밀번호를 저장해야 함
.authorities("USER")
.build());
}
}
- 코드를 살펴보면 이 클래스는 스프링 시큐리티의 인증에 필요한 UserDetailsService 인터페이스를 구현한 커스텀 인증 서비스 클래스입니다. 이 클래스가 생성될 때 필드에 선언된 users라는 메모리 db(Map)에 테스트 사용자를 미리 등록해 둡니다. 그리고 http 요청이 들어오면 동작하는 JwtFilter에서 서비스 내부에 있는 loadUserByUsername 메서드를 호출해서 이미 생성 시 저장된 테스트 사용자 정보를 받아갈 것입니다.
http 요청마다 실행되는 JwtAuthenticationFilter 코드를 살펴볼까요?
- 위에서 설명했던 것처럼 doFilter 메서드가 호출되면 내부에서는 먼저 Header에 있는 JWT를 추출하고 존재한다면 jwtUtil을 통해 username을 받아와서 userDetailsService(위에 있는)에서 loadUserByUsername 메서드를 호출합니다. 메서드가 호출되면 시큐리티에서 사용되는 UserDetails 객체를 받아옵니다. 이후 인증 토큰(UsernamePasswordAuthenticationToken) 생성을 하고 이 토큰을 SecurityContextHolder에 담아주고 마무리합니다.
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter implements Filter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String token = extractToken((HttpServletRequest) servletRequest);
// 토큰 유효성 검사
if (token != null && jwtUtil.isTokenValid(token)) {
String username = jwtUtil.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
var authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails((HttpServletRequest) servletRequest));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(servletRequest, servletResponse);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
return (bearerToken != null && bearerToken.startsWith("Bearer ")) ? bearerToken.substring(7) : null;
}
}
- 매우 간단합니다. 이렇게 http요청이 들어오면 spring security가 동작하며 jwtFilter를 통해 인증객체를 설정합니다.
시큐리티를 어떻게 설정했는지 궁금하실 분들을 위해 SecurityConfig에 대해서도 간단히 설명드리겠습니다.
- 제가 예전에 적어둔 글이 하나 있는데 최신 버전의 스프링과 Spring Security에서는 필터체이닝을 @Bean으로 등록해서 사용하도록 권장하고 있습니다. 아래와 같이 SecurityFilterChain 클래스를 빈으로 등록해 주시면 됩니다.
- 내부적으로는 간단합니다. 인증을 해주는 Manager를 하나 빈으로 등록하고 비밀번호 인코딩을 위한 클래스도 빈 등록을 해줍니다. 그리고 시큐리티 필터체인 내부에 csrf, session, request, addFilter 이것들을 하나하나 등록해 줍니다. 찾아보시면 이것들 말고도 기능이 참 많습니다.
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/api/**").permitAll()
.requestMatchers("/test/small-async").permitAll()
.anyRequest().authenticated();
})
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Async 전용 스레드풀을 설정합시다.
저는 총 3개의 비동기 전용 스레드풀을 작성하였는데요. 하나씩 설명해 보겠습니다.
먼저 일반적인 비동기 스레드풀입니다.
- 정말 별거 없습니다. 그냥 테스트를 위해서 선언해 둔 일반 스레드풀입니다. 제가 특별히 설정할 것이 없습니다. 만약 이렇게 직접 빈을 세팅하지 않으면 스프링인 기본적으로 @Async를 선언하면 SimpleAsyncTaskExecutor를 사용합니다.
@EnableAsync
@Configuration
public class AsyncConfig {
// 일반적인 AsyncExecutor
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("Async-");
executor.initialize();
return executor;
}
}
다음으로 일부로 스레드풀 개수를 확 줄여서 오류상황을 검증하기 위한 스레드풀을 등록해 봅시다.
- 로컬환경에서는 여러 요청을 보내려면 테스팅 tool을 사용해야 하는데 이번에는 http 파일만으로 해결하려고 했기 때문에 일부로 스레드풀에 1개만 들어있는 일반 스레드풀을 생성하였습니다.
@Bean(name = "smallAsyncExecutor")
public Executor smallAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1); // 스레드풀 크기: 1
executor.setMaxPoolSize(1); // 최대 스레드 개수: 1
executor.setQueueCapacity(5); // 작업 대기열 크기: 5
executor.setThreadNamePrefix("AsyncTest-");
executor.initialize();
return executor;
}
다음으로 이번 글의 핵심이 될 DelegatingSecurityContextExecutor를 등록해 봅시다.
- 조금 다른 것은 일반적인 스레드풀과 동일하게 생성하다 마지막에 return 할 때 DelegatingSecurityContextExecutor를 생성하면서 일반 스레드풀을 담아주는 (감싸주는) 특수한 형태의 스레드풀입니다.
// Delegate 기반 AsyncExecutor (SecurityContext 전파)
@Bean(name = "securityAsyncExecutor")
public Executor securityAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("SecurityAsync-");
executor.initialize();
return new DelegatingSecurityContextExecutor(executor.getThreadPoolExecutor());
}
참고로 클래스 상단에 @EnableAsync 어노테이션을 꼭 적어주세요. 이게 없으면 비동기 동작을 하지 않습니다.
이 비동기 스레드를 어떻게 사용할까요?
어렵지 않습니다. 각 메서드 상단에 @Async를 적고 스레드풀을 빈 등록하면서 지정했던 이름을 사용하시면 됩니다.
- 저는 아래와 같이 서비스 메서드 내부에서 이렇게 @Async를 선언하고 빈으로 등록한 각 스레드풀의 이름을 넣어주었습니다. 이번 목차에서는 스레드풀의 적용 방법만 설명할 것이기에 각 메서드 내부의 코드 구현정보는 생략하였습니다.
@Async
public void executeAsyncTask() {
// ...
}
@Async("asyncExecutor")
public void executeWithDefaultAsync() {
// ...
}
@Async("smallAsyncExecutor")
public void executeAsyncTask(String requestId) {
// ...
}
@Async("securityAsyncExecutor")
public void executeWithSecurityAsync() {
// ...
}
각 스레드풀(Executor)이 어떻게 동작하는지 테스트해 봅시다.
테스트를 위해 아래의 api ("/test/async")를 호출하면 다음과 같은 흐름으로 동작합니다.
@GetMapping("/test/async")
public String testAsync() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// Main Thread에서 SecurityContext 확인
if (authentication != null) {
System.out.println("Main Thread User: " + authentication.getName());
} else {
System.out.println("Main Thread: No SecurityContext");
}
// @Async 메서드 호출
asyncService.executeAsyncTask();
// Default Async (SecurityContext 미전파)
asyncService.executeWithDefaultAsync();
// Security Async (SecurityContext 전파)
asyncService.executeWithSecurityAsync();
return "Async Test Triggered!";
}
1. 클라이언트가 .http 파일에서 첫 번째 테스트 api 호출
2. 스프링 시큐리티의 JwtFilter가 동작해서 메인 스레드의 SecurityContextHolder를 세팅
3. JwtFilter를 거쳐 메인 스레드에 SecurityContextHolder가 세팅되었는지 확인 (로그를 남김)
4. 일반 @Async 메서드 호출 (SecurityContextHolder에 데이터가 있는지 체크해서 로깅)
5. 일반 @Aysnc("asyncExecutor") 메서드 호출 (SecurityContextHolder에 데이터가 있는지 체크해서 로깅)
6. 특수 @Async("securityAsyncExecutor") 메서드 호출 (SecurityContextHolder에 데이터가 있는지 체크해서 로깅)
7. 클라이언트에 응답 반환
제가 기대하는 것은 다음과 같습니다.
위의 흐름을 살펴보면 4,5,6번 과정을 통해 서로 다른 스레드풀을 사용하는 비동기 메서드를 호출하고 SecurityContextHolder의 상태가 어떤지를 각각 확인합니다. 여기서 기대하는 결과는 4,5번은 No SecurityContext라는 로그가 남는 것이고 6번은 저장된 Context를 출력하는 것입니다.
이제 테스트 요청을 보내봅시다. (제발 원했던 결과로 나와주세요!!)
- 저는 테스트를 여러 가지 방식으로 하는데 이번에는 intelliJ의 .http 파일을 사용했습니다. 프로젝트 root 경로에 http 파일을 만들고 아래와 같이 요청 정보를 적어주시면 빠르게 http 관련 테스트가 가능합니다. 참고로 요청은 위에서부터 순서대로 해주시고 로그인해서 받은 jwt를 하단의 token-value를 지운뒤 넣어서 요청하시면 됩니다.
### 계정 생성
POST http://localhost:8340/api/auth/register
Content-Type: application/json
{
"username": "testuser",
"password": "password123"
}
### 로그인
POST http://localhost:8340/api/auth/login
Content-Type: application/json
{
"username": "testuser",
"password": "password123"
}
### 토큰을 이용한 비동기 요청
GET http://localhost:8340/test/async
Authorization: Bearer token-value
http 요청 결과는 다음과 같습니다. (로그 확인)
Main Thread User: testuser
[@Async] Current User: No SecurityContext
[Default Async] Current User: No SecurityContext
[Security Async] Current User: testuser
결과를 분석해 봅시다.
제가 기대했던 결과는 4,5번 흐름에서는 No SecurityContext라는 로그가 남는 것이고 6번은 시큐리티 컨텍스트에 저장된 Context 정보를 출력하는 것입니다. 이번 테스트 결과 제가 기대했던 대로 동작했습니다.
이 결과가 의미하는 것은 일반 @Async에서 사용하는 SimpleAsyncTaskExecutor 스레드풀과 제가 직접 선언한 asyncExecutor 스레드풀은 부모 스레드가 가진 SecurityContextHolder(ThreadLocal) 데이터를 공유하지 않는다는 것입니다.
반면 특수하게 선언해 준 DelegatingSecurityContextExecutor 스레드풀의 경우에는 부모 스레드의 SecurityContextHolder를 자식 스레드인 비동기 스레드에서 그대로 가져다 사용 중인 것을 알 수 있습니다. (cool합니다)
그럼 DelegatingSecurityContextExecutor는 어떤 상황에 유용할까?
이렇게 부모 스레드의 SecurityContext를 비동기 자식 스레드에 전달하는 경우는 몇 가지가 있을 텐데 그중 쉽게 만나실 수 있는 상황이 있습니다. 예를 들어 요즘 jpa를 많이들 사용하실 텐데 엔티티 클래스 내부에 @PrePersist를 사용 중이시면서 SecurityContextHolder안에 들어있는 정보를 꺼내서 특정값에 대한 세팅이 필요한 상황에 특히 유효합니다.
왜냐하면 테스트 결과로 확인할 수 있었듯 스레드풀 설정을 따로 하지 않고 기본 설정대로 @Async를 사용하여 비동기 요청을 진행하면 SecurityContextHolder의 정보가 사라지기 때문에 기존에 가지고 있던 Context 정보가 없어지니 @PrePersist 내부의 메서드에서 SecurityContext에 접근하면 내부의 값을 찾을 수 없어서(null) 분명 로직에서는 NPE 같은 예외가 발생할 것입니다.
참고로 이런 상황은 디버깅하기도 은근히 귀찮습니다.. 대부분 로직의 문제라 생각해서 비즈니스를 살펴보는데 문제는 엉뚱한 곳에서 발견되는 것입니다. 로그가 바로 SecurityContext로 찍히다면 다행인데 @PrePersist를 사용하는 경우 대부분은 transcation 관련 문제로 찍힐 것입니다. (아닐 수도 있습니다 ㅎㅎ)
근데 비동기 스레드에 Context를 전달하는 또 다른 방법이 있었습니다.
제가 회사에서 대선배님께 조언을 받게 되었는데 또 다른 방법이 하나 있었습니다.
- 스프링 시큐리티에는 SecurityContext의 모드를 설정할 수 있습니다. 스프링 시큐리티는 기본적으로 MODE_THREADLOCAL이 적용되어 있습니다. MODE_THREADLOCAL은 스프링 시큐리티의 기본 보안 컨텍스트 저장 방식으로, 인증 정보를 현재 스레드에 저장하고 관리합니다. 이를 통해 각 스레드가 독립적으로 SecurityContext를 유지하며, 다른 스레드와 데이터를 공유하지 않습니다. 이 방식은 멀티스레드 환경에서 안전하게 인증 정보를 처리할 수 있지만, 비동기 작업에서는 기본적으로 ThreadLocal의 데이터를 전달하지 않으므로 위에서 확인한 것과 같이 스레드풀의 추가 설정(예: DelegatingSecurityContextExecutor)이 필요합니다.
음.. 그럼 모드를 뭘로 바꾸면 될까요?
- 여러 모드가 있는데 그중에서 MODE_INHERITABLETHREADLOCAL로 변경해 주면 ThreadLocal의 변형 버전이 적용되어 부모 스레드에서 생성된 값을 자식 스레드와 공유할 수 있게 됩니다. 이 방식은 주로 비동기 처리가 많거나, 부모-자식 관계의 스레드 구조에서 사용된다고 합니다. (이런 게 있다고..??)
저는 제 눈으로 본 동작만 믿으니 이것도 테스트를 해봤습니다.
- 테스트 과정에 대한 설명은 생략합니다. 위의 테스트와 완전히 동일하며(api 동일) 단순히 아래의 모드 설정을 하는 클래스를 추가했습니다. 참고로 꼭 @PostConstruct로 설정해 주세요! 그래야 시큐리티의 모드가 변경되어 적용됩니다.
@Configuration
public class SecurityContextConfig {
@PostConstruct
public void setupSecurityContextStrategy() {
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
System.out.println("SecurityContextHolder Strategy: " + SecurityContextHolder.getContextHolderStrategy());
}
}
이렇게 설정하고 서버를 실행하면 로그에 이렇게 남습니다.
- InheritableThreadLocalSecurityContextHolderStrategy라는 문구가 보이면 성공입니다.
SecurityContextHolder Strategy: org.springframework.security.core.context.InheritableThreadLocalSecurityContextHolderStrategy@60e3c26e
자 그럼 http 요청을 보낸 후 로그를 살펴봅시다.
Main Thread User: testuser
[@Async] Current User: testuser
[Default Async] Current User: testuser
[Security Async] Current User: testuser
- 저는 시큐리티의 모드를 Inheritable로 변경했을 뿐인데 모든 자식 스레드(비동기)가 메인 스레드의 SecurityContext를 공유한다는 것을 확인할 수 있었습니다. 바로 이런 생각이 들었습니다. "미쳤잖아!?? 그냥 이거 쓰면 다 해결되는 거 아닌가?"
아....... 정말 아쉽게도 그건 아니었습니다.
시큐리티 Mode가 Inheritable일 때 발생하는 문제
문제상황을 설명드리겠습니다.
- 모드 Inheritable에서 발생한 문제는 우리가 스레드풀을 사용하기 때문에 발생하는 문제였습니다. 스프링 시큐리티를 사용할 때 각 스레드가 SecurityContextHolder를 사용한다는 것은 이제 이해하셨을 것입니다. 그리고 SecurityContext는 ThreadLocal 기반으로 동작하며 ThreadLocal은 각 스레드가 가지는 고유한 영역입니다.
- 자 그럼 생각해 봅시다. 스레드풀에는 사용했던 스레드가 계속 들어가면서 여러 요청에서 재사용이 될 것입니다. 근데 시큐리티 팀에서 제공해 준 DelegatingSecurityContextExecutor를 스레드풀로 사용한다면 내부에서 SecurityContextHolder를 깨끗하게 청소해 줍니다(Clear). 근데 만약 MODE_INHERITABLETHREADLOCAL만을 사용한다면 과연 각 스레드가 풀에 돌아갈 때 청소될까요? 적어도 제가 테스트를 해봤을 때는 청소를 진행하지 않았고 직접 코드에 clear를 명시해야만 청소가 되었습니다.
자 그럼 스레드풀을 간단히 이해부터 하고 다시 테스트를 진행해 봅시다.
- 스레드 풀(Thread Pool)은 스레드를 미리 생성하여 풀(pool)에 넣어두고, 이를 필요할 때 재사용하여 작업을 처리하는 방식을 의미합니다. 이를 통해 스레드 생성 및 종료에 따른 오버헤드를 줄이고, 자원 사용을 효율적으로 관리할 수 있습니다.
bash 그림으로 이해해 봅시다.
- 하단의 bash에서 [ □ ]는 스레드를 나타내며, 스레드 풀에서 사용할 수 있는 스레드를 의미합니다. 스레드풀의 기본 동작은 다음과 같습니다. 먼저 작업이 Task Queue에 추가됩니다. 그리고 Thread Pool의 유휴 스레드가 작업을 가져가서 실행합니다. 만약 작업이 모두 완료되면 스레드가 대기 상태로 다시 돌아갑니다.
+----------------------------+
| Thread Pool |
| |
| [ □ ] [ □ ] [ □ ] [ □ ] | Active Threads
| |
+----------------------------+
↑
|
Task Queue
↓
[ Job1 ] → [ Job2 ] → [ Job3 ] → [ Job4 ]
스레드풀이 작업을 처리 중인 경우를 봅시다.
- [ ▓ ]는 현재 작업 중(Busy)인 스레드를 나타냅니다. 지금은 스레드 2개가 이미 작업을 처리 중이므로, 해당 작업은 스레드의 작업 처리에 필요한 CPU 자원을 사용하고 있습니다. [ □ ]로 표현된 나머지 스레드 2개는 유휴 상태로, 대기 중입니다. 만약 작업 큐에 새 작업이 들어오면, 이 유휴 스레드가 작업을 가져와 처리합니다. 아직 대기 중인 작업(Job3, Job4, Job5)이 여전히 큐(Task Queue)에 남아 있으며, 순차적으로 처리됩니다.
+----------------------------+
| Thread Pool |
| |
| [ ▓ ] [ ▓ ] [ □ ] [ □ ] | ▓: Busy Threads, □: Idle Threads
| |
+----------------------------+
↑
|
Task Queue
↓
[ Job3 ] → [ Job4 ] → [ Job5 ]
스레드가 모두 바쁜 경우도 봅시다.
- 지금 모든 스레드가 작업 중입니다. 그래서 추가로 들어오는 작업은 즉시 처리되지 못하고, 큐에서 대기하게 됩니다.
+----------------------------+
| Thread Pool |
| |
| [ ▓ ] [ ▓ ] [ ▓ ] [ ▓ ] | All Threads Busy
| |
+----------------------------+
↑
|
Task Queue
↓
[ Job5 ] → [ Job6 ] → [ Job7 ] → [ Job8 ]
- 그림으로만 봐도 어느 정도 스레드풀의 동작을 이해하셨을 텐데요. 기존 스레드가 작업을 완료하고 pool로 돌아간 다음 새로운 작업을 이어받아서 진행합니다. 즉, 우리는 SecurityContext를 사용하고 있는데 SecurityContext를 청소하는 작업이 진행되지 않는다면 스레드풀 개수만큼 요청이 다 진행된 후에는 기존에 세팅되었던 SecurityContext을 계속해서 사용하게 될 것이므로 실제 사용자와 시큐리티 내부의 인증정보가 일치하지 않게 되는 현상이 발생할 것입니다. (엄청 큰 문제죠?)
스레드풀을 줄여서 시큐리티 Mode가 Inheritable인 상황을 테스트하기
이번 테스트는 아주 쉽고 간단합니다.
- 제가 @Async 전용 스레드풀을 여러 개 등록할 때 smallAsyncExecutor라는 이름으로 빈을 등록한 것이 있습니다. 바로 이 테스트를 위해서였습니다. 이제 시큐리티의 context mode를 MODE_INHERITABLETHREADLOCAL로 지정한 다음 아래의 테스트 전용 api (/test/small-async)를 호출해 봅시다.
@GetMapping("/test/small-async")
public String testAsync(@RequestParam String username, @RequestParam String requestId) {
// SecurityContext에 사용자 정보 설정
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(new UsernamePasswordAuthenticationToken(
username, null, Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
));
SecurityContextHolder.setContext(context);
// 현재 사용자 확인
System.out.printf("[Main Thread] Request: %s, Thread: %s, User: %s%n",
requestId, Thread.currentThread().getName(), username);
// 비동기 작업 실행
asyncService.executeAsyncTask(requestId);
return "Async Task Triggered!";
}
이번에는 요청이 조금 다릅니다. 회원가입 로그인 요청 필요 없이도 바로 테스트가 가능합니다.
- 어차피 같은 스레드풀을 사용하기 때문에 아래와 같이 각각 요청만 다르게 해서 5번 보내봅시다.
### small-async 요청1
GET http://localhost:8340/test/small-async?username=UserA&requestId=1
### small-async 요청2
GET http://localhost:8340/test/small-async?username=UserB&requestId=2
### small-async 요청3
GET http://localhost:8340/test/small-async?username=UserC&requestId=3
### small-async 요청4
GET http://localhost:8340/test/small-async?username=UserD&requestId=4
### small-async 요청5
GET http://localhost:8340/test/small-async?username=UserE&requestId=5
결과는 다음과 같습니다.
- 로그가 조금 길다 보니 우측으로 스크롤을 쭉 해주셔야 합니다. 일단 Main 스레드가 남긴 로그를 살펴보면 모두 다른 User의 요청인 것을 알 수 있습니다. 그러고 나서 비동기 스레드인 Async Task가 출력한 로그를 맨 우측으로 스크롤해서 보면 모든 User 정보가 UserA로 동일한 것을 확인할 수 있습니다. 이것 말고도 SecurityContext: 내부의 Principal 값만 확인하셔도 모두 UserA로 세팅되어 있는 것을 확인하실 수 있습니다.
// 첫번째 요청
[Main Thread] Request: 1, Thread: http-nio-8340-exec-1, User: UserA
[Async Task] Request: 1, Thread: AsyncTest-1, SecurityContext: SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=UserA, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]], User: UserA
// 두번째 요청
[Main Thread] Request: 2, Thread: http-nio-8340-exec-3, User: UserB
[Async Task] Request: 2, Thread: AsyncTest-1, SecurityContext: SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=UserA, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]], User: UserA
// 세번째 요청
[Main Thread] Request: 3, Thread: http-nio-8340-exec-4, User: UserC
[Async Task] Request: 3, Thread: AsyncTest-1, SecurityContext: SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=UserA, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]], User: UserA
// 네번째 요청
[Main Thread] Request: 4, Thread: http-nio-8340-exec-5, User: UserD
[Async Task] Request: 4, Thread: AsyncTest-1, SecurityContext: SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=UserA, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]], User: UserA
// 다섯번째 요청
[Main Thread] Request: 5, Thread: http-nio-8340-exec-6, User: UserE
[Async Task] Request: 5, Thread: AsyncTest-1, SecurityContext: SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=UserA, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]], User: UserA
이건 아주 큰 문제입니다.
실제 시스템에서 이런 문제가 발생하면 안 되겠죠? 이것은 어찌 보면 시스템에 대한 사용자의 신뢰도에 문제가 생길 수도 있습니다.
지금은 제가 스레드풀을 1개로 설정했기에 모든 요청에서 UserA의 SecurityContext를 사용 중인데 만약 스레드풀을 200개로 늘린다면 200개 요청이 다 들어온 뒤부터 요청을 보낸 유저는 본인의 데이터가 아니라 랜덤으로 먼저 등록된 200명의 SecurityContext 정보를 받아서 사용할 것입니다.
결론은 스프링 시큐리티에서도 권장하는 DelegatingSecurityContextExecutor를 사용하여 비동기 전용 스레드풀을 등록하여 사용하는 방식을 사용하자는 것입니다. 그러면 알아서 청소도 해주기에 문제가 없을 것입니다.
마무리하며
저는 이 문제를 조금은 특수한 케이스로 보는데요. 대부분 비동기 요청은 비관심사로 동작시키기에 SecurityContext의 정보가 필요 없는 경우가 많습니다. 예를 들어 관심사 로직을 처리한 다음 Spring 이벤트를 발행하고 이 이벤트를 리스너가 받아서 비동기로 로직을 동작시킨다면 이때 개발자는 필요한 정보를 Event 객체에 담아서 보내주면 됩니다.
근데 제가 설명한 경우는 두 개의 스레드가 같은 SecurityContext를 공유해야만 하는 경우에 해당합니다. 그렇기 때문에 모든 분들이 스프링 시큐리티에서 비동기를 사용한다고 해서 이렇게 저처럼 설정하실 필요는 없습니다. 다만 MODE_INHERITABLETHREADLOCAL의 위험성은 인지하고 계시면 좋을 것 같습니다 ㅎㅎ
특히 제가 설명드린 여러 스레드가 같은 SecurityContext를 공유해야 하는 경우에는 메인 스레드와 비동기 스레드가 전혀 다른 스레드로 각각 동작하기에 에러가 발생해도 무슨 문제인지 빠르게 찾지 못하고 헤맬 확률도 조금은 있을 것입니다. 대부분 비동기 스레드는 @Async가 적힌 메서드가 호출될 때만 동작하므로 이런 기능들을 신경 쓰지 않으면 문제점을 놓치기 아주 쉽습니다.
개인적으로는 많은 분들이 관련해서 직접 테스트를 해보고 경험해 보셨으면 좋겠습니다. 더 좋은 방법이 있다면 공유도 해주시면 좋습니다 ㅎㅎ 이 글을 읽어주신 모든 분들께서 조금이라도 도움이 되었으면 하는 마음을 가지며 글을 마칩니다.
긴 글 읽어주셔서 감사합니다 :)
'Spring > Spring Security' 카테고리의 다른 글
[Spring] 스프링 시큐리티 설정이 @Bean 기반 구성으로 바뀐 이유 (0) | 2024.08.23 |
---|---|
Spring Security6 - Authentication(인증) (1) | 2023.10.21 |
Spring Security와 CORS: 크로스 도메인 요청 처리 기법 알아보기 (0) | 2023.10.21 |
Spring Boot 3.1 & Spring Security 6: Security Config 최적화 리팩토링 (12편) (0) | 2023.09.04 |
Spring Boot 3.1 & Spring Security 6: JWT 검증 리팩토링 (11편) (2) | 2023.09.04 |