경쟁 상태와 락 — 2000이 나와야 하는데 1847이 나오는 이유

@JavaPark · 2026년 8월 16일 · 10 min read

경쟁 상태 커버 — 예상 2000, 실제 1847
경쟁 상태 커버 — 예상 2000, 실제 1847

안녕하세요, 자바파커입니다.

스레드 둘이 각각 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을 기다립니다. 둘 다 영원히 멈춥니다.

데드락은 네 조건이 동시에 성립할 때만 생깁니다.

조건 내용
상호 배제 한 번에 하나만 쓸 수 있다
점유와 대기 하나를 쥔 채 다른 것을 기다린다
비선점 남이 쥔 것을 뺏을 수 없다
순환 대기 기다림이 고리를 이룬다

하나만 깨면 데드락은 생기지 않습니다. 실무에서 가장 쉬운 방법은 순환 대기를 깨는 것 — 락을 잡는 순서를 전역으로 하나 정해두면 됩니다. 항상 lock1lock2 순서로만 잡으면 고리가 만들어질 수 없습니다.

타임아웃을 거는 방법도 있습니다.

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 게이트웨이가 하는 일에서 다뤘습니다.

@JavaPark
AI 시대의 개발자 도구, 실전 경험을 공유합니다