_z_z_
8월_3주차. Race Condition과 해결 전략, 비동기 환경에서의 MDC 전달 본문
더보기
Q1. 멀티스레드 환경에서 발생하는 대표적인 문제 중 하나인 경쟁 상태(Race Condition)에 대해 설명하고, 이를 해결하기 위한 다양한 전략을 설명해보세요.
Q2. 비동기 환경에서 MDC(Logback Mapped Diagnostic Context)나 SecurityContext 같은 컨텍스트 정보를 스레드 간에 전달해야 할 경우, 처리하는 방법에 대해 설명하세요.
1_1) 레이스 컨디션(Race Condition)?
여러 스레드가 같은 값을 동시에 건드려서, 실행 순서에 따라 결과가 달라지는 문제
- 공유 데이터가 있고
- 동시에 접근하며,
- 순서에 따라 결과가 달라질 때
예시
import java.util.concurrent.*;
public class RaceDemo {
static int value = 0; // 공유 변수 (의도적으로 안전하지 않게)
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100; i++) {
pool.submit(() -> {
for (int j = 0; j < 1000; j++) {
value++; // (읽기) -> (더하기) -> (쓰기) 로 쪼개져 원자 아님
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("결과: " + value); // 종종 100_000보다 작게 찍힘
}
}
// value++ 는 내부적으로 이렇게 쪼개짐
int tmp = value; // 1. 읽기
tmp = tmp + 1; // 2. 더하기
value = tmp; // 3. 쓰기
// 업데이트 유실 시나리오
1. A가 value를 읽음
2. B도 value를 읽음
3. A가 더함 -> 1
4. B가 더함 -> 1
5. A가 씀 -> value = 1
6. B가 씀 -> value = 1 (A의 증가가 덮여서 사라짐)
>> 기대값 2 대신 1이 되어 업데이트 유실
>1. synchronized로 감싸거나
>2. AtomicInteger.incrementAndGet()이나 LongAdder.increment()와 같은 원자적 증가로 한번에 처리해야함
- 자주 보이는 패턴
- lost Update(업데이트 유실): x++가 사라짐
- Check-then-Act(체크 후 행동): if(!~) {..} 확인과 행동 사이에 다른 스레드가 끼어듦
- TOCTOU(Time Of Check to Time Of Use): 검사 시점과 사용 시점 사이 상태 변경
- 가시성 문제: 한 스레드에서의 변경이 다른 스레드에서 바로 보이지 않음
1_2) 해결 전략
A. 공유 변경을 없애기(아예 같이 안 건드리기)
- 불변(Immutable) 객체: 한 번 만들면 못 바꾸게, 바꿀 땐 새 인스턴스를 만들어 통째로 교체
- 스레드 컨파인먼트: 스레드마다 로컬로 계산하고(따로 계산하고) 마지막에만 합치기 (Map-Reduce 스타일)
- 메시지 패싱/큐: 데이터를 공유하지 말고 메시지로 전달(프로듀서–컨슈머, 액터 모델)
- 사용 시기: 읽기 위주, 설정 값, 캐시 스냅샷 등.
B. 락/동기화로 임계구역 보호(한 스레드만 들어오게 하기)
- synchronized / ReentrantLock
- 중요부분에서 한 번에 한 스레드만(상호배제)
- 락 입장/퇴장 시점에 메모리 가시성 보장(안에서 바꾼 값이 다른 스레드에 보이게)
- 재진입 가능(재귀 호출 가능)
- 사용 시점
- 단순 증감이 아닌 여러 연산을 한 덩어리로 묶어야 할 때
- ex) 증가 → 임계치 넘으면 알림 → 다른 공유 상태도 수정
- 여러 변수를 함께 다룰 때
- 간단히 쓰려면 synchronized, 세밀한 제어(타임아웃/공정성/조건대기)는 ReentrantLock
class Counter {
private final Object lock = new Object(); // 전용 락
private int value = 0;
public int incAndGet() {
synchronized (lock) {
value++;
if (value == 1000) notifySomething();
return value;
}
}
}
- 주의
- 락 구간은 짧고 작게(병목 방지)
- this 나, 공개된 객체, String/박싱 숫자 등에 락을 걸면 의도치 않게 다른 코드와 같은 락을 공유해 성능 저하나 데드락이 날 수 있음
- → private final Object lock = new Object(); 같은 전용 락 권장.
- 공정성/타임아웃/인터럽트 대응이 필요하면 ReentrantLock 고려.
C. 원자 변수(CAS)와 동시성 컬렉션(안전한 자료구조로 교체)
- LongAdder.increment()
- 내부적으로 셀을 여러 개로 분산(스트라이핑) 해서 서로 다른 스레드가 각자 셀을 갱신하여 경합을 완화
- sum() 할때 각 셀을 모아 합산
- 사용 시점
- 아주 많은 스레드가 카운트를 올리는 통계/모니터링 용도
import java.util.concurrent.atomic.LongAdder; class ThroughputCounter { private final LongAdder adder = new LongAdder(); void inc() { // 경합 분산, 매우 빠름(고경합) adder.increment(); } long value() { // 여러 셀 합산 return adder.sum(); } } - 주의
- sum()은 그 순간의 스냅샷을 합친 것 → 동시에 증가 중인 값이 바로 반영되지 않을 수 있음
- 결정로직(값이 1000넘는 순간 알람) 에는 부적합
- → AtomicLong + updateAndGet/accumulateAndGet 또는 synchronized로 사용해야 함
- 고유 ID 발급처럼 값의 연속성/순서가 필요한 경우에도 부적합
- → AtomicLong.getAndIncrement() 사용
D. 데이터베이스 레벨 잠금/트랜잭션(db에서 잠그기/검사하기)
- 비관적 락: SELECT ... FOR UPDATE
- 경합 심할 때 안전하지만 대기/교착 위험, 확장성 떨어짐(대기 길어질 수도)
- 낙관적 락: @Version 칼럼으로 충돌 감지 후 재시도
E. 설계 레벨에서의 안전장치
- 아이템포턴시: 같은 요청이 여러 번 와도 결과가 같도록
- 락 순서 규약: 여러 락을 잡을 땐 항상 같은 순서로(데드락 회피)
- 타임아웃/백오프 재시도: 무한 대기 금지
1_3) 자주 만나는 상황
| 상황 | 틀린 접근 | 안전한 접근 |
| 증가/감소 카운터 | x++ | LongAdder.increment() |
| “없으면 넣기” 캐시 | if (!map.containsKey) put | map.computeIfAbsent(key, k -> load()) |
| 설정/목록 갱신 | 리스트에 직접 추가 | 새 리스트를 복사해 수정 → 불변으로 교체 |
| 재고 차감 | 단순 UPDATE | DB 락(짧게) 또는 낙관적 락 + 재시도 |
2) 비동기(@Async, 스레드풀) 환경에서 MDC / SecurityContext 전파
MDC(로그용 문맥)와 SecurityContextHolder는 기본적으로 ThreadLocal 기반
둘 다 ThreadLocal에 담기고
스레드가 바뀌면 그 값이 따라가지 않음
그래서 비동기 로그에 traceId가 빠지거나, 인증정보가 null이 되며 securityContext가 사라짐
방법 A) Spring TaskDecorator
- 제출 시점에 현재 스레드의 MDC와 SecurityContext를 복사
- 실행 스레드에 심고
- 끝나면 깨끗이 비워 스레드풀 오염을 방지함
설정 순서
- 데코레이터 클래스를 만들고, (컨텍스트 복사/주입/정리 데코레이터 클래스)
- ThreadPoolTaskExecutor.setTaskDecorator(…)로 연결 (스레드풀에 장착)
- @Async(”그 실행기”) 또는 CompletableFuture.supplyAsync(…, 그 실행기)로 같은 실행기 사용
[ ] 같은 executor를 쓰고 있는지(thenApplyAsync에서 다른 executor 쓰면 끊김)
[ ] 실행 후 MDC.clear()/SecurityContextHolder.clearContext()로 정리했는지
[ ] 로그 패턴에 %X{traceId}와 같은 MDC 키가 잡히는지
// 1) 컨텍스트 복사/주입/정리 데코레이터
@Component
public class ContextTaskDecorator implements TaskDecorator {
public Runnable decorate(Runnable task) {
var mdc = MDC.getCopyOfContextMap();
var sec = SecurityContextHolder.getContext();
return () -> {
try {
if (mdc != null) MDC.setContextMap(mdc); else MDC.clear();
SecurityContextHolder.setContext(sec);
task.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
}
};
}
}
// 2) 스레드풀에 장착
@Configuration @EnableAsync
public class AsyncConfig {
@Bean
public ThreadPoolTaskExecutor appExecutor(ContextTaskDecorator d) {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setTaskDecorator(d);
ex.initialize();
return ex;
}
}
// 3) 사용
@Async("appExecutor")
public void work() {
log.info("user={}, trace={}",
SecurityContextHolder.getContext().getAuthentication().getName(),
MDC.get("traceId"));
}
대안(상황별)
- Spring Security 래퍼: DelegatingSecurityContextRunnable/Callable(보안만 전파) → MDC는 별도 처리 필요
- MODE_INHERITABLETHREADLOCAL: 자식 스레드에만 상속, 스레드풀 재사용에 부적합(누수 위험) → 권장 X
- TTL 라이브러리: TransmittableThreadLocal(여러 스레드풀 지원). Spring 표준이면 보통 TaskDecorator로 충분
Reactor(WebFlux) 참고
- 보안: ReactiveSecurityContextHolder
- MDC: Reactor Context ↔ MDC 브리지 필요(또는 Micrometer Context Propagation)
'쪼드잇 > 위클리페이퍼' 카테고리의 다른 글
| 8월_5주차. TCP/IP와 OSI 모델, 전송 계층에서의 TCP와 UDP (3) | 2025.09.01 |
|---|---|
| 8월_4주차. Cache 어노테이션의 차이점, 로컬 캐시와 분산캐시 (8) | 2025.08.24 |
| 8월_2주차. Spring 주요 보안공격과 대응전략, JWT의 구조와 구성 요소 (6) | 2025.08.11 |
| 8월_1주차. 세션과 토큰 기반 인증, QAuth 2.0 (3) | 2025.08.03 |
| 7월_1주차. AWS RDS와 EC2, GitHub 트리거 (5) | 2025.06.30 |