synchronized와 volatile, 언제 진짜 필요한가
두 가지 문제를 하나로 착각하지 말 것
동시성 버그의 원인을 물어보면 대부분 "synchronized 를 안 걸어서"라고 답합니다. 틀린 말은 아니지만, 이 대답은 두 가지 서로 다른 문제 — 상호배제(mutual exclusion)와 가시성(visibility) — 를 뭉뚱그립니다. 이 둘을 구분하지 못하면 synchronized 를 걸어야 할 자리에 volatile 을 쓰거나, 그 반대로 잘못 쓰는 코드가 나옵니다. 둘 다 흔합니다.
상호배제 — 동시에 들어오면 안 되는 구간
여러 스레드가 같은 변수를 동시에 수정하면 결과가 꼬입니다. 은행 계좌 잔액을 증가시키는 코드를 봅시다.
public class Account {
private int balance = 0;
public void deposit(int amount) {
balance = balance + amount; // 읽기 → 더하기 → 쓰기, 이 세 단계가 원자적이지 않다
}
}스레드 A 와 B 가 동시에 deposit(100) 을 호출하면, 둘 다 balance 를 0 으로 읽고, 각자 100을 더해서, 둘 다 100을 씁니다. 결과는 200이어야 하는데 100이 됩니다. 이건 값이 "늦게 보이는" 문제가 아니라 — 계산 자체가 중간에 끼어들어서 깨지는 문제입니다. synchronized 는 이 구간을 한 번에 한 스레드만 통과하도록 막습니다.
public synchronized void deposit(int amount) {
balance = balance + amount;
}이제 스레드 B 는 A 가 deposit 을 끝낼 때까지 블록됩니다. 정확성은 얻지만 대가가 있습니다. B 는 그 시간 동안 아무것도 못 하고 대기합니다. 임계 구역(critical section)이 길어질수록 이 대기 시간도 늘어납니다.
가시성 — 값은 바뀌었는데 안 보이는 문제
이번엔 다른 시나리오입니다.
public class Worker {
private boolean running = true;
public void run() {
while (running) {
// 작업 반복
}
}
public void stop() {
running = false;
}
}다른 스레드가 stop() 을 호출해도 run() 을 실행 중인 스레드는 영원히 멈추지 않을 수 있습니다. 이건 경쟁 조건이 아니라 캐시 문제입니다. CPU 코어마다 자체 캐시가 있고, 컴파일러와 CPU 는 성능을 위해 running 값을 레지스터나 코어 캐시에 올려놓고 메인 메모리를 다시 읽지 않을 수 있습니다. 스레드 B 가 메인 메모리의 running 을 false 로 바꿔도, 스레드 A 는 그 변경을 "볼" 보장이 없습니다.
volatile 은 이 문제를 해결합니다. 정확히는, 해당 변수에 대한 읽기·쓰기가 항상 메인 메모리를 거치도록 강제하고, 컴파일러의 재정렬 최적화를 제한합니다.
private volatile boolean running = true;이 한 줄로 stop() 이 호출된 뒤 run() 루프가 확실히 종료됩니다. 중요한 건 volatile 이 상호배제는 전혀 보장하지 않는다는 점입니다. balance += amount 같은 복합 연산에 volatile 만 붙이는 건 흔한 실수이고, 여전히 값이 깨집니다. volatile 은 "최신 값을 보이게" 할 뿐, "동시에 못 들어오게" 하지 않습니다.
어느 쪽을 언제 쓰는가
| 상황 | 필요한 것 |
|---|---|
| 여러 스레드가 같은 상태를 읽고 계산하고 다시 쓴다 (증가, 리스트 추가 등) | synchronized 또는 Lock, AtomicInteger 같은 원자적 클래스 |
| 하나의 스레드가 쓰고 다른 스레드들은 읽기만 한다 (플래그, 설정값) | volatile |
| 둘 다 필요한 경우 (계산도 하고, 여러 스레드가 값을 계속 확인해야 하는 경우) | synchronized (synchronized 는 가시성도 함께 보장합니다) |
synchronized 블록에 들어가고 나올 때는 JVM 이 메모리 배리어를 세우기 때문에 가시성 문제도 같이 해결됩니다. 그래서 "상호배제가 필요하면 무조건 synchronized, 가시성만 필요하면 volatile" 이라는 단순한 규칙이 실무에서는 대체로 맞습니다.
다만 synchronized 를 아무 데나 걸면 성능이 떨어지고, 뒤에서 다룰 가상 스레드 환경에서는 synchronized 블록이 훨씬 심각한 문제(pinning)를 일으킵니다. "일단 synchronized 걸어두면 안전하다"는 습관이 왜 위험해지는지는 pinning 장에서 이어집니다.