자바 스레드와 동시성 기초 - 경쟁 조건 synchronized volatile 차이 (자바 고급 17단원)
이 단원에서 배우는 것
여기까지의 코드는 전부 "한 번에 한 줄씩, 순서대로" 실행된다는 전제 위에 있었다. 컬렉션에 값을 넣고 다음 줄에서 읽으면 당연히 그 값이 있었고, 필드에 대입하면 대입된 값이 보였다. 이 전제는 스레드가 하나일 때만 참이다. 톰캣이든 스프링 부트든 요청마다 스레드를 하나씩 배정하므로, 여러 요청이 같은 객체의 필드를 건드리는 순간 그 전제는 사라진다. 이번 단원은 그 전제가 깨지는 지점을 직접 재현하고, synchronized 와 volatile 이 각각 무엇을 되돌려 주는지 구분한다. 이 배치 전체에서 재고와 주문을 다루는 하나의 서비스를 계속 발전시킨다.
- 경쟁 조건(race condition)을 코드로 재현하고, 원인을 "읽고-고치고-쓰기"의 분해로 설명한다
synchronized가 주는 두 가지 보장(상호 배제, 가시성)과volatile이 주는 한 가지 보장을 구분한다- 락 범위·락 객체·데드락 순서를 결정할 때 무엇을 기준으로 삼는지 익힌다
왜 필요한가
재고 100개짜리 상품에 동시에 200명이 주문 버튼을 눌렀다. 로그를 보면 주문은 137건이 성공했고 재고 컬럼은 -37 이 되어 있다. 코드에는 분명히 if (재고 < 수량) return 실패; 가 있는데도 그렇다. 이런 장애는 개발 PC에서 재현되지 않는다. 혼자 클릭해서는 절대 안 나오고, 부하가 걸리는 실서버에서만 나온다.
원인은 자바가 느려서도, 코드에 오타가 있어서도 아니다. stock-- 같은 한 줄이 CPU 입장에서는 한 동작이 아니기 때문이다. 아래를 그대로 돌려 보자.
public class RaceDemo {
static int stock = 10_000;
static void decrease() {
stock--; // 읽고, 1 빼고, 다시 쓴다. 세 단계다
}
public static void main(String[] args) throws InterruptedException {
Thread[] workers = new Thread[10];
for (int i = 0; i < workers.length; i++) {
workers[i] = new Thread(() -> {
for (int k = 0; k < 1_000; k++) decrease();
});
workers[i].start();
}
for (Thread t : workers) t.join();
System.out.println("남은 재고 = " + stock); // 0 이어야 하는데
}
}
필자의 M 시리즈 맥, JDK 17에서 세 번 돌린 결과는 388, 0, 132 이었다. 만 번 빼야 하는데 388번은 사라졌다. 두 번째 실행에서 0 이 나온 것이 이 문제의 가장 고약한 성질이다. 동시성 버그는 "가끔 맞는다"가 아니라 "가끔 틀린다" 이고, 테스트를 열 번 돌려 열 번 통과해도 증명된 것은 없다.
문법과 예제
1. 상호 배제 — synchronized
stock-- 의 세 단계 사이에 다른 스레드가 끼어들지 못하게 막으면 된다. synchronized 는 어떤 객체의 모니터 락을 잡고, 같은 락을 원하는 스레드를 블로킹시킨다.
static synchronized void decrease() { // static 이면 락 객체는 RaceDemo.class
stock--;
}
여기서 락은 코드가 아니라 객체에 걸린다는 점이 핵심이다. synchronized 인스턴스 메서드의 락 객체는 this, static 메서드는 그 클래스의 Class 객체다. 서로 다른 두 메서드라도 락 객체가 같으면 동시에 못 들어가고, 같은 메서드라도 인스턴스가 다르면 동시에 들어간다.
2. 가시성 — volatile
상호 배제만이 문제가 아니다. 한 스레드가 쓴 값이 다른 스레드에 영영 보이지 않는 일도 일어난다. 아래는 흔한 "플래그로 워커 멈추기" 패턴이다.
public class VisibilityDemo {
static boolean running = true; // volatile 이 없다
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long n = 0;
while (running) n++; // running 을 읽지만 갱신을 못 볼 수 있다
System.out.println("worker 종료, 반복 " + n);
});
worker.setDaemon(true); // 안 멈춰도 JVM 은 끝나게 한다
worker.start();
Thread.sleep(1000);
running = false; // 여기서 멈춰야 한다
worker.join(3000);
System.out.println("3초 뒤에도 살아있나 = " + worker.isAlive());
}
}
출력은 3초 뒤에도 살아있나 = true 다. 워커는 종료 메시지를 끝내 찍지 못한다. JIT 컴파일러가 루프 안에서 running 이 변하지 않는다고 판단해 값을 레지스터로 끌어올렸기 때문이다. 이것은 JVM의 버그가 아니라 자바 메모리 모델이 허용하는 최적화다. 필드에 volatile 을 붙이면 매번 메모리에서 읽고 쓰도록 강제되어 즉시 종료된다.
static volatile boolean running = true;
중요한 것은 volatile 은 가시성만 준다는 사실이다. volatile int stock 으로 바꿔도 stock-- 의 경쟁 조건은 하나도 해결되지 않는다. 읽기와 쓰기 각각은 원자적이지만 그 사이가 벌어져 있기 때문이다. 셋의 관계를 정리하면 이렇다.
| 도구 | 상호 배제(원자성) | 가시성 | 비용 |
|---|---|---|---|
| 일반 필드 | 없음 | 없음 | 없음 |
volatile | 없음 | 있음 | 메모리 배리어, 블로킹 없음 |
AtomicInteger 등 | 단일 연산 한정 | 있음 | CAS 재시도, 경합이 심하면 커짐 |
synchronized | 블록 전체 | 있음 | 경합 시 블로킹·컨텍스트 스위치 |
선택 기준은 단순하다. 플래그 하나면 volatile, 카운터 하나면 Atomic*, "확인하고 나서 바꾼다"가 한 덩어리여야 하면 synchronized 다.
3. 실무형 예제 — 재고 예약 서비스
앞의 재고 차감을 서비스 형태로 만든다. 확인과 차감이 반드시 같은 락 안에 있어야 한다는 점에 주목한다.
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
class InventoryService {
private final Map<String, Integer> stock = new HashMap<>();
private final AtomicLong rejected = new AtomicLong();
void put(String sku, int qty) { stock.put(sku, qty); }
/** 확인과 차감이 한 락 안에 있어야 한다 */
synchronized boolean reserve(String sku, int qty) {
int cur = stock.getOrDefault(sku, 0);
if (cur < qty) {
rejected.incrementAndGet();
return false;
}
stock.put(sku, cur - qty);
return true;
}
synchronized int stockOf(String sku) { return stock.getOrDefault(sku, 0); }
long rejectedCount() { return rejected.get(); }
}
public class InventoryDemo {
public static void main(String[] args) throws InterruptedException {
InventoryService inv = new InventoryService();
inv.put("SKU-1", 100);
Thread[] buyers = new Thread[50];
for (int i = 0; i < buyers.length; i++) {
buyers[i] = new Thread(() -> {
for (int k = 0; k < 10; k++) inv.reserve("SKU-1", 1);
});
buyers[i].start();
}
for (Thread t : buyers) t.join();
System.out.println("남은 재고 = " + inv.stockOf("SKU-1")); // 0
System.out.println("거절 건수 = " + inv.rejectedCount()); // 400
}
}
몇 번을 돌려도 0 과 400 이 나온다. 500번 시도 중 100번만 성공하고 400번은 정확히 거절된다. rejected 는 락 안에서만 건드리므로 사실 long 이어도 되지만, 락 밖에서도 읽는 통계값이라 AtomicLong 으로 두었다. 이런 "락 안에서 쓰고 락 밖에서 읽는" 값이 실제로는 가장 흔한 Atomic* 용도다.
4. 락 범위와 데드락
락 구간이 넓으면 안전하지만 처리량이 죽는다. 특히 락을 잡은 채 I/O를 하면 안 된다. 아래는 실제로 자주 보는 형태다.
// 나쁜 예: 결제 서버 응답(수백 ms)을 기다리는 동안 모든 주문이 줄을 선다
synchronized boolean order(String sku, int qty, String card) {
if (!reserve(sku, qty)) return false;
return paymentClient.approve(card, qty); // 네트워크 호출
}
재고 차감만 락으로 감싸고 결제는 락 밖에서 하되, 결제가 실패하면 재고를 되돌리는 구조로 바꿔야 한다. 락을 두 개 이상 잡을 때는 데드락도 생각해야 한다. 계정 A→B 이체와 B→A 이체가 동시에 일어나면 서로 상대의 락을 기다리며 영원히 멈춘다. 해법은 모든 코드가 같은 순서로 락을 잡는 것이고, 실무에서는 계정 ID 같은 불변 키로 정렬해서 잡는다.
void transfer(Account a, Account b, long amount) {
Account first = a.id() < b.id() ? a : b; // 항상 작은 id 부터
Account second = a.id() < b.id() ? b : a;
synchronized (first) {
synchronized (second) {
a.withdraw(amount);
b.deposit(amount);
}
}
}
실무에서 자주 틀리는 것
1. 락 객체가 다르면 synchronized 는 아무 일도 안 한다
synchronized 인스턴스 메서드는 this 에 락을 건다. 스프링 빈은 기본이 싱글턴이라 우연히 잘 동작한다. 그러다 프로토타입 스코프로 바꾸거나, 요청마다 new 하는 코드로 리팩터링하거나, 인스턴스를 두 개 만드는 순간 보호가 통째로 사라진다. 조용히 사라지고 예외도 안 난다. 공유 상태를 지킬 의도라면 락 객체도 공유되는 것이어야 한다.
private static final Object LOCK = new Object(); // 의도를 코드로 못박는다
void reserve(...) {
synchronized (LOCK) { ... }
}
같은 이유로 synchronized ("lock") 이나 synchronized (Boolean.TRUE) 는 금지다. 문자열 리터럴과 Boolean 상수는 JVM 전체가 공유하는 객체라, 전혀 관계없는 라이브러리 코드와 락을 나눠 쓰게 된다.
2. 확인과 변경 사이가 벌어진다 (check-then-act)
메서드 하나하나를 synchronized 로 만들어 놓고 안심하는 코드가 많다.
// 각 메서드는 synchronized 지만 전체는 안전하지 않다
if (inv.stockOf(sku) >= qty) { // 락 잡고 읽고 풀었다
inv.decrease(sku, qty); // 그 사이 다른 스레드가 다 가져갔을 수 있다
}
ConcurrentHashMap 도 마찬가지다. if (!map.containsKey(k)) map.put(k, v); 는 안전하지 않고, putIfAbsent 나 computeIfAbsent 로 한 번에 처리해야 한다. 원자성이 필요한 단위는 메서드가 아니라 업무 규칙이다.
3. volatile 로 카운터를 만든다
volatile long hits; 에 hits++ 를 하는 코드는 흔하지만 값이 샌다. 앞의 표대로 volatile 은 원자성을 주지 않는다. 조회수·처리 건수 같은 카운터는 AtomicLong, 경합이 아주 심하면 LongAdder 를 쓴다. 반대로 "설정을 새로 읽었다" 같은 플래그 하나에 synchronized 를 쓰는 것은 과하다.
4. 단일 JVM 락으로 분산 환경을 지키려 한다
가장 비싸게 배우는 실수다. synchronized 는 그 JVM 안에서만 유효하다. 서버를 두 대로 늘리는 순간 재고 초과 판매가 다시 시작된다. 여러 인스턴스가 같은 DB를 보는 구조라면 진짜 경계는 자바 락이 아니라 DB 트랜잭션이다. UPDATE stock SET qty = qty - ? WHERE sku = ? AND qty >= ? 처럼 원자적 UPDATE로 처리하고 갱신 행 수가 0이면 실패로 보는 방식, 또는 비관적 락(SELECT ... FOR UPDATE)이 정석이다. 자바 락은 그 위의 보조 수단일 뿐이다.
5. Thread.sleep 으로 타이밍을 맞춘다
"조금 기다리면 다른 스레드가 끝나 있겠지" 하고 Thread.sleep(100) 을 넣은 코드는 부하가 몰리면 반드시 깨진다. 기다림이 필요하면 join(), CountDownLatch, 다음 단원의 Future.get() 처럼 완료를 직접 확인하는 수단을 쓴다. 테스트 코드의 sleep 도 같은 이유로 깨지기 쉬운 테스트(flaky test)의 1등 원인이다.
스스로 확인하기
private volatile int stock = 100;으로 선언하고if (stock >= qty) stock -= qty;로 재고를 줄인다. 이 코드에 남아 있는 문제는 무엇이고,volatile이 실제로 막아 주는 문제는 무엇인가?- 아래 두 메서드는 같은 클래스에 있다. 스레드 A가
a()를 실행하는 동안 스레드 B가b()에 들어갈 수 있는가?synchronized void a() { ... } static synchronized void b() { ... } - 주문 처리에서 재고 차감 → 결제 승인(평균 200ms) → 주문 저장 순으로 실행한다. 세 단계를 하나의
synchronized블록으로 감쌌더니 초당 처리량이 5건이 되었다. 어디를 락 밖으로 빼야 하고, 그때 새로 생기는 문제는 무엇인가?
정답
- 남아 있는 문제는 원자성이다. 두 스레드가 동시에
stock을 100으로 읽고 각각 10씩 빼면 결과가 90이 되어 10개가 공짜로 나간다.volatile이 막아 주는 것은 가시성뿐이다. 즉 다른 스레드가 방금 바꾼 값을 못 보고 낡은 값을 계속 읽는 상황은 막아 주지만, 읽기와 쓰기 사이에 끼어드는 것은 못 막는다. 해결하려면synchronized로 확인·차감을 묶거나, DB의 조건부 UPDATE로 넘긴다. - 들어갈 수 있다.
a()의 락 객체는 인스턴스(this)이고b()의 락 객체는Class객체다. 서로 다른 락이므로 상호 배제가 전혀 성립하지 않는다. 두 메서드가 같은static필드를 건드린다면 그대로 경쟁 조건이다. 인스턴스 메서드와static메서드가 같은 상태를 공유하면 락 객체를 하나로 통일해야 한다. - 결제 호출을 락 밖으로 빼야 한다. 락 안에는 재고 차감만 남긴다. 새로 생기는 문제는 보상이다. 재고는 이미 줄었는데 결제가 실패하거나 타임아웃 나면 그 수량을 되돌려야 하고, 되돌리는 처리 자체가 실패할 수도 있다. 그래서 실무에서는 재고를 즉시 차감하는 대신 "예약(reserve) 상태로 잡아 두고 일정 시간 안에 결제가 확정되지 않으면 자동 해제"하는 구조를 쓴다. 주문 저장까지 포함해 하나의 DB 트랜잭션으로 묶고 결제만 밖에 두는 것이 가장 단순한 형태다.