Spring/동시성 제어

CAS(Compare-And-Swap) 이해하기

kyoooooong 2025. 11. 6. 11:48

 

1. 동시성의 출발점: synchronized를 이해하기

자바에서 여러 스레드가 동시에 같은 데이터를 수정하면, 서로의 연산이 덮어써지거나 순서가 꼬이는 데이터 불일치(Data Race) 가 발생할 수 있습니다. 이 문제를 막기 위한 가장 기본적인 방법이 바로 synchronized 키워드입니다. 이 키워드는 한 번에 오직 하나의 스레드만 특정 코드 블록에 접근할 수 있도록 하는 락(lock) 역할을 합니다.

 

내부 동작 원리

자바의 락은 내부적으로 모니터(Monitor) 라는 메커니즘을 사용합니다. 즉, synchronized 블록에 진입하는 순간, 해당 객체의 모니터 락을 획득(monitorenter) 하고, 블록을 빠져나올 때 락을 해제(monitorexit) 합니다. 이 과정은 바이트코드 수준에서 monitorenter / monitorexit 명령으로 표현되며, 자바 언어 차원에서는 단순히 다음과 같은 형태로 보입니다

synchronized(obj) {
    // 한 번에 한 스레드만 접근 가능한 코드
}

 

즉, 자바의 락(lock)은 “모니터(Monitor)” 메커니즘을 이용해 구현되어 있고, 그 모니터를 사용하는 주요 수단이 synchronized 키워드입니다.

모니터(Monitor) JVM 내부에 구현된 동기화 메커니즘. 락(lock)을 관리하고, 대기(wait)/알림(notify) 기능을 제공.
synchronized 개발자가 사용할 수 있는 언어 차원의 키워드로, 실제로는 내부에서 모니터 락을 획득하고 해제하는 명령(monitorenter / monitorexit)으로 변환.

 

좀 더 구체적으로 보면

  • synchronized는 문법적 도구입니다. → “이 코드 블록을 한 번에 한 스레드만 실행하게 해줘” 라고 JVM에 요청하는 역할.
  • JVM은 그 요청을 처리하기 위해 내부의 ‘모니터 락(monitor lock)’을 사용합니다.
  • → 각 객체(Object)는 내부적으로 “모니터”를 하나씩 가지고 있고,
  • → synchronized(obj) 문이 실행되면 그 객체의 모니터 락을 얻어야 코드가 실행됩니다.

 

락의 범위

  • synchronized 인스턴스 메서드 → 해당 객체(this) 의 모니터를 획득
  • synchronized 정적 메서드 → 해당 클래스(Class 객체) 의 모니터를 획득
  • synchronized(obj) { ... } → 명시한 obj 의 모니터를 획득

즉, “한 번에 하나의 스레드만 코드 블록을 통과”하도록 보장합니다.

 

특징

  • 재진입 가능(Reentrant) : 같은 스레드가 같은 락을 다시 얻을 수 있음
  • 블로킹(Blocking) : 이미 점유된 락이면 다른 스레드는 대기 상태로 전환됨
  • 공정성 비보장 : 먼저 기다린 스레드가 반드시 먼저 깨어난다는 보장은 없음

 

성능 측면

임계구역이 길거나 경합이 심하면, 다른 스레드가 계속 대기하게 되어 TPS(처리량) 이 떨어집니다. 그래서 락 구간을 줄이거나, 아예 락을 쓰지 않는 방법(Non-blocking) 으로 가는 시도가 등장했습니다.

synchronized는 안전하고 단순하지만, 대기(Blocking)로 인한 성능 손실이 크다는 한계가 있습니다. 이 단점을 보완하기 위해 등장한 것이 CAS(Compare-And-Swap) 입니다.


Compare-And-Swap(CAS): 락 없이 안전하게 바꾸기

CAS가 뭔가요?

CAS(Compare-And-Swap)는 프로그래밍 언어나 라이브러리의 이름이 아니라, CPU가 직접 지원하는 동시성 제어 알고리즘입니다. 즉, 여러 스레드가 동시에 하나의 데이터를 수정하려 할 때, 락(lock)을 걸지 않고도 안전하게 값의 일관성을 유지하도록 도와주는 하드웨어 수준의 원자적 연산(atomic operation) 이죠.

말 그대로

