경쟁 상태(Race Condition)의 본질과 해결 전략

Jun_k·2026년 4월 12일

Java

목록 보기
7/7

멀티스레드를 사용하는 이유

현대 CPU는 여러 개의 코어를 가지고 있으며, 백엔드 서버(Spring Boot 등)는
수많은 요청을 동시에 처리해야 한다.
처리량을 극대화하기 위해서 하나의 프로세스 내에서 여러 개의 스레드
자원을 공유하며 병렬로 작업을 한다.

경쟁 상태 (Race Condition)

경쟁 상태란,
"두 개 이상의 스레드가 공유 데이터에 동시에 접근하여 변경하려고 할 때,
실행 순서에 따라 결과가 달라지는 현상" (결과가 그때그때 달라짐.)

예시 (count++)
우리 눈으로 보기에는 자바 코드 상에서 count++ 한 줄이지만,
CPU 입장에서 봤을 때 아래의 3단계를 거쳐야 한다.

  1. READ: 메모리에 저장된 count 값을 읽어 CPU 레지스터로 가져온다.

  2. MODIFY: 레지스터에 있는 값에 1을 더한다.

  3. WRITE: 계산된 결과를 다시 메모리에 저장한다.

경쟁 상태로 인한 문제 발생

  • 스레드 A와 B가 count=0인 상태에서 동시에 접근한다는 가정.
  1. 스레드 A: 메모리에서 0을 읽음 (READ)

  2. 스레드 B: 메모리에서 0을 읽음 (READ) -> A가 아직 저장하지 않음

  3. 스레드 A: 0 + 1 = 1 -> 계산 (MODIFY)

  4. 스레드 B: 0 + 1 = 1 -> 계산 (MODIFY)

  5. 스레드 A: 메모리에 1 저장 (WRITE)

  6. 스레드 B: 메모리에 1 저장 (WRITE)

  • 최종 결과: 정상 흐름이라면 count2가 되어야 하지만, 실제로는 1이 된다.

  • 스레드 A의 시점: "내가 읽었을 때 0이었으니까,
    여기에 1을 더해서 1을 저장해야지."

  • 스레드 B의 시점: "나도 읽었을 때 0이었어.
    그러니 나도 1을 더해서 1을 저장할게."

  • 이처럼 작업이 채 끝나지 않았는데 중간에 다른 스레드가 개입하면서
    약간 딱딱하게 말하면 원자성이 깨졌다고 하고,
    쉽게 말하면 데이터가 꼬여버리는 것이다.


해결을 위한 4가지 방법

  1. 격리: ThreadLocal (공유 자체를 차단)
  • 공유를 아예 막아버리면 경쟁 자체도 발생하지 않는다.

  • ThreadLocal은 각 스레드에게 자신만의 전용 저장소를 할당한다.

  • 적용: 사용자 세션 정보, 트랜잭션 컨텍스트 등
    (@Transactional이 붙은 메서드를 실행하면 내부적으로 TransactionSynchronizationManager 실행되고, 이 클래스 내부적으로
    ThreadLocal을 사용하여 트랜잭션 정보를 보관한다.
    당연히 트랜잭션 정보는 사용자끼리 섞이면 안되기 때문에
    ThreadLocal로 관리되어야 한다.)

  • 스레드 풀 환경에서는 작업 종료 후 반드시 .remove()를 호출해야
    보안 문제나 메모리 누수를 막을 수 있다.
    (스레드는 재사용되기에 .remove를 해서 이전 내용물을 비워줘야 한다. 안 비우면 사용자 A의 요청을 스레드 1번이 처리해서
    이 안에 A의 데이터가 들어있을텐데 비우지 않으면 일단 스레드 1번이 스레드 풀로 반납이 되고, 다만 그 안에 담긴 데이터는 스레드와 운명을 같이하며 지워지지 않기에 나중에 사용자 B가 마침 반납된 스레드 1번을 받았을때 이전 사용자 A의 정보가 그대로 들어있게 된다.
    당연히 메모리도 차지하고 있기에 누수 문제도 발생한다.)

  1. 불변성: Record & Final (수정 자체를 차단)
  • 상태가 변하지 않는다면, 여러 스레드가 읽기만 하므로 안전하다.
  • record 키워드를 사용하여 DTO를 설계하면 모든 필드가 final이 되어
    데이터 정합성이 보장된다.
