[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. 해결책 — AtomicBooleancompareAndSet (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 개발자가 기억할 것

 volatileAtomicBoolean
해결하는 문제가시성 (최신 값 보장)가시성 + 원자성
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를 아무도 못 끼어드는 하나의 동작"으로 만들어준다.