CS/OS

OS 3편 - Race Condition, synchronized (feat. volatile)

isTrue 2026. 4. 7. 23:57
반응형

 

 

여러 스레드가 같은 메모리를 동시에 건드릴 때 생기는 문제다.
로컬에선 안 터지다가 운영에서 트래픽 몰릴 때 갑자기 데이터가 이상해지는 버그의 주범이다.

 


1. Race Condition이란

count++ 한 줄처럼 보이지만 CPU 입장에선 3단계다.

1. count 값을 메모리에서 읽어옴  (READ)
2. 1을 더함                      (ADD)
3. 결과를 메모리에 저장           (WRITE)

스레드 2개가 동시에 실행되면 이 사이에 끼어들 수 있다.

스레드 1                  스레드 2
READ  → count = 0
                          READ  → count = 0
ADD   → 0 + 1 = 1
                          ADD   → 0 + 1 = 1
WRITE → count = 1
                          WRITE → count = 1

최종 결과: count = 1  (원래 2가 되어야 함)

실행 순서에 따라 결과가 달라지는 이 상황이 Race Condition이다.

실무에서 주로 터지는 상황

├── 재고 차감 (동시에 마지막 1개 주문)
├── 포인트 / 잔액 업데이트
├── 좋아요 / 조회수 카운터
└── 선착순 쿠폰 발급

로컬 테스트에선 요청이 1~2개라 안 터지다가, 운영에서 트래픽 몰릴 때 갑자기 숫자가 안 맞는다. 로그엔 에러도 없다.


 

2. 해결 방법

synchronized

한 번에 스레드 하나만 이 메서드에 들어올 수 있게 막는다.

public synchronized void like() {
    count++;
}

나머지 스레드는 밖에서 대기한다. Race Condition은 잡히지만, 읽기까지 전부 직렬화되니 트래픽 많을 때 성능이 떨어진다.

AtomicInteger

READ-ADD-WRITE를 CPU 레벨에서 하나의 원자적 연산으로 처리한다. synchronized보다 훨씬 빠르고, 단순 카운터엔 이걸 쓰는 게 맞다.

AtomicInteger count = new AtomicInteger(0);

public void like() {
    count.incrementAndGet();
}

 

3. volatile - 가시성 문제

CPU는 메모리에서 매번 읽으면 느리니까 캐시에 값을 복사해서 쓴다.

문제는 스레드 1이 값을 업데이트해도 스레드 2가 CPU 캐시에서 오래된 값을 읽을 수 있다는 거다. 

실무에서 직접 겪다가 발견한 volatile 키워드!

메모리: count = 5

CPU 1 캐시: count = 5    CPU 2 캐시: count = 5

스레드 1이 count = 6으로 업데이트
→ CPU 1 캐시엔 6
→ 메모리엔 아직 5
→ 스레드 2는 캐시에서 5를 읽음 (오래된 값)

 

volatile은 캐시를 우회해서 항상 메모리에서 직접 읽고 쓰게 강제한다.

volatile int count = 0;

단, volatile만으로는 Race Condition이 안 잡힌다

volatile이 해결하는 건 가시성(Visibility) 문제다. READ-ADD-WRITE 사이에 다른 스레드가 끼어드는 건 여전히 막지 못한다.

volatile count = 0

스레드 1: READ  → 0 (메모리에서 직접 읽음)
스레드 2: READ  → 0 (메모리에서 직접 읽음)
스레드 1: ADD, WRITE → 1
스레드 2: ADD, WRITE → 1  ← 여전히 Race Condition

 

4. 실무 케이스 - Map 참조 교체

@Component에서 여러 빈이 공유하는 Map을 주기적으로 새 버전으로 교체하는 구조라면 volatile이 정확한 선택이다.

@Component
public class SomeComponent {
    private volatile Map<String, String> cache = new HashMap<>();

    public void update() {
        Map<String, String> newMap = new HashMap<>();
        newMap.put("key1", "value1"); // 다 채우고
        newMap.put("key2", "value2"); // 다 채우고
        cache = newMap;               // 마지막에 참조 교체
    }

    public String get(String key) {
        return cache.get(key);
    }
}

여기서 위험한 건 Race Condition이 아니라 스레드가 CPU 캐시에서 이전 참조를 읽는 가시성 문제다.

volatile이 참조 교체를 모든 스레드에 즉시 보이게 해준다.

 

단, 반드시 새 맵을 완전히 채운 다음에 참조를 교체해야 한다!!

빈 맵으로 먼저 교체하고 채우면, 그 사이에 읽는 스레드가 빈 맵을 보게 된다.

만약 맵 내부를 직접 수정하는 구조로 바뀐다면 그때는 ConcurrentHashMap을 써야 한다.

private Map<String, String> cache = new ConcurrentHashMap<>();

5. 서버가 2대 이상이면?

synchronized와 volatile은 같은 JVM 안에서만 유효하다. 서버가 2대면 JVM도 2개고 메모리도 따로다.

서버 1 (JVM 1)          서버 2 (JVM 2)
재고 = 1                재고 = 1

둘 다 동시에 차감 시도
→ 둘 다 성공
→ 재고 -2 되어야 하는데 -1

분산 환경에서는 DB 레벨 락이나 Redis 분산 락으로 해결한다. 이건 Redis 챕터에서 제대로 다룬다.


6. 정리

  해결하는 문제  한계
synchronized Race Condition 방지 같은 JVM 안에서만, 성능 저하
volatile CPU 캐시 가시성 문제 Race Condition은 못 막음
AtomicInteger Race Condition 방지 단순 연산에만 적합
ConcurrentHashMap 맵 내부 동시 수정 -
DB / Redis 락 분산 환경 Race Condition 네트워크 비용 발생

 

반응형