두 스레드가 count++를 동시에 실행하면 값이 1만 증가하는가 — 가시성과 원자성
// Java 25
class Counter {
private int count = 0;
public void increment() { count++; }
}
두 스레드가 동시에 counter.increment()를 10,000번씩 호출하면 count가 20,000이 될까? — 아니다. 대부분 17,000~19,000 사이의 값이 나온다. count++는 하나의 연산처럼 보이지만, 실제로는 읽기 → 더하기 → 쓰기 세 단계이며, 두 스레드가 동시에 같은 값을 읽으면 하나의 증가가 사라진다(lost update).
이것이 경쟁 조건(race condition)이다. 이 글은 Java 메모리 모델(JMM), synchronized, volatile, 그리고 스레드 안전성의 두 축 — 가시성(visibility)과 원자성(atomicity) — 을 풀어간다.
스레드 기초 — 생성과 생명주기
// Java 25 — 스레드 생성
// 방식 1: Thread 상속
class MyThread extends Thread {
public void run() {
System.out.println("스레드: " + Thread.currentThread().getName());
}
}
new MyThread().start();
// 방식 2: Runnable 구현 (권장)
Runnable task = () -> System.out.println("작업: " + Thread.currentThread().getName());
new Thread(task).start();
flowchart LR
NEW["NEW<br/>생성됨"] -->|start| RUN["RUNNABLE<br/>실행 대기/실행 중"]
RUN -->|wait/sleep| BLOCK["BLOCKED/WAITING<br/>일시 정지"]
BLOCK -->|notify/timeout| RUN
RUN -->|run 완료| TERM["TERMINATED<br/>종료"]
start()를 호출해야 스레드가 생성된다.run()을 직접 호출하면 새 스레드가 아니라 현재 스레드에서 실행된다 — 흔한 실수. (Thread API)
경쟁 조건(Race Condition) — count++가 안전하지 않은 이유
// Java 25 — 경쟁 조건 재현
class UnsafeCounter {
private int count = 0;
public void increment() { count++; } // 안전하지 않음
public int get() { return count; }
}
var counter = new UnsafeCounter();
Runnable task = () -> { for (int i = 0; i < 10000; i++) counter.increment(); };
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join(); // 두 스레드 완료 대기
System.out.println("결과: " + counter.get()); // 20000이 아닌 더 낮은 값
count++의 실제 바이트코드:
0: aload_0
1: getfield #count // count 값 읽기
4: iconst_1 // 1 준비
5: iadd // 더하기
6: putfield #count // 결과 쓰기
세 명령어 사이에 다른 스레드가 끼어들 수 있다 — 스레드 A가 읽고(count=5), 스레드 B도 읽고(count=5), 둘 다 6으로 쓴다. 하나의 증가가 사라진다.
왜 이런 일이 일어나는가 — 내부 메커니즘: count++는 원자적(atomic) 연산이 아니다. CPU 수준에서 세 단계로 실행된다: (1) 메모리에서 count 값을 레지스터로 읽기 (mov), (2) 레지스터에서 1 더하기 (add), (3) 레지스터 값을 메모리에 쓰기 (mov). 두 스레드가 동시에 (1)을 실행하면 — 둘 다 같은 값(5)을 읽고, 둘 다 6을 쓴다. 하나의 증가가 "사라진다(lost update)".
비유: count++는 화이트보드에 숫자를 바꾸는 작업과 같다. (1) 현재 숫자(5)를 읽고, (2) 머릿속에서 1 더해서(6), (3) 보드에 6이라고 적는다. 두 사람이 동시에 같은 보드에서 숫자를 읽으면 — 둘 다 5를 보고, 둘 다 6을 적는다. 결과적으로 2번 증가해야 할 것이 1번만 증가한다. synchronized는 "한 번에 한 사람만 보드에 접근할 수 있는 열쇠"를 제공한다.
synchronized — 상호 배제로 원자성 보장
synchronized는 한 시점에 하나의 스레드만 블록/메서드에 진입하게 만든다. (JLS §17.1)
// Java 25 — synchronized로 경쟁 조건 해결
class SafeCounter {
private int count = 0;
public synchronized void increment() { count++; } // 한 스레드만 진입
public synchronized int get() { return count; }
}
// 또는 synchronized 블록 (더 세밀한 제어)
class SafeCounter2 {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
}
synchronized메서드는this를 잠근다. static 메서드는Class객체를 잠근다.synchronized블록은 명시적 lock 객체를 쓰므로 더 유연하다.
synchronized의 가시성 효과
synchronized는 상호 배제(원자성)뿐 아니라 메모리 가시성도 보장한다 — 잠금을 해제하기 전의 모든 쓰기가, 모든 쓰기가, 잠금을 획득하는 다른 스레드에게 보인다. (JLS §17.4 - Memory Model)
volatile — 원자성 없이 가시성만 보장
// Java 25 — volatile: 메모리 가시성만 보장
class FlagExample {
private volatile boolean running = true;
public void stop() { running = false; }
public void run() {
while (running) {
// 작업 수행
}
System.out.println("종료");
}
}
volatile 키워드는:
- 변수의 값을 메인 메모리에서 직접 읽고 쓴다 — CPU 캐시가 아니라 메인 메모리.
- 한 스레드의 쓰기가 다른 스레드에게 즉시 보인다(가시성).
- 하지만 원자성은 보장하지 않는다 —
volatile int count; count++;는 여전히 안전하지 않다.
volatile boolean플래그는 스레드 종료 패턴에 자주 쓰인다.volatile이 없으면 다른 스레드가running = false로 바꿔도, 루프 스레드가 CPU 캐시의 stale 값을 계속 읽어 무한 루프에 빠질 수 있다.
volatile vs synchronized — 언제 무엇을?
| 항목 | volatile |
synchronized |
|---|---|---|
| 가시성 | O | O |
| 원자성 | X (단일 읽기/쓰기만 원자적) | O |
| 블로킹 | 없음 | 있음 (잠금 획득 대기) |
| 성능 | 빠름 | 느림 (컨텐션 시) |
| 용도 | 플래그, 단일 값 상태 | 복합 연산, 다중 필드 |
AtomicInteger — 락 없는 원자 연산
// Java 25 — AtomicInteger로 count++ 해결
import java.util.concurrent.atomic.AtomicInteger;
class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() { count.incrementAndGet(); } // 원자적
public int get() { return count.get(); }
}
AtomicInteger는 CAS(Compare-And-Swap) 하드웨어 명령을 사용하여 락 없이 원자성을 보장한다 — synchronized보다 가볍고 빠르다.
CAS: 현재 값이 예상 값과 같으면 새 값으로 교체. 다르면 실패하고 재시도. 이것이
java.util.concurrent.atomic패키지의 핵심. (AtomicInteger API)
Java 메모리 모델(JMM) — happens-before 관계
JMM은 스레드 간 메모리 변경의 가시성을 정의한다. 핵심 개념은 happens-before: 한 액션이 다른 액션보다 "먼저 일어났음"이 보장되는 관계. (JLS §17.4)
happens-before를 보장하는 액션:
synchronized블록/메서드의 해제 → 획득volatile쓰기 → 읽기Thread.start()→ 스레드의 첫 액션- 스레드의 모든 액션 →
Thread.join()반환 AtomicInteger.set()→get()
happens-before가 없으면 한 스레드의 변경이 다른 스레드에 "보이지 않을 수 있다". CPU 캐시, 컴파일러 재배치, JIT 최적화가 메모리 접근 순서를 바꿀 수 있기 때문이다. 동시성 버그는 "재현이 안 되는 버그"로 악명 높은데, happens-before 없는 코드가 그 원인이다.
Lock 인터페이스 — synchronized보다 유연한 잠금 (Java 5+)
java.util.concurrent.locks.Lock은 synchronized보다 유연한 잠금 메커니즘을 제공한다. (Lock API)
// Java 25 — ReentrantLock
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Lock;
class LockCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // 반드시 finally에서 해제
}
}
}
synchronized vs ReentrantLock
| 항목 | synchronized |
ReentrantLock |
|---|---|---|
| 잠금 해제 | 자동 (블록 종료) | 수동 (unlock() in finally) |
| 타임아웃 | 불가 | tryLock(timeout) 가능 |
| 인터럽트 | 무시 | lockInterruptibly() 가능 |
| 조건 변수 | wait()/notify() (1개) |
Condition (여러 개 가능) |
| 공정성 | 불가 | new ReentrantLock(true) (FIFO) |
ReentrantLock은 잠금 해제를 잊으면 데드락이 발생하므로 반드시finally블록에서unlock()해야 한다.synchronized가 자동 해제를 제공하므로, 간단한 경우에는synchronized를 우선한다.
wait() / notify() — 생산자-소비자 패턴
// Java 25 — BlockingQueue 시뮬레이션 (교육용 — 실제로는 java.util.concurrent 사용)
class SimpleBlockingQueue<T> {
private final java.util.LinkedList<T> queue = new java.util.LinkedList<>();
private final int capacity;
public SimpleBlockingQueue(int capacity) { this.capacity = capacity; }
public synchronized void put(T item) throws InterruptedException {
while (queue.size() == capacity) {
wait(); // 큐가 가득 차면 대기
}
queue.add(item);
notifyAll(); // 대기 중인 소비자에게 알림
}
public synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // 큐가 비었으면 대기
}
T item = queue.removeFirst();
notifyAll(); // 대기 중인 생산자에게 알림
return item;
}
}
wait()/notify()는 반드시synchronized블록 안에서 호출해야 한다. 아니면IllegalMonitorStateException. 또한 조건 검사는if가 아니라while루프로 — spurious wakeup(거짓 웨이크업)과 다중 스레드 경쟁 때문에notify후에도 조건이 변경되어 있을 수 있다.
프로덕션에서는
wait()/notify()를 직접 쓰지 않는다 —java.util.concurrent패키지의BlockingQueue,CountDownLatch,CompletableFuture등 고수준 동시성 도구를 쓴다 (장 19).
실습 — 경쟁 조건 직접 체감
// Java 25 — RaceConditionDemo.java
import java.util.concurrent.atomic.AtomicInteger;
public class RaceConditionDemo {
public static void main(String[] args) throws InterruptedException {
// 안전하지 않은 카운터
int[] unsafe = { 0 };
Runnable unsafeTask = () -> {
for (int i = 0; i < 100000; i++) unsafe[0]++;
};
Thread t1 = new Thread(unsafeTask);
Thread t2 = new Thread(unsafeTask);
t1.start(); t2.start(); t1.join(); t2.join();
System.out.println("안전하지 않음: " + unsafe[0] + " (예상: 200000)");
// AtomicInteger
AtomicInteger safe = new AtomicInteger(0);
Runnable safeTask = () -> {
for (int i = 0; i < 100000; i++) safe.incrementAndGet();
};
Thread t3 = new Thread(safeTask);
Thread t4 = new Thread(safeTask);
t3.start(); t4.start(); t3.join(); t4.join();
System.out.println("AtomicInteger: " + safe.get() + " (예상: 200000)");
}
}
java RaceConditionDemo.java
안전하지 않음: 128437 (예상: 200000)
AtomicInteger: 200000 (예상: 200000)
확인할 것: 안전하지 않은 카운터는 200,000에 미치지 못한다 — lost update. AtomicInteger는 정확히 200,000.
요약 — 이 글의 결론
count++는 원자적이지 않다. 읽기-더하기-쓰기 세 단계이며, 동시 실행 시 lost update 발생.synchronized또는AtomicInteger로 해결.- 가시성과 원자성은 다르다.
volatile은 가시성만 보장(원자성 없음).synchronized는 둘 다 보장.AtomicInteger는 CAS로 원자성을 보장하면서 락 없이 가시성도 확보. - Java 메모리 모델(JMM)의 happens-before가 동시성의 기초다. happens-before 없이 공유 메모리에 접근하면, 변경이 "보이지 않을 수 있다".
synchronized,volatile,Thread.join()등이 happens-before를 보장한다. synchronized메서드보다 블록을 선호한다. 잠금 범위를 최소화하면 컨텐션과 성능 저하를 줄일 수 있다.- 플래그 종료 패턴에는
volatile boolean을 쓴다.volatile이 없으면 종료 신호가 스레드에 보이지 않아 무한 루프에 빠질 수 있다.
생각해 볼 문제
volatile int count; count++;에서volatile이 원자성을 보장하지 않는 이유를 설명하라.synchronized(this)의 위험성은 무엇인가? 외부 코드가 같은 객체로synchronized블록을 만들면?- CAS(Compare-And-Swap)가 락보다 나은 점은? ABA 문제는 무엇인가?
Thread.sleep(0)과Thread.yield()의 차이를 설명하라.wait()/notify()를 사용할 때while루프로 조건을 재확인해야 하는 이유는? (spurious wakeup)
참고
- JLS §17 - Threads and Locks - 접근 2026-07-10
- JLS §17.4 - Memory Model - 접근 2026-07-10
- Thread API (Java 25) - 접근 2026-07-10
- AtomicInteger API - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 20. virtual threads (0) | 2026.07.12 |
|---|---|
| Java - 19. concurrent (0) | 2026.07.12 |
| Java - 17. annotation-reflection (0) | 2026.07.12 |
| Java - 16. io nio (0) | 2026.07.12 |
| Java - 15. datetime (0) | 2026.07.12 |
