Thread Safety

mongBrown·2026년 4월 13일

Thread Safety — 공유 자원을 안전하게 다루는 방법

매장에 재고가 1개 남은 상품이 있다. 두 명이 동시에 구매 버튼을 눌렀다. 서버는 두 요청을 거의 동시에 받아서 둘 다 "재고 있음"을 확인하고 구매를 승인했다. 재고는 1개인데 두 명 모두 구매에 성공한 상황. 이게 thread safety가 깨진 전형적인 사례다.

여러 스레드가 같은 자원에 동시 접근할 때 데이터 일관성이 깨지는 것, 이걸 thread safety 문제라고 한다.


먼저 알아야 할 두 가지 개념

원자성 (Atomicity)

어떤 연산이 중간에 끊기지 않고 한 번에 완료되는 성질이다. 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이 되어야 함)

가시성 (Visibility)

한 스레드가 바꾼 값을 다른 스레드가 즉시 볼 수 있는 성질이다. CPU는 메인 메모리를 매번 읽는 게 느려서 값을 캐시에 복사해두고 쓴다. 스레드 A가 값을 바꿔도 그게 캐시에만 있으면 스레드 B는 메인 메모리의 옛날 값을 보게 된다.

스레드 A: count = 6으로 변경 → CPU 캐시에만 존재
스레드 B: count 읽음 → 메인 메모리의 옛날 값(5)을 봄

해결 방법

1. Stateless — 공유 상태 자체를 없애기

가장 근본적인 해결이다. 공유하는 상태가 없으면 동시에 접근해도 문제가 생기지 않는다.

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);  // 각 요청이 자신의 데이터를 직접 들고 다님
    }
}

2. synchronized — 락으로 순서 보장

한 번에 하나의 스레드만 접근하게 막는다. 락을 선점한 스레드가 끝날 때까지 나머지는 대기한다. 원자성과 가시성을 모두 보장한다.

메서드에 선언하면 인스턴스(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가 실행 중이면 같은 객체의 minusthis에 락을 걸려고 하다가 대기한다. plusminus는 같은 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; } }
}

plusminus는 같은 countLock을 쓰므로 서로 기다린다. renamenameLock을 쓰므로 plus와 무관하게 실행된다. 메서드 전체에 this 락을 걸면 관련 없는 작업까지 대기하게 되어 성능이 낮아지는데, 블록으로 락 객체를 분리하면 이를 피할 수 있다.

3. volatile — 가시성만 보장

캐시를 거치지 않고 메인 메모리에서 직접 읽고 쓴다. 한 스레드가 바꾼 값을 다른 스레드가 즉시 볼 수 있게 된다.

원자성은 보장하지 않는다. count++처럼 3단계 연산에는 쓸 수 없다. 쓰기 스레드가 하나이고 나머지는 읽기만 하는 단순 대입에 적합하다.

volatile boolean running = true;

// 스레드 A (하나): 단순 대입 — 1단계라 원자성 문제 없음
running = false;

// 스레드 B, C, D: 읽기만 — A가 바꾼 값을 즉시 봐야 함
while (running) {
    // 작업 중...
}

4. AtomicInteger — 락 없이 원자성 + 가시성

CAS(Compare-And-Swap)를 이용해 락 없이 원자성과 가시성을 모두 해결한다.

1. 현재 값을 읽는다 (5)
2. +1 계산 (6)
3. "지금도 5이면 6으로 써라" → CPU 명령어 하나로 실행
   → 5가 맞으면: 6 저장 성공
   → 다른 스레드가 이미 바꿨으면: 처음부터 재시도

READ-MODIFY-WRITE를 CPU 명령어 한 개로 처리하기 때문에 중간에 끼어들 수 없다. 락을 잡고 대기하는 과정이 없어서 synchronized보다 성능이 좋다.

5. Redis 분산 락 — 다중 서버 환경

서버가 여러 대인 환경에서는 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는 공유 자원의 성격, 서버 환경, 성능 요구사항을 같이 보고 상황에 맞추어서 쓰도록 하자

profile
화이팅!

0개의 댓글