[TIL] 멀티스레드 Race Condition과 AtomicBoolean (volatile의 한계)
멀티스레드 환경에서 volatile의 한계(가시성 vs 원자성)와 check-then-act 레이스 컨디션을 AtomicBoolean.compareAndSet(CAS)으로 해결하는 방법을 정리했습니다.
📝 TIL (Today I Learned) \n+>
volatile만으로는 해결되지 않는 문제가 있다. "가시성"과 "원자성"은 다르고,check-then-act패턴이 섞이면 레이스 컨디션이 터진다. 이걸AtomicBoolean.compareAndSet으로 정리해 본다.
🍀 새롭게 배운 것
volatile은 가시성(visibility) 은 해결하지만, 원자성(atomicity) 은 해결하지 못한다.- "읽고 → 조건 확인하고 → 쓰는"
check-then-act는 멀티스레드에서 레이스가 나기 쉽다. AtomicBoolean.compareAndSet(true, false)는 한 번만 상태 전환되도록 만들어 주는 대표 패턴이다. (CAS 기반)
1. 배경: 왜 이런 문제가 생기는가?
Spring WebFlux는 비동기·논블로킹 스택이고, 소수의 Netty I/O 스레드가 많은 작업을 번갈아 처리한다. 그래서 같은 객체의 메서드가 서로 다른 스레드에서 거의 동시에 호출되는 상황이 자연스럽게 발생한다.
예를 들어 GeminiLiveSession에서 아래 흐름이 동시에 일어날 수 있다.
[Netty I/O 스레드 A] → Gemini로부터 오디오 수신 → handleIncoming() → session.onError()
[Netty I/O 스레드 B] → WebSocket 연결 끊김 감지 → disconnect() → session.close()
close()와 onError()가 동시에 호출될 수 있고, 이때 둘 다 "세션 종료"를 시도하면 문제가 생긴다.
2. 문제 1 — 가시성 (Visibility)
멀티코어 CPU는 성능을 위해 각 코어마다 캐시(L1/L2 Cache) 를 가진다. 그래서 어떤 스레드가 값을 바꿔도, 다른 스레드는 캐시에 남아 있는 오래된 값을 읽을 수 있다.
[CPU 코어 A] [CPU 코어 B]
캐시: active = true 캐시: active = true
↑ RAM 반영 전 ↑ RAM 반영 전
RAM: active = false (이미 변경됨)
코어 A가 active = false로 바꿨더라도, 코어 B는 여전히 true를 볼 수 있다. 이게 가시성 문제다.
volatile은 가시성을 해결한다
private volatile boolean active = true;
volatile은 “이 변수를 읽고 쓸 때 항상 메인 메모리(RAM)에서 직접 읽고 써라”는 JVM에 대한 명령입니다.
[CPU 코어 A] RAM [CPU 코어 B]
write active=false ──→ RAM 즉시 반영
RAM에서 직접 읽음 ←── read active
코어 B는 항상 최신 값을 봅니다. 가시성 문제 해결!
3. 문제 2 — Check-Then-Act Race Condition
volatile로 가시성을 해결해도, 더 근본적인 문제가 남는다. 바로 check-then-act 패턴이다.
// volatile boolean 버전
public void close() {
if (!active) return; // ① check: active 읽기
active = false; // ② act: active 쓰기
outboundSink.tryEmitComplete();
inboundAudioSink.tryEmitComplete();
}
①과 ②는 서로 다른 연산이다. 그 사이에 다른 스레드가 끼어들 수 있다.
문제가 되는 시나리오
시간 →
스레드 A (close 호출) 스레드 B (onError 호출)
────────────────────────────────────────────────────
① active 읽음 → true
① active 읽음 → true ← volatile이라 최신값(true) 보임
② active = false 씀
② active = false 씀 ← 이미 false지만 또 씀 (괜찮음)
outboundSink.tryEmitComplete()
outboundSink.tryEmitError() ← 💥 이미 complete된 sink에 error!
inboundAudioSink.tryEmitComplete()
inboundAudioSink.tryEmitError() ← 💥 또 충돌!
두 스레드 모두 ①에서 true를 읽고 통과한다. volatile은 최신 값을 보장하지만, “내가 읽은 순간부터 내가 쓸 때까지 다른 스레드가 건드리지 않는다”는 보장은 없다.
이것이 Check-Then-Act Race Condition
- Check:
if (!active)— 조건 확인 - Then-Act:
active = false— 상태 변경
그래서 check-then-act가 원자적(Atomic) 이지 않으면 레이스 컨디션이 발생한다.
💡 원자성(Atomicity): "쪼갤 수 없는 한 덩어리"처럼 실행되는 성질. 중간에 다른 스레드가 끼어들 수 없음.
4. 해결책 — AtomicBoolean과 compareAndSet (CAS)
AtomicBoolean이란?
java.util.concurrent.atomic 패키지의 클래스로, boolean에 대해 원자적 연산을 제공한다. 내부적으로 CPU의 CAS(Compare-And-Swap) 를 사용한다.
compareAndSet(expect, update)이 하는 일
atomicBoolean.compareAndSet(true, false)
이 한 줄이 하드웨어 수준에서 원자적으로 실행하는 작업:
① 현재 값을 읽는다
② 현재 값 == expect(true) 이면?
→ 값을 update(false)로 바꾸고 true 반환
③ 현재 값 != expect(true) 이면?
→ 아무것도 안 하고 false 반환
이 세 단계가 나눌 수 없는 하나의 연산으로 실행된다. 중간에 다른 스레드가 절대 끼어들 수 없다.
같은 시나리오를 다시 보면
// AtomicBoolean 버전
public void close() {
if (!active.compareAndSet(true, false)) return; // 원자적으로 check + act
outboundSink.tryEmitComplete();
inboundAudioSink.tryEmitComplete();
}
시간 →
스레드 A (close 호출) 스레드 B (onError 호출)
───────────────────────────────────────────────────────────
compareAndSet(true→false) 성공 ✅
→ active가 false로 변경됨
→ true 반환, 계속 실행
compareAndSet(true→false) 실패 ❌
→ 현재값이 이미 false
→ false 반환, return으로 탈출
outboundSink.tryEmitComplete() (아무것도 안 함)
inboundAudioSink.tryEmitComplete() (아무것도 안 함)
스레드 A만 통과하고, 스레드 B는 자동으로 탈출한다. 정확히 하나만 실행!
5. 최종 코드 비교
// ❌ Before: volatile — 가시성만 해결, race condition 존재
private volatile boolean active = true;
public void close() {
if (!active) return; // check
active = false; // act ← 이 사이에 다른 스레드 침입 가능
outboundSink.tryEmitComplete();
inboundAudioSink.tryEmitComplete();
}
public void onError(Throwable error) {
if (!active) return; // 두 스레드 모두 통과 가능
active = false;
outboundSink.tryEmitError(error); // close()와 충돌
inboundAudioSink.tryEmitError(error); // close()와 충돌
}
// ✅ After: AtomicBoolean — 가시성 + 원자성 모두 해결
private final AtomicBoolean active = new AtomicBoolean(true);
public void close() {
if (!active.compareAndSet(true, false)) return; // check + act = 원자적
outboundSink.tryEmitComplete(); // 오직 하나의 스레드만 도달
inboundAudioSink.tryEmitComplete();
}
public void onError(Throwable error) {
if (!active.compareAndSet(true, false)) return; // 나머지 스레드는 여기서 탈출
outboundSink.tryEmitError(error);
inboundAudioSink.tryEmitError(error);
}
이렇게 하면 close()와 onError() 중 둘 중 하나만 본문을 실행한다. 나머지는 compareAndSet이 실패하면서 즉시 return 된다.
즉, "세션 종료"라는 상태 전환이 정확히 한 번만 일어나게 된다.
6. 정리: Spring 개발자가 기억할 것
volatile | AtomicBoolean | |
|---|---|---|
| 해결하는 문제 | 가시성 (최신 값 보장) | 가시성 + 원자성 |
| check-then-act | ❌ 안전하지 않음 | ✅ compareAndSet으로 안전 |
| 주요 용도 | 단순 플래그 읽기/쓰기 | 상태 전환이 딱 한 번 일어나야 할 때 |
언제 AtomicBoolean을 써야 하나?
"읽고 → 조건 확인하고 → 쓰는" 흐름이 있고, 그 흐름이 한 번만 일어나야 한다면 AtomicBoolean을 먼저 떠올리면 좋다.
// 이런 패턴이 보이면 AtomicBoolean 고려
if (!flag) return; // check
flag = true; // act
doSomethingOnce(); // 딱 한 번만!
실제로 Spring 애플리케이션에서는 이런 상황이 자주 등장한다.
- 리소스를 딱 한 번만 초기화/해제할 때
- WebSocket/SSE 연결을 한 번만 닫을 때
- 이벤트를 중복 없이 발행할 때
📌 핵심 한 줄 요약 \n+>
volatile은 "다른 스레드가 쓴 값을 내가 볼 수 있게" 해주고, \n+>AtomicBoolean.compareAndSet은 "check-then-act를 아무도 못 끼어드는 하나의 동작"으로 만들어준다.