이번주 위클리 페이퍼의 주제는
멀티스레드 환경에서 발생하는 대표적인 문제 중 하나인 경쟁 상태(Race Condition)에 대해 설명하고, 이를 해결하기 위한 다양한 전략을 설명해보자.
비동기 환경에서 MDC(Logback Mapped Diagnostic Context)나 SecurityContext 같은 컨텍스트 정보를 스레드 간에 전달해야 할 경우, 처리하는 방법에 대해 설명해보자.
경쟁 상태
공학 분야에서 경쟁 상태(race condition)란?
둘 이상의 입력 또는 조작의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 말한다.
멀티 스레드 상황에서의 경쟁 상태는
둘 이상의 스레드가 동시에 같은 자원(예: 변수, 객체, 메모리 영역 등)에 접근하고 수정하려고 할 때, 실행 순서에 따라 결과가 달라지는 문제이다.
이를 해결하기 위한 전략
1. 동기화 사용
멀티 스레드 환경에서 동기화는 원자성과 순서를 보장한다.
원자성: 연산이 실행중일 때 중간에 다른 스레드가 끼어들지 않도록 함.
순서: 한 연산이 끝났다면 그 다음 연산은 반드시 이전 연산의 결과를 보장받음.
- 뮤텍스/락
가장 단순하고 명확한 방법이다.
상호배제를 구현하기 위한 동기화 방법 중 하나.
여러 개의 스레드가 공유 자원에 동시에 접근하는 것을 막아준다.
일반적으로 스레드가 사용하려는 자원에 Lock을 걸고 사용이 끝난 후 Unlock 상태로 만들어 다른 스레드의 접근을 막는 패턴을 사용한다.
- 세마포어
동시에 접근할 수 있는 스레드의 개수를 제한하는 동기화 도구
이진 세마포어 (Binary Semaphore)는 뮤텍스 처럼 동작한다. (한 번에 한 스레드만 혀용)
카운팅 세마포어 (Counting Semaphore)는 제한된 개수의 자원을 관리할 때 유용하다.
자원 개수 제한이 필요한 상황(DB 커넥션, 파일 핸들, 쓰레드 풀 등)에 적합하다.
허용하는 스레드 수를 잘못 설정하면 과도한 대기 또는 자원 초과 사용 같은 문제가 발생할 수 있다.
2. 원자적 연산
멀티스레드 환경에서 경쟁 상태가 생기는 이유는, 어떤 연산이 내부적으로 여러 단계로 쪼개져 실행되는데 그 사이에 다른 스레드가 끼어들 수 있기 때문이다.
원자적 연산은 이런 과정을 쪼개지 않고 한 번에 수행되도록 보장하는 연산이다.
현대 CPU는 CAS(Compare-And-Swap), Test-and-Set 같은 원자적 명령어를 제공한다.
락-프리(lock-free) 자료구조 - 원자적 연산을 활용해 동시 접근에도 안전한 큐, 맵, 리스트 등을 구현하여 여러 스레드가 동시에 접근해도 안전하게 작동하도록 한다.
비동기 환경에서의 컨텍스트 정보 전달
비동기/멀티 스레드에서 MDC와 SecurityContext는 기본이 TreadLocal 기반이라 스레드가 바뀌면 정보가 끊어지는 문제가 발생한다.
<정보가 끊어지는 문제?>
MDC (Mapped Diagnostic Context, Logback/SLF4J)
- 로그에 붙일 정보를 ThreadLocal에 보관한다.(예: traceId, sessionId, userId, 요청 ID 등)
- 로그 패턴에 %X{traceId}처럼 넣으면, 해당 스레드의 MDC에 들어있는 값이 출력된다.
- 문제: 요청 처리 도중 스레드가 바뀌면 MDC 값이 새 스레드로 전파되지 않아 로그에 traceId가 빠지거나 잘못 찍힐 수 있다.
SecurityContext (Spring Security)
- 현재 사용자의 인증(Authentication) 객체를 ThreadLocal에 저장한다.(예: 로그인한 Principal, 권한(Role) 목록, 인증 상태 등)
- 문제: 비동기 실행 시 다른 스레드에서는 SecurityContextHolder.getContext()가 비어 있어서 → 인증 정보가 없는 익명 사용자처럼 보일 수 있다.
이런 문제를 해결하기 위해 Spring은 TaskDecorator 인터페이스를 제공한다.
이 인터페이스를 구현하여 비동기 작업을 실행하기 전후에 필요한 컨텍스트 정보를 설정하고 저장할 수 있다.
TaskDecorator 인터페이스를 사용해 비동기 처리에서 MDC를 사용하는 방법
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import java.util.Map;
/**
* 비동기 작업에 제출 시점의 MDC를 그대로 전달해 주는 단순한 데코레이터.
*/
public class SimpleMdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
// 현재 스레드(MDC)의 컨텍스트 스냅샷
final Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
// 실행 스레드에 MDC 설정
if (context != null) {
MDC.setContextMap(context);
}
try {
task.run();
} finally {
// 작업이 끝나면 정리(풀 스레드 오염 방지)
MDC.clear();
}
};
}
}
참고: https://f-lab.kr/insight/spring-async-and-mdc
@Async를 사용해 SecurityContext 전파하는 방법
1. @EnableAsync를 통해 비동기를 활성화 한다.
@Configuration
@EnableAsync
public class AsyncConfig { }
2. ThreadPoolTaskExecutor를 생성하고 DelegatingSecurityContextAsyncTaskExecutor로 래핑하여 전파 가능한 스레드풀 빈을 등록한다.
@Bean(name = "securityExecutor")
public AsyncTaskExecutor securityExecutor() {
ThreadPoolTaskExecutor base = new ThreadPoolTaskExecutor();
base.setCorePoolSize(8);
base.setMaxPoolSize(16);
base.setQueueCapacity(200);
base.setThreadNamePrefix("sec-");
base.initialize();
// SecurityContext 전파 래핑
return new DelegatingSecurityContextAsyncTaskExecutor(base);
}
3. @Async로 지정
메서드에 @Async("securityExecutor")로 등록한 빈 이름을 명시한다.
@Service
public class SampleService {
@Async("securityExecutor")
public void doAsyncWork() {
// 이 블록 안에서는 호출 시점의 SecurityContext(Principal, 권한 등)가 그대로 보인다.
}
}
참고: https://curiousjinan.tistory.com/entry/spring-security-async-context
이 방법 외에도 SecurityContext의 모드를 사용하는 방법이 있다고 한다.
SecurityContext는 시큐리티 인증 정보를 담은 컨텍스트를 어디에 저장할지 모드로 관리한다.
- MODE_THREADLOCAL (기본값)
- ThreadLocal을 이용해 현재 스레드 단위로 SecurityContext를 저장.
- 각 스레드가 자기만의 인증 정보를 독립적으로 가지고 있어, 다른 스레드와 충돌하지 않음.
- 웹 요청 처리처럼 하나의 요청 = 하나의 스레드인 상황에서는 안전하고 직관적.
- MODE_INHERITABLETHREADLOCAL
- 자식 스레드를 새로 만들면 부모 스레드의 SecurityContext를 그대로 물려받음.
- 하지만 스레드 풀처럼 재사용되는 스레드 환경에서는 제대로 동작하지 않아 잘 쓰이지 않음.
- MODE_GLOBAL (거의 사용하지 않음)
- 모든 스레드가 같은 SecurityContext를 공유.
- 테스트 용도나 특수한 경우 외에는 위험하므로 권장되지 않음.
위 처럼 모드가 있기 때문에 MODE_INHERITABLETHREADLOCAL로 변경한다면 부모 스레드에서 생성된 값을 자식 스레드와 공유할 수 있게 된다.
하지만 InheritableThreadLocal은 자식 스레드가 새로 생성될 때만 부모의 값을 복사한다고 한다.
대부분의 비동기 처리는 스레드를 새로 만드는 게 아니라 풀에서 꺼내어 재사용하는 방식이기 때문에 부모 자식 간의 전파가 일어나지 않아 정보가 끊기게 된다.
또한 전파 방식을 세밀하게 제어할 수 없고 스레드 풀의 스레드가 계속 살아있으므로 스레드 반납 시 SecurityContext를 제대로 정리하지 않는다면 다음 요청에서 다른 사용자의 SecurityContext가 보일 수 있다.
따라서 MODE_INHERITABLETHREADLOCAL는 단순히 새 스레드를 직접 생성하여 사용하는 작은 테스트 코드에선 편리하게 보일 수 있으나 실제 환경에선 거의 사용되지 않는다.
'Weekly Paper' 카테고리의 다른 글
| [위클리 페이퍼] OSI 7 Layers & TCP/IP 4 Layers; TCP & UDP (1) | 2025.08.31 |
|---|---|
| [위클리 페이퍼] 세션, 토큰 기반 인증과 OAuth 2.0 (3) | 2025.08.03 |
| [위클리 페이퍼] AWS RDS, GitHub Actions CI/CD (0) | 2025.07.07 |
| [위클리 페이퍼] 컨테이너와 Docker, 컨테이너 오케스트레이션 (0) | 2025.06.29 |
| [위클리 페이퍼] JPA의 N+1문제, 트랜잭션의 ACID (1) | 2025.06.15 |