
여러 스레드가 같은 메모리를 동시에 건드릴 때 생기는 문제다.
로컬에선 안 터지다가 운영에서 트래픽 몰릴 때 갑자기 데이터가 이상해지는 버그의 주범이다.
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 | 네트워크 비용 발생 |
'CS > OS' 카테고리의 다른 글
| OS 4편 - 실무 병목 찾기 (0) | 2026.04.08 |
|---|---|
| OS 2편 - 동기 vs 비동기, 그리고 언제 써야 하는가 (0) | 2026.04.07 |
| OS 1편 - 프로세스, 스레드, Thread Pool (0) | 2026.04.07 |