빠른 비교
public class Counter {
private int value;
public synchronized void increment() {
value++;
}
public synchronized int getValue() {
return value;
}
}갈리는 기준
어떤 동시성 보호 방식을 먼저 떠올리면 되나
| 상황 | 먼저 떠올릴 선택 |
|---|---|
| 단순 공유 상태 보호 | synchronized |
| 잠금 제어를 더 세밀하게 해야 함 | ReentrantLock |
| 단순 카운터 | AtomicInteger |
| 동시 접근 컬렉션 | ConcurrentHashMap |
| 공유 가변 상태 자체를 줄일 수 있음 | 불변 객체 또는 상태 분리 |
공유 가변 상태: 왜 문제가 생기는가
Thread safety 문제는 단순 코드 한 줄이 아니라 "읽기-수정-쓰기" 같은 여러 단계가 엮일 때 생깁니다. value++조차 내부적으로 읽기 → 증가 → 쓰기의 세 단계이므로, 여러 thread가 동시에 접근하면 중간 단계에서 끼어들어 결과가 꼬일 수 있습니다.
// 비동기 두 스레드가 counter를 동시에 증가시키면
// 예상: 2000, 실제: 1998 같이 나올 수 있음
for (int i = 0; i < 1000; i++) {
new Thread(() -> counter.value++).start(); // ❌ thread-unsafe
}synchronized: 기본적인 공유 상태 보호
synchronized는 Java의 기본 mutual exclusion 도구로, 한 번에 한 Thread만 블록에 들어오도록 합니다. 메서드 전체나 특정 블록에 붙일 수 있습니다. lock 범위가 넓으면 병렬성이 떨어지고, 여러 lock을 잘못 섞으면 deadlock이 생길 수 있습니다.
// 메서드 전체 — 간단하지만 lock 범위가 넓음
public synchronized void increment() { value++; }
// 블록 단위 — 꼭 필요한 부분만 보호
public void process() {
prepareData(); // lock 불필요
synchronized (this) {
value++; // lock 필요한 부분만
}
}ReentrantLock — synchronized보다 세밀한 제어가 필요할 때
ReentrantLock은 synchronized와 같은 상호 배제를 제공하지만, 타임아웃을 두고 lock 시도, 조건별 대기(Condition), lock 획득 여부 확인 등 더 세밀한 제어를 지원합니다. 반드시 finally에서 unlock()을 호출해야 합니다.
private final ReentrantLock lock = new ReentrantLock();
private int value;
public void increment() {
lock.lock();
try {
value++;
} finally {
lock.unlock(); // 예외가 나도 반드시 해제
}
}
// tryLock — 일정 시간 내에 못 얻으면 포기
public boolean tryIncrement() throws InterruptedException {
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
value++;
return true;
} finally {
lock.unlock();
}
}
return false; // lock 못 얻음
}thread-safe 대안 먼저 검토하기
실무에서는 lock 전에 "정말 공유 가변 상태가 필요한가"를 먼저 생각합니다. 불변 객체, AtomicInteger 같은 원자 클래스(CAS 기반, lock 없음), ConcurrentHashMap 같은 thread-safe 컬렉션으로 해결할 수 있다면 직접 lock보다 더 안전하고 간결합니다.
// AtomicInteger — 단순 카운터에는 synchronized보다 간결
// CAS(Compare-And-Swap): "현재 값이 예상한 값과 같을 때만 새 값으로 교체"
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // lock 없이 원자적 증가
// ConcurrentHashMap — 동시 접근 Map
Map<String, Integer> map = new ConcurrentHashMap<>();
map.put("key", 1); // thread-safe
map.merge("key", 1, Integer::sum); // 원자적 누적synchronized vs AtomicInteger vs ConcurrentHashMap
단일 숫자 증가 같은 좁은 문제라면 AtomicInteger가 가장 간단하고, 컬렉션 동시 접근이면 ConcurrentHashMap이 먼저입니다. synchronized는 여러 필드가 함께 움직이는 임계 구역을 보호할 때 더 적합합니다.
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
ConcurrentHashMap<String, Integer> scores = new ConcurrentHashMap<>();
scores.merge("kim", 1, Integer::sum);선택 기준
| 상황 | 적합한 선택 |
|---|---|
| 가변 상태를 없앨 수 있을 때 | 불변 객체 사용 |
| 단순 정수 카운터 | AtomicInteger |
| 기본적인 공유 상태 보호 | synchronized |
| 세밀한 lock 제어 필요 | ReentrantLock |
| 동시 접근 Map | ConcurrentHashMap |
주의할 점
thread safety는 synchronized를 하나 붙였다고 끝나지 않습니다. 어떤 상태가 공유되는지, 읽기와 쓰기가 어떤 순서로 일어나는지 함께 봐야 합니다.
// ❌ 읽기와 쓰기 중 하나만 synchronized — 여전히 불안전
public synchronized void increment() { value++; }
public int getValue() { return value; } // 비동기 읽기
// ✅ 읽기와 쓰기 모두 synchronized
public synchronized void increment() { value++; }
public synchronized int getValue() { return value; }
// ✅ 또는 AtomicInteger 사용
private final AtomicInteger value = new AtomicInteger(0);
public void increment() { value.incrementAndGet(); }
public int getValue() { return value.get(); }서로 다른 lock 순서를 섞으면 deadlock 위험이 생깁니다. 여러 자원을 잠글 때는 순서를 일관되게 유지해야 합니다.
// thread A: lock(user) -> lock(order)
// thread B: lock(order) -> lock(user)
// 서로 기다리며 멈출 수 있음참고 링크
3 sources