“현재 값이 내가 예상한 값과 같다면, 그 값을 새 값으로 바꿔라.”

라는 한 번에 이루어지는(원자적) 명령을 수행합니다.

 

이 연산은 CPU가 한 덩어리로 처리하기 때문에 중간에 다른 스레드가 끼어들거나 값이 깨질 일이 없습니다.

자바에서는 이러한 CAS 알고리즘을 바로 활용할 수 있도록 java.util.concurrent.atomic 패키지에서 추상화해 제공합니다. 대표적으로 아래와 같은 형태로 사용합니다

atomic.compareAndSet(expectedValue, newValue);

 

이 메서드는 내부적으로 CAS 알고리즘을 이용해 비교 후 교체를 시도하며, 현재 값이 기대한 값과 같으면 새 값으로 교체하고 true를 반환하고, 그렇지 않으면 false를 반환합니다.

 

정리하자면

  • CAS는 “하드웨어 수준에서 수행되는 비교 후 교체 알고리즘”입니다.
  • 자바에서는 이를 compareAndSet() 메서드로 간단히 사용할 수 있습니다.
  • 여러 스레드가 동시에 접근하더라도, 한 번에 한 스레드만 성공적으로 갱신하게 됩니다.
  • 실패한 스레드는 잠깐 기다렸다가 다시 시도(retry) 하면 됩니다.

 

왜 빠른가요?

CAS는 Non-blocking(논블로킹) 방식이기 때문입니다. synchronized는 이미 락을 잡은 스레드가 있으면 다른 스레드가 기다려야(Blocking) 하지만, CAS는 기다리지 않습니다.

즉,

  • 실패하면 “그냥 다시 시도”
  • 성공하면 즉시 다음 단계로 진행

이런 식으로 OS 수준의 대기나 스케줄링 비용이 전혀 없습니다.

 

정리하자면

  • 대기열이 없음 → 컨텍스트 스위칭 비용 제거
  • 짧은 연산에 최적화 → 단일 필드나 카운터 증가처럼 작은 작업에 매우 빠름
  • 실패해도 바로 재시도 → 락처럼 스레드를 멈추지 않음

요약하면: “락을 걸지 않고도 스레드 간 일관성을 지키는 방법이 CAS입니다.”

 

간단한 예시

import java.util.concurrent.atomic.AtomicInteger;

public class CasCounter {
    private final AtomicInteger count = new AtomicInteger();

    public int increment() {
        int prev, next;
        do {
            prev = count.get();       // 1) 현재 값 읽기
            next = prev + 1;          // 2) 증가
        } while (!count.compareAndSet(prev, next)); // 3) 비교 후 교체 (실패 시 반복)
        return next;
    }
}

 

여러 스레드가 동시에 호출해도, 한 번에 한 스레드만 값 변경에 성공합니다.


CAS와 자바 메모리 모델(JMM): volatile의 역할

CAS는 “바꾸는 동작” 자체는 안전하게 수행되지만, 그 변경이 다른 스레드에게 즉시 보이는지(가시성) 까지는 보장하지 않습니다. 그래서 자바에서는 volatile과 함께 사용하는 것이 기본 패턴입니다.

 

volatile이란?

자바의 스레드는 변수를 자신의 캐시 메모리에 복사해 사용합니다. 이때 한 스레드가 값을 바꿔도, 다른 스레드는 여전히 옛날 값을 볼 수 있죠. volatile을 붙이면 이런 문제가 해결됩니다.

private volatile int count;
  • 모든 스레드가 변수를 메인 메모리에서 직접 읽고 씁니다. → 즉시 최신 값이 반영됨
  • “한 스레드가 쓴 값”은 “다른 스레드의 읽기보다 항상 먼저 일어난다”는 Happens-Before 관계를 만들어줍니다. → 데이터가 꼬이지 않습니다.

핵심 요약: volatile은 “가시성(visibility)”을 보장하고, CAS는 “원자성(atomicity)”을 보장합니다. 이 둘이 함께 있어야 락 없이 안전한 갱신이 가능합니다.

 

추가 이해 포인트: Atomic 계열 클래스의 내부 구조

AtomicIntegerAtomicLongAtomicReference 등 Atomic 계열 클래스

자바에서 가장 대표적인 락 없는 동시성 제어(Non-blocking concurrency) 도구입니다.

