두 스레드가 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.Locksynchronized보다 유연한 잠금 메커니즘을 제공한다. (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이 없으면 종료 신호가 스레드에 보이지 않아 무한 루프에 빠질 수 있다.

생각해 볼 문제

  1. volatile int count; count++;에서 volatile이 원자성을 보장하지 않는 이유를 설명하라.
  2. synchronized(this)의 위험성은 무엇인가? 외부 코드가 같은 객체로 synchronized 블록을 만들면?
  3. CAS(Compare-And-Swap)가 락보다 나은 점은? ABA 문제는 무엇인가?
  4. Thread.sleep(0)Thread.yield()의 차이를 설명하라.
  5. wait() / notify()를 사용할 때 while 루프로 조건을 재확인해야 하는 이유는? (spurious wakeup)

참고

'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