// 데이터 수정 불가
public record UserCounter(int count) { } 
  1. Atomic 클래스 (비차단 제어)
  • Lock을 걸지 않고도 CPU 레벨에서 원자성을 보장하는
    CAS(Compare-And-Swap) 알고리즘을 사용한다.

  • 내가 알고 있는 정보가 여전히 유효한지 끝까지 확인하는 방식

  • CAS 알고리즘의 논리 구조

    • 현재 메모리 값(VV)을 읽는다.

    • 내가 예상한 값(EE)과 현재 값(VV)이 같은지 비교한다.

    • V=EV = E라면 새로운 값(NN)으로 교체한다.
      아니라면 다시 1단계로 돌아간다(Loop).

    • 장점: 스레드를 멈추게 하는(Blocking) 비용이 없어 성능이 우수하다.

  • 상황 가정: count++ (현재 값 10)

    1. 읽기 (Read): 스레드 A가 메모리를 보니 10이다.
      • 이때 스레드 A는 메모해 둔다. "내가 본 값(EE)은 10이다."

    2. 연산 (Modify): 스레드 A가 자기 안에서 10 + 1을 해서 11을 만든다.
      • 이때 바꾸고 싶은 값(NN)이 11이 된다.

    3. 검증 (Compare): 이제 메모리에 11을 쓰기 직전,
      CPU가 메모리의 진짜 값(VV)을 다시 확인한다.

    • 질문: "지금 메모리에 있는 값(VV)이, 내가 아까 봤던 값(E=10E=10)과 똑같니?"

    • 왜 이 질문을 하나?: 만약 그 사이 다른 스레드가 값을 11로 바꿔버렸다면, 메모리의 진짜 값(VV)은 11이 되어 있을 것이다.
      그러면 V(11)E(10)V(11) \neq E(10)이 되므로,
      "아! 누군가 그새 값을 바꿨구나!"라고 판단하고 작업을 취소하는 것이다.
  1. Synchronized & Lock (직접 제어)
  • 특정 영역(Critical Section)에 한 번에 하나의 스레드만
    들어오도록 문을 잠그는 방식이다.

  • 자원을 사용하려는 스레드가 아예 문을 걸어 잠그는 것

  • Synchronized: 자바의 고유 락(Monitor Lock)을 이용한 간단한 해결책.

  • ReentrantLock: 더 세밀한 제어(공정성 설정, 타임아웃 등)가
    필요할 때 사용한다.

  • 단점: 과도한 사용은 스레드 대기를 유발하여
    시스템 전체의 성능 하락을 초래한다.

  • 비유: 1인용 공용 화장실

    • 스레드 A가 화장실에 들어가면서 문을 잠근다. (Lock 획득)

    • 뒤이어 온 스레드 B는 문이 잠겨 있으니 화장실 앞에서 줄 서서 기다린다.
      (Blocking/Wait)

    • 스레드 A가 일을 다 보고, 문을 열고 나온다.

    • 줄 서 있던 스레드 B가 그제야 들어간다.

  • 성능 하락이 발생하는 이유

    • 컨텍스트 스위칭: 줄을 서서 기다리는 스레드 B는 그냥 서 있는게 아니고,
      OS에 의해 "휴면 상태"로 전환되었다가 다시 "실행 상태"로 깨어나야 한다.
      이 "잠들고 깨우는 과정"이 CPU 입장에서는 굉장히 무거운 작업이다.

    • count++ 같이 아주 간단하고 짧은 작업이라도, 잠그고 깨우는 비용이
      훨씬 큰 배보다 배꼽이 큰 상황이 발생할 수 있다.

profile
개발을 즐겨보자.

0개의 댓글