이 클래스들은 내부적으로 모두 “volatile + CAS” 조합으로 설계되어 있습니다.

 

1. 내부 필드: volatile로 선언

// 예: AtomicLong.java 일부
private volatile long value;

여기서 volatile은 가시성(visibility) 을 보장합니다. 즉, 여러 스레드가 동시에 이 value를 읽거나 쓸 때, 각 스레드가 캐시에 저장된 오래된 값을 보는 것이 아니라 항상 메인 메모리에 있는 최신 값을 읽게 만듭니다.

한 스레드가 value를 바꾸면,다른 스레드의 다음 읽기 연산에서는반드시 최신 값이 반영됩니다.

 

이로써 “값이 즉시 반영되는 일관성” 이 확보됩니다.

 

2. 값 변경: CAS(compareAndSet)로 원자성 확보

값을 실제로 변경할 때는 단순히 value++ 같은 연산을 하지 않습니다. 그 대신 하드웨어가 지원하는 CAS 명령어를 사용합니다.

public final boolean compareAndSet(long expect, long update) {
    return U.compareAndSwapLong(this, VALUE, expect, update);
}

여기서 U는 Unsafe 클래스의 인스턴스이며, 실제 CPU의 compare-and-swap 명령을 호출해 메모리 주소 수준에서 직접 원자적 비교·교체를 수행합니다.

  • expect: 내가 예상한 현재 값
  • update: 바꾸려는 새 값
  • 둘이 같으면 교체 성공, 다르면 실패 후 재시도

이 과정이 성공적으로 완료되면, 락을 잡지 않고도 한 스레드만 안전하게 값 변경에 성공하게 됩니다.

 

3. CAS 실패 시 재시도 루프

CAS는 실패할 수도 있습니다. 다른 스레드가 그 사이 값을 바꿔버리면, 내 expect 값과 현재 값이 달라지기 때문입니다. 그래서 Atomic 계열 클래스의 연산(incrementAndGetaddAndGet 등)은 내부적으로 이런 루프 구조를 가집니다.

do {
    prev = value;            // 현재 값 읽기 (volatile)
    next = prev + 1;         // 새로운 값 계산
} while (!compareAndSet(prev, next)); // 실패하면 재시도

이 방식 덕분에 락을 기다릴 필요가 없고, 성공할 때까지 아주 짧은 반복만 수행하면 됩니다.

 

예시 비교

❌ volatile만 사용했을 때 (데이터 깨짐)

volatile long count = 0;

void increment() {
    count++; // 여러 스레드가 동시에 실행 시 덮어쓰기 발생
}

결과: 1000번 호출해도 결과가 950, 980 등 들쭉날쭉할 수 있음.

 

✅ AtomicLong 사용 (CAS로 보장)

AtomicLong count = new AtomicLong();

void increment() {
    count.incrementAndGet(); // CAS 기반, 원자적
}

결과: 1000번 호출 → 항상 정확히 1000.

 

4. 왜 효율적인가?

  1. 락을 걸지 않으므로 스레드 대기나 컨텍스트 스위칭이 없습니다.
  2. → OS 레벨의 스케줄링 비용이 사라져 훨씬 가볍습니다.
  3. 짧은 갱신 작업(단일 필드) 에 최적화되어 있습니다.
  4. → 카운터, 시퀀스, 통계 값 증가 등에 매우 효율적입니다.
  5. CPU 캐시 친화적 구조
  6. → 메모리 장벽을 최소화하면서도 데이터 일관성을 유지합니다.

 

결론

즉, AtomicLong은 내부 필드를 volatile로 선언하여 가시성을 확보하고, CAS 명령어(compareAndSet) 를 통해 원자성을 유지합니다.

결국 Atomic 클래스는

“volatile + CAS”를 조합한, 락 없는 동시성 제어의 기본 단위입니다.


CAS의 단점과 해결 방법

(1) 경합이 심할 때의 문제

CAS는 실패하면 바로 재시도하기 때문에, 여러 스레드가 동시에 같은 자원을 바꾸려 할 때 모두가 서로의 변경을 덮어쓰려 시도하면서 계속 충돌이 납니다. 이런 상황에서는 실제로 아무도 성공하지 못하고 계속 실패와 재시도를 반복하면서 CPU를 낭비하게 되는데, 이를 라이블락(Livelock) 상태라고 부릅니다. (락이 걸린 건 아니지만, 서로 양보하느라 아무 일도 진척되지 않는 상태입니다.)

 

