1. 개요
volatile 을 붙이면 count++ 도 스레드 안전해진다고 착각하는 경우가 많다. synchronized 와 volatile 은 애초에 서로 다른 문제를 푸는 키워드다. 이 글은 그 차이를 정리하고, 왜 이 둘만으로는 부족해서 java.util.concurrent 가 나왔는지 짚는다.
2. 핵심 내용
2-1. 경쟁 상태부터
int count = 0;
void increment() { count++; }count++ 은 겉보기엔 한 줄이지만 실제로는 읽기 → 더하기 → 쓰기, 세 단계다. 스레드 두 개가 동시에 이 세 단계를 실행하면 한쪽의 결과가 덮어써질 수 있다. 스레드 1000개가 각각 1000번씩 증가시켜도 최종값이 1,000,000 이 안 나오는 게 이 문제다.
2-2. synchronized: 상호 배제 + 가시성
synchronized void increment() { count++; }한 시점에 한 스레드만 이 블록에 들어갈 수 있다(상호 배제, mutual exclusion). 그리고 블록을 빠져나갈 때 변경 사항을 메인 메모리에 반영하고, 들어갈 때 최신 값을 다시 읽어오는 것도 보장한다(가시성). synchronized 는 두 가지를 한 번에 준다.
2-3. volatile: 가시성만, 원자성은 아니다
volatile boolean running = true;
void stop() { running = false; } // 다른 스레드에서 호출
void loop() {
while (running) { /* 작업 */ } // running 변경이 바로 보임
}volatile 은 CPU 캐시에 값을 들고 있지 말고 매번 메인 메모리에서 읽고 쓰라는 지시다. 컴파일러/CPU 의 명령어 재정렬도 그 변수 주변에서는 막는다. 하지만 원자성은 보장하지 않는다. volatile int count 에 count++ 을 해도 여전히 읽기-더하기-쓰기 세 단계라 경쟁 상태가 그대로 남는다. volatile 이 맞는 용도는 위 예제처럼 “한쪽이 쓰고 다른 쪽은 읽기만 하는 플래그” 같은 경우다.
2-4. java.util.concurrent 가 왜 필요했나
synchronized/wait/notify 만으로 동시성 코드를 짜면 문제가 많다. 락을 걸어둔 채 일정 시간만 기다리다 포기(tryLock)할 수도 없고, 대기 중인 스레드를 인터럽트할 수도 없고, 데드락이 나기 쉽다. Java 5(2004)에서 Doug Lea 가 만든 java.util.concurrent 가 이런 저수준 문제들을 해결했다.
Lock lock = new ReentrantLock();
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try { /* 임계 구역 */ } finally { lock.unlock(); }
}
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet(); // CAS 기반, 락 없이 원자적 증가
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> doWork());ReentrantLock 은 타임아웃과 인터럽트 가능한 락을, AtomicInteger 는 CAS(Compare-And-Swap)로 락 없이 원자적 연산을, ExecutorService 는 스레드를 직접 만들고 관리하는 대신 스레드 풀 추상화를 준다. ConcurrentHashMap 은 전체 맵을 잠그는 대신 버킷 단위로 더 세밀하게 잠가서 Collections.synchronizedMap 보다 동시 처리량이 높다. 여기까지는 “스레드를 어떻게 안전하게 공유하나” 의 문제이고, “애초에 스레드를 덜 묶어두려면 어떻게 하나” 는 또 다른 접근이다. 코틀린 코루틴이 그 방향이다.
여기서 다루는 건 전부 한 JVM 안에서 여러 스레드가 공유 메모리를 다투는 문제다. “여러 서버(여러 JVM)에 걸쳐 이 작업을 딱 한 번만 실행하고 싶다”는 건 완전히 다른 문제, 분산 락이다. 그 분산 락이 테스트를 어떻게 속였는지는 ShedLock 함정 모음과 k3s 운영기 7편에 정리해뒀다. 참고로 Thread.sleep, Lock.lockInterruptibly 등이 던지는 InterruptedException 은 체크 예외의 대표적인 예다.
3. 마무리
요약
synchronized는 상호 배제와 가시성을 함께 보장한다.volatile은 가시성만 보장하고 원자성은 보장하지 않는다.volatile int에++을 해도 경쟁 상태는 남는다.java.util.concurrent는 타임아웃·인터럽트 가능한 락, CAS 기반 원자 연산, 스레드 풀, 세밀한 락 단위의 동시성 컬렉션을 제공한다.- 이건 전부 한 JVM 내부의 동시성이고, 여러 서버에 걸친 분산 락은 다른 문제다.