관리 메뉴

백엔드 엔지니어 이재혁

[Java] CAS 연산 활용 본문

Java

[Java] CAS 연산 활용

alex00728 2025. 6. 9. 22:44

앞서, CAS 연산은 비교와 변경을 하나로 합친 원자적 연산으로 처리해주는 기능이라고 배웠다.

 

이번에는 그럼 CAS 연산을 활용해 멀티스레드 환경에서 락 없이 안전한 값 변경을 구현해보자.

 

incrementAndGet 직접 구현해보기

private static int incrementAndGet(AtomicInteger atomicInteger) {
    int getValue;
    boolean result = false;
    do {
        getValue = atomicInteger.get(); // 1
        log("getValue: " + getValue);
        result = atomicInteger.compareAndSet(getValue, getValue + 1); // 2
        log("result: " + result);
    } while (!result); // 3, 4

    return getValue + 1;
}
  1. `getValue`에 `atomicIntger`의 현재 값을 대입한다.
  2. `atomicInteger.compareAndSet()` 메서드(CAS 연산)를 이용해서 다음 작업들을 원자적으로 처리해준다.
    1. `getValue`가 `atomicInteger`의 현재 값과 같은지 비교한다.
    2. 같다면 `getValue+1`을 `atomicInteger`에 대입하고, `result`는 `true`를 반환한다.
    3. 다르다면 `atomicInteger`를 변경하지 않고, `result`는 `false`를 반환한다.
  3. `result`가 `true`가 나올 때, 즉 CAS 연산을 성공할 때만 do ~ while 문을 빠져나온다.
  4. `result`가 `false`가 나오면, CAS 연산을 실패했다는 의미이므로 처음부터 다시 실행한다.

 

정말로 스레드 안전하게 작동할까?

위의 설명만 보면 감이 잘 오지 않을 수 있다. 다른 스레드가 값을 중간에 바꿔버린 경우를 가정해보자.

 

`getValue = atomicInteger.get();` 이후에 다른 스레드에서 값을 바꿔버렸다면!

private static int incrementAndGet(AtomicInteger atomicInteger) {
    int getValue;
    boolean result = false;
    do {
        getValue = atomicInteger.get(); // 1) atomicInteger.get() = 0
        log("getValue: " + getValue);
        // 2) 이 사이 순간 다른 스레드에서 atomicInteger의 값을 1로 바꿔버렸다면?
        result = atomicInteger.compareAndSet(getValue, getValue + 1); // 3)
        log("result: " + result);
    } while (!result); // 4)
    
    // 5) 이 순간에 다른 스레드에서 값을 바꿔버릴 수 있기 때문에 
    // atomicInteger를 읽는 것이 아니라 getValue + 1을 반환한다.
    return getValue + 1;
}

 

1) `getValue`에는 0이 들어가 있을 것이다. `atomicInteger`의 값도 현재 0이다.

2) 중간에 다른 스레드에서 `atomicInteger`의 값을 1로 바꿨다!

3) CAS 연산을 실행하면 먼저, `atomicInteger`의 값과 `getValue`의 값이 같은지 확인한다.

`atomicInteger = 1` `getValue = 0` → 다르다! `atomicInteger`는 그대로 두고 `result`에 `false`를 반환한다!

4) `!result`는 `true` 이므로 다시 반복문을 처음부터 실행한다. `getValue`에 새로 값을 받아온다. 다른 스레드에서 변경한 그 값을 새로 받아온다.

5) return 시점에도 다른 스레드에서 값을 바꿔버렸을 수 있으니 `atomicInteger`의 값을 사용하지 않고 `getValue + 1`을 반환한다!

 

중간에 다른 스레드가 값을 변경하면, 새 값을 적용하지 않고 다시 실행하는 방식으로 스레드 안전성을 보장한다!

 

정리

  1. 현재 변수의 값을 읽어온다.
  2. 변수의 값을 1 증가시킬 때, 원래 값이 같은지 확인한다. (CAS 연산 사용)
  3. 동일하다면 증가된 값을 변수에 저장하고 종료한다.
  4. 동일하지 않다면 다른 스레드가 값을 중간에 변경한 것이므로, 다시 처음으로 돌아가 위 과정을 반복한다.

 

CAS 활용의 핵심

CAS 연산의 핵심은 내가 값을 바꿀 때까지 값이 유지됐는지 확인하는 것에 있다.

CAS 연산의 두번째 인자에 어떤 것이든 원자적 연산을 넣으면 되기 때문에 +1 이외에도 더 확장할 수 있을 것 같다.

지금까지는 true/false, 사칙연산 수준까지는 가능하겠다는 생각이 든다. 더 확장할 수 있을지는 더 고민해볼 수 있겠지만 바로 아래 CAS 연산의 성능적 관점을 고려하면 빠르게 처리할 수 있는 (= 충돌 가능성이 낮은) 연산에만 적용하는 것이 좋겠다.

 

CAS 연산의 성능적 관점

이 CAS 연산을 사용하면, 실제로 락을 사용하지 않기 때문에 락을 관리하는 비용(성능)을 아낄 수 있다.

대신, 충돌이 자주 일어난다면, do ~ while 문을 반복적으로 실행해야 하므로 이 반복문이 자주 실행되어 비용이 상승하기 때문에 락 관리 비용보다 작을 때만 사용해야 한다.

 

쉽게 표현하자면, (쉽게 표현할 수 없는 요소들이지만 설명을 간단하게 하기 위함)

락을 관리하는데 CPU 시간 2초가 필요하다고 가정해보자.

스레드간 충돌이 거의 일어나지 않는다면 CAS 연산을 사용하는데 드는 비용이 0에 가깝다고 할 수 있다.

이러면 2초만큼 CPU를 덜 사용한 것이다.

 

이번에는 반복문을 실행하는데 드는 비용이 10초이고, 스레드간 충돌이 50% 확률로 일어난다고 가정해보자.

평균적으로 반복문 재실행에 5초만큼 시간이 더 사용될 것이다. (10초 * 50%)

락을 안써서 2초 덜 사용했지만 충돌해서 반복문을 다시 실행하는데 5초를 더 사용했다. 3초만큼 CPU 전기세를 더 소모한 것이다.

 

CAS(Compare-And-Swap)와 락(Lock) 방식의 비교

락(Lock) 방식

  • 비관적(pessimistic) 접근법
  • 데이터에 접근하기 전에 항상 락을 획득
  • 다른 스레드의 접근을 막음
  • "다른 스레드가 방해할 것이다"라고 가정

CAS(Compare-And-Swap) 방식

  • 낙관적(optimistic) 접근법
  • 락을 사용하지 않고 데이터에 바로 접근
  • 충돌이 발생하면 그때 재시도
  • "대부분의 경우 충돌이 없을 것이다"라고 가정

Lock 방식을 사용하면, 간단한 CPU 연산을 처리할 때도 모든 스레드가 락을 획득하기 위해 대기하는 과정을 거쳐야 한다.

CAS 방식을 사용하면 일단 모든 스레드가 작업을 하게 두고, 문제가 생겼을 때만 다시 작업을 실행하도록 한다.

 

결론

간단한 CPU 연산에는 락보다는 CAS를 사용하는 것이 효과적이다.

'Java' 카테고리의 다른 글

[Java] 컬렉션 프레임워크와 동시성  (0) 2025.06.13
[JAVA] CAS 락 구현  (0) 2025.06.10
[Java] Atomic과 CAS 연산  (0) 2025.06.09
[Spring] Spring의 주입 방식  (0) 2025.06.09
[Spring] Spring이 작동하는 방식  (0) 2025.06.08