목록으로

데이터베이스에서 CAS 패턴으로 상태 머신 경합 잡기

6

데이터베이스에서 CAS 패턴으로 상태 머신 경합 잡기

→ CAS(Compare-And-Swap)는 "현재 값이 내가 예상한 값과 같을 때만 새 값으로 교체하라"는 동시성 제어 패턴이다. 상태 머신은 정해진 규칙에 따라 상태가 전이되는 구조를 말한다.

2026-02-19 ~ 2026-02-20


발단

대량 발송 시스템에 상태 관리가 있다.

PENDING → PROCESSING → COMPLETED
                    → FAILED
                    → CANCELLED
                    → PAUSED → PROCESSING (재개)

어느 날 이상한 리포트가 올라왔다. 발송이 완료되어 COMPLETED가 됐는데, 직후에 타임아웃 체크가 돌면서 FAILED로 덮어씌운 것이다.


경합(Race Condition)이 뭔데?

경합(Race Condition)은 두 개 이상의 작업이 동시에 같은 데이터를 바꾸려 할 때, 실행 순서에 따라 결과가 달라지는 문제다.

시간   스레드 A (발송 완료)          스레드 B (타임아웃 체크)
─────────────────────────────────────────────────
 T1   상태 조회 → PROCESSING     상태 조회 → PROCESSING
 T2   COMPLETED로 변경 ✅
 T3                              FAILED로 변경 (COMPLETED를 덮어씀!)
 T4   결과: FAILED 💀

기존 코드의 문제

-- 무조건 UPDATE
UPDATE push_request SET status = ? WHERE id = ?

현재 상태가 뭐든 상관없이 덮어쓴다.


CAS(Compare-And-Swap)가 뭔데?

"비교하고, 같으면 바꾸고, 다르면 안 바꾸는" 패턴이다.

-- "현재 PROCESSING인 경우에만 COMPLETED로 바꿔라"
UPDATE push_request
SET status = 'COMPLETED'
WHERE id = ? AND status = 'PROCESSING'

누군가 이미 상태를 바꿨다면 WHERE에 안 걸려서 0 rows affected. 경합에 졌다는 뜻이니 무시하면 된다.

아까 상황을 다시 보면:

 T1   스레드 A: UPDATE ... SET COMPLETED WHERE status=PROCESSING → 1 row ✅
 T2   스레드 B: UPDATE ... SET FAILED WHERE status=PROCESSING → 0 rows (이미 COMPLETED니까!)
 T3   결과: COMPLETED ✅

적용

모든 상태 전이마다 전용 메서드를 만들었다.

int startFromPending(Long id);       // PENDING → PROCESSING
int resumeFromPaused(Long id);       // PAUSED → PROCESSING
int retryFromFailed(Long id);        // FAILED → PROCESSING
int completeFromProcessing(Long id); // PROCESSING → COMPLETED
int failFromProcessing(Long id);     // PROCESSING → FAILED
int pauseFromProcessing(Long id);    // PROCESSING → PAUSED

각 메서드의 WHERE에 출발 상태가 고정되어 있으니 허용되지 않은 전이는 물리적으로 불가능하다.

int updated = repository.completeFromProcessing(id);
if (updated == 0) {
    log.warn("이미 다른 상태로 변경됨, 완료 처리 스킵");
    return;
}

왜 SELECT FOR UPDATE를 안 썼나?

SELECT FOR UPDATE는 조회하면서 해당 행에 락(잠금)을 거는 SQL 구문으로, 다른 트랜잭션이 그 행을 수정하지 못하게 막는다.

방식CAS (낙관적 락)SELECT FOR UPDATE (비관적 락)
락 대기없음있음
데드락 위험없음있음 (두 트랜잭션이 서로의 락을 기다리며 교착 상태에 빠지는 것)
충돌 빈도 높을 때재시도 필요순서 보장

이 시스템은 동시에 같은 request를 건드리는 경우가 드물다. 이런 "충돌이 드문" 상황에서는 CAS가 훨씬 가볍다.


배운 것

  1. DB 상태 전이에는 WHERE 절로 CAS를 쓰자. UPDATE ... SET status = ? WHERE id = ? AND status = ?
  2. affected rows 수가 핵심이다. 0이면 경합에 진 것.
  3. 상태 전이마다 전용 메서드를 만들면 허용되지 않은 전이를 코드 레벨에서 막을 수 있다.
  4. 분산 환경(여러 대의 서버가 동시에 같은 데이터를 다루는 환경)에서도 분산 락 없이 CAS로 충분한 경우가 많다.