매장에 재고가 1개 남은 상품이 있다. 두 명이 동시에 구매 버튼을 눌렀다. 서버는 두 요청을 거의 동시에 받아서 둘 다 "재고 있음"을 확인하고 구매를 승인했다. 재고는 1개인데 두 명 모두 구매에 성공한 상황. 이게 thread safety가 깨진 전형적인 사례다.
여러 스레드가 같은 자원에 동시 접근할 때 데이터 일관성이 깨지는 것, 이걸 thread safety 문제라고 한다.
어떤 연산이 중간에 끊기지 않고 한 번에 완료되는 성질이다. count++는 코드 한 줄이지만 CPU에서는 3단계로 실행된다.
1. 메모리에서 값을 읽는다 (READ)
2. +1 한다 (MODIFY)
3. 메모리에 다시 쓴다 (WRITE)
이 3단계 사이에 다른 스레드가 끼어들 수 있다. 원자성이 보장되지 않으면 아래 상황이 발생한다.
스레드 A: count 읽음 → 5
스레드 B: count 읽음 → 5 ← A가 아직 안 썼으니까
스레드 A: 5+1=6 저장
스레드 B: 5+1=6 저장 ← 둘 다 더했는데 결과는 6 (7이 되어야 함)
한 스레드가 바꾼 값을 다른 스레드가 즉시 볼 수 있는 성질이다. CPU는 메인 메모리를 매번 읽는 게 느려서 값을 캐시에 복사해두고 쓴다. 스레드 A가 값을 바꿔도 그게 캐시에만 있으면 스레드 B는 메인 메모리의 옛날 값을 보게 된다.
스레드 A: count = 6으로 변경 → CPU 캐시에만 존재
스레드 B: count 읽음 → 메인 메모리의 옛날 값(5)을 봄
가장 근본적인 해결이다. 공유하는 상태가 없으면 동시에 접근해도 문제가 생기지 않는다.
Spring의 @Service 빈은 기본적으로 싱글턴이다. 애플리케이션 전체에서 인스턴스가 하나라는 뜻이고, 모든 요청이 그 하나의 인스턴스를 공유한다. 여기에 상태를 저장하면 요청마다 서로의 데이터를 덮어쓰게 된다.
// 잘못된 방식 — 싱글턴 빈의 필드에 상태 저장
@Service
public class OrderService {
private String currentUser; // 모든 요청이 공유
public void setUser(String user) { this.currentUser = user; }
public void placeOrder(String item) {
buy(currentUser, item); // 다른 스레드가 setUser를 호출하면 값이 바뀌어 있을 수 있음
}
}
// Stateless 방식 — 상태를 파라미터로 전달
@Service
public class OrderService {
public void placeOrder(String user, String item) {
buy(user, item); // 각 요청이 자신의 데이터를 직접 들고 다님
}
}
한 번에 하나의 스레드만 접근하게 막는다. 락을 선점한 스레드가 끝날 때까지 나머지는 대기한다. 원자성과 가시성을 모두 보장한다.
메서드에 선언하면 인스턴스(this) 전체에 락이 걸린다.
class Counter {
private int count = 0;
private String name = "";
synchronized void plus() { count++; } // this에 락
synchronized void minus() { count--; } // this에 락
void rename(String n) { name = n; } // synchronized 없음
}
plus가 실행 중이면 같은 객체의 minus도 this에 락을 걸려고 하다가 대기한다. plus와 minus는 같은 this 락을 공유하기 때문이다.
반면 rename은 synchronized가 없어서 락을 확인하지 않는다. plus가 실행 중이어도 rename은 바로 진입할 수 있다.
블록으로 선언하면 락 범위와 대상을 직접 지정할 수 있다.
class Counter {
private int count = 0;
private String name = "";
private final Object countLock = new Object();
private final Object nameLock = new Object();
void plus() { synchronized(countLock) { count++; } }
void minus() { synchronized(countLock) { count--; } }
void rename(String n) { synchronized(nameLock) { name = n; } }
}
plus와 minus는 같은 countLock을 쓰므로 서로 기다린다. rename은 nameLock을 쓰므로 plus와 무관하게 실행된다. 메서드 전체에 this 락을 걸면 관련 없는 작업까지 대기하게 되어 성능이 낮아지는데, 블록으로 락 객체를 분리하면 이를 피할 수 있다.
캐시를 거치지 않고 메인 메모리에서 직접 읽고 쓴다. 한 스레드가 바꾼 값을 다른 스레드가 즉시 볼 수 있게 된다.
원자성은 보장하지 않는다. count++처럼 3단계 연산에는 쓸 수 없다. 쓰기 스레드가 하나이고 나머지는 읽기만 하는 단순 대입에 적합하다.
volatile boolean running = true;
// 스레드 A (하나): 단순 대입 — 1단계라 원자성 문제 없음
running = false;
// 스레드 B, C, D: 읽기만 — A가 바꾼 값을 즉시 봐야 함
while (running) {
// 작업 중...
}
CAS(Compare-And-Swap)를 이용해 락 없이 원자성과 가시성을 모두 해결한다.
1. 현재 값을 읽는다 (5)
2. +1 계산 (6)
3. "지금도 5이면 6으로 써라" → CPU 명령어 하나로 실행
→ 5가 맞으면: 6 저장 성공
→ 다른 스레드가 이미 바꿨으면: 처음부터 재시도
READ-MODIFY-WRITE를 CPU 명령어 한 개로 처리하기 때문에 중간에 끼어들 수 없다. 락을 잡고 대기하는 과정이 없어서 synchronized보다 성능이 좋다.
서버가 여러 대인 환경에서는 synchronized나 AtomicInteger가 통하지 않는다. 각 서버는 독립된 JVM 위에서 실행되고, JVM은 자기 프로세스의 메모리만 관리한다. 서버 1이 락을 잡아도 서버 2는 그 사실을 알 방법이 없다.
재고: 1개
서버 1 (스레드 A): synchronized로 재고 조회 → 1개 확인
서버 2 (스레드 B): synchronized로 재고 조회 → 1개 확인 ← 서버 1의 락을 모름
서버 1: 재고 차감 → 0개로 업데이트
서버 2: 재고 차감 → 0개로 업데이트 ← 이미 0인데 또 차감
서버 1과 서버 2의 synchronized는 각자 자기 JVM 안에서만 유효하다. 서버 1이 락을 잡은 동안 서버 1 내의 다른 스레드는 막을 수 있지만, 서버 2는 막지 못한다.
Redis는 단일 스레드로 동작해서 동시에 두 요청이 들어와도 하나씩 처리한다. 모든 서버가 같은 Redis를 바라보기 때문에 서버가 몇 대든 락을 공유할 수 있다.
재고: 1개
서버 1: Redis에 락 키 SET (성공) → 재고 조회 → 1개 → 차감
서버 2: Redis에 락 키 SET 시도 → 이미 키가 있음 → 대기
서버 1: 재고 0개로 업데이트 완료 → 락 키 삭제
서버 2: 락 키 SET 성공 → 재고 조회 → 0개 → 구매 불가 처리
공유 상태를 없앨 수 있다 → Stateless
단순 대입, 쓰는 스레드가 하나 → volatile
복합 연산, 서버 단일 → AtomicInteger (성능 우선)
복합 연산, 락 범위 제어 필요 → synchronized 블록
서버 여러 대 → Redis 분산 락
성능만 보면 Stateless > Atomic > synchronized > Redis 순이다. 하지만 성능이 유일한 기준은 아니다. 서버가 여러 대라면 Atomic은 처음부터 선택지가 아니고, 공유 상태를 없애는 게 구조적으로 불가능한 경우도 있다.
중요한 건 "가장 빠른 방법"을 고르는 게 아니라 "지금 환경에서 쓸 수 있는 방법 중 가장 적절한 것"을 고르는 것이다. 단일 서버에서 단순 플래그 하나를 공유한다면 volatile로 충분하고, 재고처럼 동시 접근이 잦고 정합성이 중요한 자원은 환경에 따라 Atomic이나 Redis를 선택한다. synchronized는 락 범위를 세밀하게 제어해야 할 때, 특히 같은 객체 안에서 보호가 필요한 자원과 그렇지 않은 자원이 섞여 있을 때 블록 단위로 쓰는 게 유리하다.
결국 thread safety는 공유 자원의 성격, 서버 환경, 성능 요구사항을 같이 보고 상황에 맞추어서 쓰도록 하자