안녕하세요, 자바파커입니다.
스레드 둘이 각각 1000번씩
+1했습니다. 결과는?
1847입니다. 다시 돌리면 1923이고, 또 돌리면 1786입니다. 코드는 그대로인데 매번 다릅니다.
결론부터 말씀드리면 — count++는 한 동작이 아닙니다. 읽고, 더하고, 쓰는 세 동작입니다. 그 사이에 다른 스레드가 끼어들면 증가 하나가 통째로 사라집니다.
문제의 코드
public class Counter {
private int count = 0;
public void increment() {
count++; // 한 줄이지만 한 동작이 아니다
}
}Counter c = new Counter();
Thread a = new Thread(() -> { for (int i = 0; i < 1000; i++) c.increment(); });
Thread b = new Thread(() -> { for (int i = 0; i < 1000; i++) c.increment(); });
a.start(); b.start();
a.join(); b.join();
System.out.println(c.count); // 2000 이 아니다이 코드에서 2000이 나오는 경우가 오히려 우연입니다.
count++를 분해하면
count++는 컴파일되면 세 단계가 됩니다.
| 단계 | 하는 일 |
|---|---|
| 읽기 | 메모리에서 count 값을 가져온다 |
| 수정 | 가져온 값에 1을 더한다 |
| 쓰기 | 결과를 메모리에 돌려놓는다 |
스레드는 이 셋 사이 어느 지점에서든 멈출 수 있습니다. 운영체제가 CPU를 다른 스레드에 넘기기 때문입니다.
따로 일어나면 문제없다
A: 읽기 1000 → 수정 1001 → 쓰기 1001
B: 읽기 1001 → 수정 1002 → 쓰기 1002
결과 1002 ✔겹치면 하나가 사라진다
A: 읽기 1000 → 수정 1001 ──────────→ 쓰기 1001
B: 읽기 1000 → 수정 1001 ─────────────→ 쓰기 1001
결과 1001 ✘ 두 번 더했는데 1만 늘었다둘 다 1000을 손에 쥔 채 각자 1001을 씁니다. 나중에 쓴 쪽이 앞의 결과를 덮어써서, 증가 하나가 흔적도 없이 사라집니다.
2000에서 1847이 나왔다면 이 겹침이 153번 일어난 것입니다.
이렇게 타이밍에 따라 결과가 달라지는 상태를 경쟁 상태(race condition)라고 합니다. 그리고 count++처럼 중간에 끼어들면 안 되는 구간을 임계 구역(critical section)이라고 합니다.
락으로 막기
해결은 단순합니다. 임계 구역에 한 번에 하나만 들어가게 하면 됩니다.
public synchronized void increment() {
count++;
}A가 들어가 있는 동안 B는 문 앞에서 기다립니다. A가 나오면 그때 B가 들어갑니다. 겹칠 수가 없으니 항상 2000입니다.
락은 공짜가 아니다
여기서 끝내면 절반만 아는 겁니다. 락에는 세 가지 비용이 있습니다.
1. 기다리는 동안 아무것도 못 합니다. 스레드를 8개로 늘려도 이 지점에서는 한 줄로 섭니다. 임계 구역이 길수록 병렬성이 사라집니다.
2. 컨텍스트 스위칭 비용이 붙습니다. 대기하는 스레드는 재웠다 깨워야 하고, 그 자체가 비쌉니다. 경합이 심하면 락 자체가 병목이 됩니다.
3. 락이 둘 이상이면 데드락이 생깁니다. 이게 가장 위험합니다.
데드락 — 서로를 기다리며 멈춘다
// 스레드 A
synchronized (lock1) {
synchronized (lock2) { ... }
}
// 스레드 B
synchronized (lock2) {
synchronized (lock1) { ... }
}A가 lock1을, B가 lock2를 잡은 상태에서 A는 lock2를, B는 lock1을 기다립니다. 둘 다 영원히 멈춥니다.
데드락은 네 조건이 동시에 성립할 때만 생깁니다.
| 조건 | 내용 |
|---|---|
| 상호 배제 | 한 번에 하나만 쓸 수 있다 |
| 점유와 대기 | 하나를 쥔 채 다른 것을 기다린다 |
| 비선점 | 남이 쥔 것을 뺏을 수 없다 |
| 순환 대기 | 기다림이 고리를 이룬다 |
하나만 깨면 데드락은 생기지 않습니다. 실무에서 가장 쉬운 방법은 순환 대기를 깨는 것 — 락을 잡는 순서를 전역으로 하나 정해두면 됩니다. 항상 lock1 → lock2 순서로만 잡으면 고리가 만들어질 수 없습니다.
타임아웃을 거는 방법도 있습니다.
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
try { ... } finally { lock.unlock(); }
} else {
// 포기하고 재시도 — 영원히 멈추지는 않는다
}락 없이 푸는 방법
락은 정확하지만 비쌉니다. 문제가 단순하면 더 싼 방법이 있습니다.
1. 원자적 연산 — AtomicInteger
AtomicInteger count = new AtomicInteger();
count.incrementAndGet(); // 읽기·수정·쓰기가 쪼개지지 않는다CPU의 CAS(Compare-And-Swap) 명령을 씁니다. "값이 아직 1000이면 1001로 바꿔라"를 한 번에 수행하고, 그 사이 값이 바뀌었으면 실패하고 다시 시도합니다. 단순 카운터라면 이게 synchronized보다 훨씬 빠릅니다.
2. 애초에 공유하지 않기 — ThreadLocal, 지역 변수
가장 확실한 해결은 공유 변수를 없애는 것입니다. 각 스레드가 자기 몫을 따로 세고 마지막에 한 번만 합치면 경쟁 자체가 없습니다.
// 스레드별로 세고 마지막에 합산
long total = list.parallelStream().mapToLong(this::process).sum();3. 불변 객체
값을 바꾸지 않고 새 객체를 만들면 경쟁 상태가 성립하지 않습니다. 함수형 스타일이 동시성에 강한 이유입니다.
무엇을 언제 쓰나
| 상황 | 선택 |
|---|---|
| 단순 카운터·플래그 | AtomicInteger · AtomicLong |
| 여러 값을 함께 바꿔야 함 | synchronized · ReentrantLock |
| 맵에 동시 접근 | ConcurrentHashMap |
| 읽기가 압도적으로 많음 | ReadWriteLock |
| 공유할 필요가 없음 | 공유하지 않기 ← 1순위로 검토 |
FAQ — 자주 묻는 질문
Q. volatile을 붙이면 해결되나요?
아니요. volatile은 가시성만 보장합니다. 한 스레드가 바꾼 값을 다른 스레드가 곧바로 보게 해줄 뿐, count++가 쪼개지는 것(원자성)은 막지 못합니다. 플래그 하나를 켜고 끄는 용도라면 맞지만, 증가 연산에는 AtomicInteger를 써야 합니다.
Q. 테스트에서는 잘 되는데요?
경쟁 상태는 재현이 안 되는 게 특징입니다. 스레드 수가 적거나 반복이 적으면 겹칠 확률이 낮아 그냥 통과합니다. 그러다 운영에서 트래픽이 몰릴 때 터집니다. "가끔 숫자가 안 맞는다", "재현이 안 된다"는 리포트가 오면 이쪽부터 의심하세요.
Q. 웹 애플리케이션에도 해당되나요?
그렇습니다. 서버는 요청마다 스레드를 씁니다. 싱글턴 빈의 필드, 정적 변수, 캐시가 전부 공유 자원입니다. 재고 차감이나 포인트 적립처럼 읽고-계산해서-쓰는 로직이면 그대로 같은 문제가 생깁니다. 이때는 애플리케이션 락으로는 부족하고 DB 락이나 원자적 UPDATE로 풀어야 합니다 — 서버가 여러 대면 JVM 락은 다른 서버를 막지 못하기 때문입니다.
정리
count++는 읽기 · 수정 · 쓰기 세 동작입니다- 둘이 같은 값을 읽으면 증가 하나가 사라집니다
- 락은 정확하지만 기다림·컨텍스트 스위칭·데드락이라는 비용을 부릅니다
- 락이 둘 이상이면 잡는 순서를 전역으로 고정하세요
- 단순 카운터는
AtomicInteger가, 가장 좋은 해결은 공유하지 않는 설계입니다
동시성 버그가 무서운 이유는 어렵기 때문이 아니라 재현되지 않기 때문입니다. 그래서 "겹치면 어떻게 되는지"를 머릿속에 그릴 수 있느냐가 그대로 실력이 됩니다.
여러 서버가 같은 데이터를 만질 때의 이야기는 DB 인덱스와 조회 원리에서 이어지는 트랜잭션 쪽 주제이고, 요청이 서버 앞단에서 어떻게 분배되는지는 API 게이트웨이가 하는 일에서 다뤘습니다.