해결책 1: 스핀(Spin)

스핀은 말 그대로 아주 짧은 시간 동안 “기다리지 않고 바쁘게 도는 것” 입니다. 즉, 실패 시 바로 스레드를 잠재우지 않고, CPU가 깨어 있는 상태로 짧게 루프를 돌면서 다시 CAS를 시도합니다. 이때 JDK 9 이상에서는 아래 메서드를 사용해 CPU에 “나는 지금 잠깐 스핀 중이야” 라고 알려줄 수 있습니다.

Thread.onSpinWait(); // JDK 9+, 스핀 중임을 CPU에 힌트

이 힌트 덕분에 CPU는 전력 소모를 줄이거나 파이프라인을 효율적으로 조정하는 등의 최적화를 수행할 수 있습니다. 즉, 단순한 바쁜 대기(busy-wait)가 아니라, 짧고 효율적인 대기 방식으로 동작하게 됩니다.

 

해결책 2: 백오프(Backoff)

만약 스핀 중에도 계속 충돌이 발생한다면, 실패할수록 재시도 간격을 조금씩 늘려주는 방식이 효과적입니다. 이를 지수 백오프(Exponential Backoff) 라고 합니다.

즉,

  • 첫 번째 실패 → 10μs 대기
  • 두 번째 실패 → 20μs
  • 세 번째 실패 → 40μs … 이런 식으로
  • 대기 시간을 2배씩 늘려가며 재시도합니다.

이렇게 하면 동시에 시도하는 스레드들이 조금씩 서로 다른 타이밍에 재시도하게 되어, 충돌 확률이 자연스럽게 줄어듭니다. 백오프는 단순히 대기 시간을 늘리는 게 아니라, 시도 간의 간격을 분산시켜 CPU 경쟁을 완화하는 전략입니다. 또한 보통 약간의 랜덤값(지터, jitter)을 섞어 스레드들이 같은 타이밍에 재시도하지 않게 만듭니다.

 

(2) ABA 문제 — “잠깐 바뀌었다가 다시 A로 돌아온 상황을 감지하지 못하는 함정”

CAS는 단순히 “현재 값이 내가 예상한 값(A)과 같은가?”만 비교합니다. 그런데 그 사이에 값이 A → B → A로 바뀌었다면, 겉보기엔 여전히 A이기 때문에 “변경되지 않았다”고 착각하고 성공해버릴 수 있습니다. 이것이 바로 ABA 문제입니다.

 

왜 위험한가

  • 조건이 깨졌는데도 진행되는 문제
  • 중간 단계(B 상태)에서만 유효했던 제약 조건(예: 거래 한도, 재고 제한, 버전 검사 등)이 있었더라도 값이 다시 A로 돌아오면 CAS는 “변경 없음”으로 인식하여 검증을 건너뛰고 잘못된 로직을 실행할 수 있습니다.
  • 데이터 무결성 훼손
  • 값은 같지만 이미 의미가 달라졌거나 삭제된 데이터를 다시 참조하게 되어 깨진 연결(link)이나 중복 처리 오류가 발생할 수 있습니다.

 

예시 — 택배 처리 시스템

  1. 스레드 1: “A 박스(송장 #123)”를 ‘배송 중’ 상태로 변경하려고 준비합니다.
  2. 스레드 2: 해당 박스를 ‘취소’ 처리했다가, 같은 송장 번호로 새로운 A′ 박스를 등록합니다.
  3. 스레드 1: 여전히 “A 박스”라고 생각하고 CAS로 ‘배송 중’ 상태로 변경합니다.

👉 겉보기엔 송장 번호(A)가 같지만, 실제로는 완전히 다른 박스(A′)이므로 취소된 상품이 배송 처리되는 오류가 생깁니다.

 

해결책 — “값 + 변화의 흔적(버전/마크)”을 함께 비교하기

1️⃣ AtomicStampedReference<T>

  • 값뿐 아니라 스탬프(버전 번호) 를 함께 저장합니다.
  • 값이 A로 돌아와도 스탬프가 달라졌기 때문에 “이전과 다르다”고 판단합니다.
import java.util.concurrent.atomic.AtomicStampedReference;

AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);

