락과 CAS 선택 기준
CAS 갱신 함수 호출 수와 재시도율을 관찰하고, 예약 상태의 상호 배제와 예외 복구를 구분해 락과 CAS 선택 기준을 정리합니다.
락은 공유 상태 접근을 직렬화하고, CAS 기반 갱신은 충돌 시 다시 시도할 수 있습니다.
충돌이 드물고 계산이 짧으면 CAS가 유리할 수 있습니다.
반대로 여러 스레드가 같은 값을 자주 갱신하거나 계산에 시간이 걸리면 반복 실패가 CPU와 계산량을 낭비합니다.
선택은 “락 프리라서 빠르다” 같은 이름이 아니라 충돌률, 임계 영역 길이, 대기 중 CPU 사용, 복합 불변식으로 판단합니다.
데이터베이스 호출처럼 긴 작업은 CAS 루프 안에 넣을 대상이 아닙니다.
CAS 갱신 함수의 호출 횟수
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
public final class ExpensiveCasUpdate {
private final AtomicInteger value = new AtomicInteger();
private final LongAdder calculations = new LongAdder();
void increment() {
value.updateAndGet(current -> {
calculations.increment();
long checksum = 0;
for (int i = 0; i < 10_000; i++) {
checksum += i;
}
if (checksum < 0) throw new AssertionError();
return current + 1;
});
}
public static void main(String[] args) throws InterruptedException {
ExpensiveCasUpdate demo = new ExpensiveCasUpdate();
Thread[] workers = new Thread[8];
for (int i = 0; i < workers.length; i++) {
workers[i] = Thread.ofPlatform().start(() -> {
for (int n = 0; n < 1_000; n++) {
demo.increment();
}
});
}
for (Thread worker : workers) {
worker.join();
}
System.out.println("updates=" + demo.value.get());
System.out.println("calculations=" + demo.calculations.sum());
}
}이번 실행은 updates=8000, calculations=49493을 출력했습니다. calculations는 갱신 함수 호출 수이며 CAS 실패 횟수를 직접 센 값은 아닙니다.
갱신 함수는 재호출될 수 있으므로 함수 안의 지표 변경은 설명용이며 제품 코드에서는 피합니다.
상수 checksum 루프는 최적화될 수 있고, 이 코드는 경과 시간·CPU 사용량·잠금 구현의 성능을 측정하지 않습니다.
측정할 항목과 외부 작업
- CAS 갱신 함수는 순수해야 하며 외부 I/O를 포함하지 않는다.
- 성능 결론은 처리량과 지연 백분위, 재시도 수를 같은 부하에서 측정한다.
잠금으로 묶은 좌석 제거와 매출 증가
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
public final class LockedSeatInventory {
record Receipt(String seat, int charged) {}
private final ReentrantLock lock = new ReentrantLock();
private final Set<String> available = new HashSet<>(Set.of("A1", "A2"));
private int revenue;
Receipt reserve(String seat, int price) {
if (price <= 0) throw new IllegalArgumentException("price");
lock.lock();
try {
if (!available.remove(seat)) throw new IllegalStateException("unavailable: " + seat);
revenue = Math.addExact(revenue, price);
return new Receipt(seat, price);
} finally {
lock.unlock();
}
}
int revenue() {
lock.lock();
try { return revenue; }
finally { lock.unlock(); }
}
public static void main(String[] args) throws InterruptedException {
LockedSeatInventory inventory = new LockedSeatInventory();
Runnable task = () -> {
try { System.out.println(inventory.reserve("A1", 120)); }
catch (IllegalStateException e) { System.out.println("rejected"); }
};
Thread a = Thread.ofPlatform().start(task);
Thread b = Thread.ofPlatform().start(task);
a.join(); b.join();
System.out.println("revenue=" + inventory.revenue());
}
}좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다
예약의 좌석 제거와 revenue 변경·예외·unlock 범위가 다릅니다. 특히 overflow 시 좌석은 제거되고 수익은 유지되는 부분 변경을 단계로 설명합니다.
LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다.
예외 경계 · 변경과 잠금 해제
좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다
같은 락으로 예약해도 예외 전에 끝난 좌석 제거는 자동으로 되돌아가지 않습니다.
설명과 원래 예시 값
가격 거절: price ≤ 0
- available·revenue 변경
- remove 전 · 모두 유지
- 종료 방식
IllegalArgumentException· 잠금 획득 전
좌석 없음
- available·revenue 변경
- remove가 false · 모두 유지
- 종료 방식
IllegalStateException· finally에서 unlock
정상 예약
- available·revenue 변경
- 좌석 제거 후
revenue + price대입 - 종료 방식
Receipt반환 · finally에서 unlock
합계 오버플로
- available·revenue 변경
- 좌석은 제거됨 · revenue는 이전 값
- 종료 방식
ArithmeticException· finally에서 unlock
이번 실행은 rejected 뒤 Receipt[seat=A1, charged=120], revenue=120을 출력했습니다. 첫 두 줄의 출력 순서는 고정하지 않습니다. 가격 거절과 오버플로는 이 main에서 실행하지 않은 코드 경로입니다.
이 예의 조건
- 원 main의 같은 A1·가격 120 요청 두 개에서는 정상 Receipt와 좌석 없음 거절이 각각 한 번이며 첫 두 출력의 순서는 가변입니다.
- 가격 거절·합계 overflow는 main 미실행 조건입니다. 각 분기를 공통 검사·변경·종료 단계마다 비교합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다. 예외 경계 · 변경과 잠금 해제 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 같은 락으로 예약해도 예외 전에 끝난 좌석 제거는 자동으로 되돌아가지 않습니다. 가격 거절: price ≤ 0 available·revenue 변경 remove 전 · 모두 유지 종료 방식 IllegalArgumentException · 잠금 획득 전 좌석 없음 available·revenue 변경 remove가 false · 모두 유지 종료 방식 IllegalStateException · finally에서 unlock 정상 예약 available·revenue 변경 좌석 제거 후 revenue + price 대입 종료 방식 Receipt 반환 · finally에서 unlock 합계 오버플로 available·revenue 변경 좌석은 제거됨 · revenue는 이전 값 종료 방식 ArithmeticException · finally에서 unlock 이번 실행은 rejected 뒤 Receipt[seat=A1, charged=120] , revenue=120 을 출력했습니다. 첫 두 줄의 출력 순서는 고정하지 않습니다. 가격 거절과 오버플로는 이 main에서 실행하지 않은 코드 경로입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 가격과 잠금의 진입 조건 가격 거절은 잠금 전에 끝나고 유효 가격의 요청만 같은 락에 들어갑니다. |
|
|
| 제거와 합계의 분기 좌석 제거 결과와 그 뒤 합계 계산에 따라 필드 변경 범위가 달라집니다. |
|
|
| 각 반환과 해제 책임 잠금을 얻은 분기는 정상·예외 모두 finally에서 unlock합니다. |
|
|
좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다
예약의 좌석 제거와 revenue 변경·예외·unlock 범위가 다릅니다. 특히 overflow 시 좌석은 제거되고 수익은 유지되는 부분 변경을 단계로 설명합니다.
LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다.
예외 경계 · 변경과 잠금 해제
좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다
같은 락으로 예약해도 예외 전에 끝난 좌석 제거는 자동으로 되돌아가지 않습니다.
설명과 원래 예시 값
가격 거절: price ≤ 0
- available·revenue 변경
- remove 전 · 모두 유지
- 종료 방식
IllegalArgumentException· 잠금 획득 전
좌석 없음
- available·revenue 변경
- remove가 false · 모두 유지
- 종료 방식
IllegalStateException· finally에서 unlock
정상 예약
- available·revenue 변경
- 좌석 제거 후
revenue + price대입 - 종료 방식
Receipt반환 · finally에서 unlock
합계 오버플로
- available·revenue 변경
- 좌석은 제거됨 · revenue는 이전 값
- 종료 방식
ArithmeticException· finally에서 unlock
이번 실행은 rejected 뒤 Receipt[seat=A1, charged=120], revenue=120을 출력했습니다. 첫 두 줄의 출력 순서는 고정하지 않습니다. 가격 거절과 오버플로는 이 main에서 실행하지 않은 코드 경로입니다.
이 예의 조건
- 원 main의 같은 A1·가격 120 요청 두 개에서는 정상 Receipt와 좌석 없음 거절이 각각 한 번이며 첫 두 출력의 순서는 가변입니다.
- 가격 거절·합계 overflow는 main 미실행 조건입니다. 각 분기를 공통 검사·변경·종료 단계마다 비교합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 LockedSeatInventory.reserve의 가격 검사, 좌석 제거, 합계 계산 순서에 따른 정상 반환과 세 예외 경로를 비교합니다. 예외 경계 · 변경과 잠금 해제 좌석 예약의 상호 배제와 예외 시 변경 범위를 구분한다 같은 락으로 예약해도 예외 전에 끝난 좌석 제거는 자동으로 되돌아가지 않습니다. 가격 거절: price ≤ 0 available·revenue 변경 remove 전 · 모두 유지 종료 방식 IllegalArgumentException · 잠금 획득 전 좌석 없음 available·revenue 변경 remove가 false · 모두 유지 종료 방식 IllegalStateException · finally에서 unlock 정상 예약 available·revenue 변경 좌석 제거 후 revenue + price 대입 종료 방식 Receipt 반환 · finally에서 unlock 합계 오버플로 available·revenue 변경 좌석은 제거됨 · revenue는 이전 값 종료 방식 ArithmeticException · finally에서 unlock 이번 실행은 rejected 뒤 Receipt[seat=A1, charged=120] , revenue=120 을 출력했습니다. 첫 두 줄의 출력 순서는 고정하지 않습니다. 가격 거절과 오버플로는 이 main에서 실행하지 않은 코드 경로입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 가격과 잠금의 진입 조건 가격 거절은 잠금 전에 끝나고 유효 가격의 요청만 같은 락에 들어갑니다. |
|
|
| 제거와 합계의 분기 좌석 제거 결과와 그 뒤 합계 계산에 따라 필드 변경 범위가 달라집니다. |
|
|
| 각 반환과 해제 책임 잠금을 얻은 분기는 정상·예외 모두 finally에서 unlock합니다. |
|
|
available과 revenue의 변경을 서로 다른 원자 연산으로 나누면 두 변경 사이의 상태가 노출될 수 있습니다. 공통 잠금의 상호 배제와 예외 발생 시 복구 정책은 별도로 설계해야 합니다.
불변 객체 CAS로 작은 복합 상태 바꾸기
import java.util.concurrent.atomic.AtomicReference;
public final class CasSeatCounter {
record State(int remaining, int revenue) {}
private final AtomicReference<State> state = new AtomicReference<>(new State(2, 0));
boolean reserve(int price) {
while (true) {
State observed = state.get();
if (observed.remaining() == 0) return false;
State candidate = new State(observed.remaining() - 1,
Math.addExact(observed.revenue(), price));
if (state.compareAndSet(observed, candidate)) return true;
}
}
public static void main(String[] args) throws InterruptedException {
CasSeatCounter counter = new CasSeatCounter();
Thread a = Thread.ofPlatform().start(() -> counter.reserve(70));
Thread b = Thread.ofPlatform().start(() -> counter.reserve(80));
Thread c = Thread.ofPlatform().start(() -> counter.reserve(90));
a.join();
b.join();
c.join();
System.out.println(counter.state.get());
}
}이 main은 가격 70·80·90 중 두 건을 수용하므로 remaining은 0이고 revenue는 150·160·170 중 하나입니다. 이번 출력은 State[remaining=0, revenue=150]이었습니다. 어떤 두 호출이 성공했는지와 각 reserve 반환값은 따로 출력하지 않습니다.
State는 작은 불변 값이므로 새 객체를 만든 뒤 참조 하나를 CAS로 교체합니다. AtomicReference가 내부 가변 객체의 임의 변경까지 보호하는 것은 아닙니다. 좌석 ID 집합 전체를 매번 복사해야 한다면 락 쪽이 단순할 수 있습니다.
이 reserve에는 잠금 예제와 달리 price가 양수인지 검사하는 코드가 없습니다. Math.addExact가 실패하면 candidate 구성 중에 예외가 발생하므로 공유 state 참조는 바뀌지 않습니다. 이 main의 세 양수 가격은 그 예외를 실행하지 않습니다.
확인 문제
원 클래스를 그대로 두고 main 대신 새 호출 순서를 생각해 보세요. LockedSeatInventory에서 A1을 Integer.MAX_VALUE에 예약한 뒤 A2를 1에 예약하면 두 번째 호출 뒤 available과 revenue는 어떤 상태인가요? CasSeatCounter에서도 첫 reserve 가격을 Integer.MAX_VALUE, 두 번째를 1로 두면 공유 State는 어떻게 남나요? 두 코드의 상태 모델은 좌석 ID 집합과 남은 수로 서로 다릅니다.
확인 문제 해설
잠금 구현의 첫 예약은 A1을 제거하고 revenue를 2,147,483,647로 만듭니다. 두 번째는 A2를 먼저 제거한 다음 덧셈에서 ArithmeticException을 내므로 available은 비고 revenue는 이전 값 그대로입니다. finally가 해제하는 것은 락이며 이미 제거한 A2를 복구하지 않습니다.
CAS 구현의 첫 성공 뒤 State는 (remaining=1, revenue=2,147,483,647)입니다. 두 번째는 candidate를 만드는 중 덧셈이 실패해 compareAndSet까지 도달하지 않으므로 공유 State가 유지됩니다. 이것도 외부 결제까지 되돌리는 트랜잭션은 아닙니다. 잠금 구현에서는 같은 락 안에서 좌석 존재와 실패할 수 있는 계산을 먼저 검증하고, 그 계산이 끝난 뒤 두 상태를 바꾸는 식으로 변경 경계를 설계할 수 있습니다. 원 예제 자체를 수정하거나 이 예외를 실행한 기록으로 해석하지 않습니다.
복합 상태 크기로 락·CAS 선택
| 질문 | CAS에서 확인할 것 | 락에서 확인할 것 |
|---|---|---|
| 충돌률 | 낮을수록 실패 재시도 부담이 작음 | 경쟁이 높으면 획득·대기 관리 비용 측정 |
| 계산 길이 | 실패로 버릴 계산량이 작고 매우 짧은가 | 필요한 상태 변경을 짧은 임계 영역으로 묶을 수 있는가 |
| 상태 크기 | 작은 불변 값을 한 참조로 교체 | 큰 가변 구조의 공통 임계 영역 |
| 대기 정책 | 재시도 동안 CPU 사용 | park·조건 대기 활용 |
| 외부 작업 | 갱신 함수에서 I/O·부수 효과 제외 | I/O는 가능한 한 분리하고 상태 재검사·실패 정책 설계 |
연습 문제
입금만 지원하는 잔액에 CAS 실패와 성공을 세고, 실패율이 20퍼센트를 넘는지 출력하세요.
계산 함수에는 지표 변경을 넣지 말고 명시적 루프에서 실패만 기록합니다.
정답과 해설
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public final class RetryAwareBalanceSolution {
private final AtomicLong balance = new AtomicLong();
private final LongAdder successes = new LongAdder();
private final LongAdder failures = new LongAdder();
void deposit(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount");
while (true) {
long observed = balance.get();
if (balance.compareAndSet(observed, Math.addExact(observed, amount))) {
successes.increment();
return;
}
failures.increment();
}
}
public static void main(String[] args) throws InterruptedException {
RetryAwareBalanceSolution account = new RetryAwareBalanceSolution();
Thread[] workers = new Thread[4];
for (int i = 0; i < workers.length; i++) {
workers[i] = Thread.ofPlatform().start(() -> {
for (int n = 0; n < 20_000; n++) {
account.deposit(1);
}
});
}
for (Thread worker : workers) {
worker.join();
}
long success = account.successes.sum();
long failure = account.failures.sum();
double rate = failure / (double) (success + failure);
System.out.printf("balance=%d retryRate=%.3f warn=%s%n",
account.balance.get(), rate, rate > 0.20);
}
}이번 출력은 balance=80000 retryRate=0.233 warn=true였습니다. 재시도율은 실패 횟수 / (성공 횟수 + 실패 횟수)이며 실행마다 달라집니다. 세 지표는 모든 worker를 join한 뒤 읽습니다.
retryRate는 소수 셋째 자리로 표시하지만 warn은 반올림 전 rate가 0.20을 넘는지로 결정합니다. 표시가 0.200이라는 이유만으로 warn 값을 정할 수는 없습니다. 그 표시 경계는 이번 실행에서 관측하지 않았습니다.
이 값이 지속적으로 높다면 동일 부하에서 잠금 구현과 지연 분포를 비교합니다.
예약 상태 전이의 검증 결론
CAS는 경쟁을 제거하지 않고 충돌을 감지해 다시 계산합니다.
버리는 계산량과 CPU 대기를 측정하고, 상태가 크거나 임계 작업이 길면 잠금으로 직렬화하세요.
선택의 근거는 이름이 아니라 부하 특성입니다.