int stamp = ref.getStamp();
String cur = ref.getReference();

// (예상값=A, 예상스탬프=stamp) → (새값=C, 새스탬프=stamp+1)
boolean ok = ref.compareAndSet("A", "C", stamp, stamp + 1);

 

2️⃣ AtomicMarkableReference<T>

  • 값과 함께 1비트 상태 플래그(마크) 를 저장합니다.
  • “활성/삭제됨” 등 간단한 상태를 같이 관리할 때 유용합니다.
  • (예: 락-프리 리스트에서 ‘논리 삭제’ 표시 등)

 

요약

  • 문제: CAS는 값만 비교하므로 A→B→A를 “변경 없음”으로 오판할 수 있습니다.
  • 위험: 조건 검증 우회, 중복 처리, 데이터 무결성 훼손 가능.
  • 해결: AtomicStampedReference나 AtomicMarkableReference처럼 값 + 버전(또는 상태) 을 함께 비교하여 “변화의 흔적”까지 검증하면 안전합니다.

CAS(Compare-And-Swap) vs 낙관적 락(Optimistic Lock)

공통점: “락 없이 낙관적으로 시도한다”

둘 다 “경합이 심하지 않을 것”이라 가정하고, 락(lock) 을 걸지 않은 채 먼저 작업을 시도한 뒤, 변경 충돌이 감지되면 그때 다시 시도(retry) 하는 방식을 취합니다.

 

차이점 ①: 적용 레벨

구분 CAS (Compare-And-Swap) 낙관적 락 (Optimistic Lock)

작동 레벨 CPU / JVM 내부 (하드웨어 수준) 데이터베이스 / 애플리케이션 (소프트웨어 수준)
사용 위치 멀티스레드 환경, Atomic 클래스 트랜잭션, JPA/Hibernate
대표 예시 AtomicInteger.compareAndSet() @Version (JPA 버전 필드)

 

CAS는 CPU가 직접 지원하는 원자적 연산, 낙관적 락은 DB의 버전 필드 비교로 동시성 충돌을 감지하는 논리적 제어 방식입니다.

 

차이점 ②: 동작 방식

CAS (CPU 수준 원자 연산)

  • 메모리 값과 예상 값(expected)을 비교 → 같으면 새 값으로 교체
  • 하드웨어가 한 번에 처리 (원자성 보장)
  • 실패 시 즉시 재시도 (Non-blocking)
do {
  prev = count.get();
  next = prev + 1;
} while (!count.compareAndSet(prev, next));

 

낙관적 락 (DB 수준 충돌 감지)

  • DB 레코드에 version 컬럼 추가
  • UPDATE 시점에 WHERE version=? 조건으로 비교
  • 다르면 갱신 0건 → 충돌 발생 → 예외 처리
@Entity
class Product {
    @Version
    private Long version;
}

 

실제 SQL 동작 👇

UPDATE product
SET price = ?, version = version + 1
WHERE id = ? AND version = ?

 

관계 요약

레벨 CPU / 메모리 DB / 애플리케이션
비교 기준 메모리 값 (expectedValue) DB 버전(version)
충돌 감지 방식 CAS 실패 시 재시도 version 불일치 시 예외
철학 “잠그지 말고, 값이 바뀌면 다시 시도” “락 없이 버전 비교로 충돌 감지”
대표 키워드 Atomic, Non-blocking Versioning, Conflict detection

CAS는 하드웨어 수준의 낙관적 락, 낙관적 락은 애플리케이션 수준의 CAS

 

둘 다 락 없이 동시성 문제를 해결하지만, CAS는 CPU 차원에서 변수 하나를 제어하고, 낙관적 락은 트랜잭션 단위로 데이터 일관성을 보장합니다.


정리

synchronized 안전하고 단순하지만, 블로킹으로 느릴 수 있음
CAS 락 없이 빠른 원자 연산, 경합이 적을수록 효율적
volatile 메모리 가시성 보장 — CAS와 함께 필수
단점 ABA 문제, 라이블락 → 스핀/백오프/Stamped로 해결
사용 예시 AtomicIntegerConcurrentHashMapConcurrentLinkedQueueLongAdder 등