CAS 패턴으로 동시성 버그를 잡았다
CAS 패턴으로 동시성 버그를 잡았다
2026-02-19 ~ 2026-02-20
문제 상황
푸시 발송 서비스를 2대의 서버로 운영하고 있다. Active와 Standby인데, 둘 다 Kafka Consumer가 돌고 있어서 메시지를 동시에 처리할 수 있다. → Active-Standby는 서버 이중화 구성 방식이다. Active 서버가 주로 작업을 처리하고, Standby 서버는 대기하다가 Active에 장애가 생기면 대신 작업을 맡는 구조다.
어느 날 Telegram 알림이 2개 왔다.
[서버1] 전체 발송 완료: REQ_20260207_abcd1234
[서버2] 전체 발송 완료: REQ_20260207_abcd1234
같은 요청이 두 번 완료 처리되었다. 발송 자체는 Kafka Consumer Group 덕분에 중복 발송은 없었지만, 완료 상태 전이가 2번 일어나면서 Telegram 알림이 중복으로 갔다. → Consumer Group이란 같은 그룹에 속한 여러 Consumer가 토픽의 메시지를 나눠서 처리하는 Kafka의 기능이다. 같은 메시지가 그룹 내 한 Consumer에게만 전달되므로 중복 처리를 방지한다.
왜 이런 일이 일어나는가
상태 전이 코드가 이랬다.
// 문제 코드
PushRequest request = repository.findByRequestId(requestId);
if (request.getStatus() == PROCESSING) {
request.setStatus(COMPLETED);
request.setEnddate(LocalDateTime.now());
repository.save(request);
sendTelegramAlarm("완료!");
}
두 서버가 거의 동시에 이 코드를 실행하면:
시간 서버1 서버2
─────────────────────────────────────────
T1 조회: status=PROCESSING 조회: status=PROCESSING
T2 if PROCESSING → true if PROCESSING → true
T3 status = COMPLETED status = COMPLETED
T4 save() save()
T5 Telegram 알림! Telegram 알림!
T1에서 둘 다 PROCESSING을 읽었기 때문에, 둘 다 조건문을 통과한다. 이걸 Check-Then-Act 경합(Race Condition)이라고 한다. → Race Condition(경합 조건)이란 두 개 이상의 스레드나 프로세스가 같은 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 버그를 말한다.
CAS(Compare-And-Swap)란?
CAS는 "비교하고 교환하기"라는 뜻이다. 핵심 아이디어: "내가 마지막으로 본 값이 아직 그대로면 바꿔라. 아니면 실패로 처리하라."
데이터베이스에서는 UPDATE의 WHERE 절에 현재 상태를 조건으로 넣어서 구현한다.
UPDATE push_request
SET status = 'COMPLETED', enddate = NOW()
WHERE request_id = 'REQ_20260207_abcd1234'
AND status = 'PROCESSING' -- ← CAS 조건
이 쿼리는 원자적(atomic)으로 실행된다. → 원자적이란 "더 이상 쪼갤 수 없는 하나의 단위"라는 뜻으로, 실행 도중 다른 작업이 끼어들 수 없음을 의미한다. 전부 성공하거나 전부 실패하거나 둘 중 하나다. DB가 행 단위 잠금(row lock)을 걸어서, 두 서버가 동시에 실행해도 하나만 성공한다. → row lock이란 DB가 특정 행을 수정하는 동안 다른 트랜잭션이 같은 행을 수정하지 못하도록 잠그는 것이다.
시간 서버1 서버2
──────────────────────────────────────────────────────
T1 UPDATE ... WHERE status='PROCESSING' (대기 중 - row lock)
T2 → 1 row updated (성공!) (잠금 해제)
T3 Telegram 알림! UPDATE ... WHERE status='PROCESSING'
T4 → 0 row updated (실패!)
T5 (알림 안 보냄)
서버1이 먼저 잠금을 잡으면, status가 COMPLETED로 바뀐다. 서버2가 잠금을 받았을 때는 이미 status가 COMPLETED라서 WHERE 조건에 맞지 않아 0건 업데이트된다.
적용한 코드
// Repository (JPQL)
@Modifying
@Query("""
UPDATE PushRequestEntity a
SET a.status = :status, a.enddate = :enddate
WHERE a.requestId = :requestId
AND a.status = :expectedStatus
""")
int updateStatusWithCas(
String requestId,
PushRequestStatus status,
LocalDateTime enddate,
PushRequestStatus expectedStatus
);
반환값이 int인 게 핵심이다. 업데이트된 행 수를 반환해서, 0이면 다른 누군가가 이미 처리한 것이다.
// Service
int updated = repository.updateStatusWithCas(
requestId, COMPLETED, LocalDateTime.now(), PROCESSING);
if (updated == 0) {
log.warn("CAS 실패 - 이미 다른 인스턴스에서 처리됨: {}", requestId);
return; // 중복 처리 방지
}
// updated > 0일 때만 후속 처리
sendTelegramAlarm("완료!");
모든 상태 전이에 CAS 적용
단순히 완료 처리뿐 아니라, 모든 상태 전이에 CAS를 적용했다.
| 전이 | CAS 조건 |
|---|---|
| PROCESSING → COMPLETED | WHERE status = 'PROCESSING' |
| PROCESSING → PAUSED | WHERE status = 'PROCESSING' |
| PROCESSING → FAILED | WHERE status = 'PROCESSING' |
| PROCESSING → CANCELLED | WHERE status = 'PROCESSING' |
| USER_PAUSED → PROCESSING | WHERE status = 'USER_PAUSED' |
| PAUSED → PROCESSING | WHERE status = 'PAUSED' |
상태 머신의 모든 전이가 원자적이므로, 어떤 인스턴스에서 실행하든 정확히 한 번만 전이된다. → 상태 머신이란 "어떤 상태에서 어떤 상태로 전이할 수 있는지"를 정의한 모델이다. 예를 들어 PROCESSING에서 COMPLETED로는 갈 수 있지만, CANCELLED에서 COMPLETED로는 갈 수 없다는 규칙을 명확히 한다.
낙관적 잠금(Optimistic Lock)과의 차이
JPA에서는 @Version을 사용한 낙관적 잠금이 있다.
→ 낙관적 잠금이란 "충돌이 드물 것"이라고 낙관적으로 가정하고, 실제로 저장할 때 충돌 여부를 확인하는 방식이다. 잠금을 미리 걸어두는 비관적 잠금과 반대되는 개념이다.
@Entity
class PushRequest {
@Version
private Long version;
}
이건 업데이트 시 WHERE version = {이전값}을 자동으로 붙여준다.
동시 수정 시 OptimisticLockException이 발생한다.
CAS와 비슷하지만 차이가 있다.
| CAS (WHERE status = ?) | @Version | |
|---|---|---|
| 조건 | 비즈니스 필드 (status) | 전용 버전 필드 |
| 실패 시 | 0 반환 (조용히 실패) | 예외 발생 |
| 의미 | "이 상태에서만 전이 허용" | "누구든 먼저 수정하면 실패" |
| 적합한 상황 | 상태 머신 | 일반적인 동시 수정 방지 |
우리 경우에는 "PROCESSING에서만 COMPLETED로 갈 수 있다"라는 비즈니스 규칙이 있으므로, 상태 필드를 직접 WHERE에 넣는 CAS가 더 적합했다.
배운 것
- "조회 → 확인 → 수정" 패턴은 동시성에 취약하다. 조회와 수정 사이에 다른 스레드가 끼어들 수 있다.
- UPDATE ... WHERE status = 'expected' 한 문장으로 조회와 수정을 원자적으로 합칠 수 있다.
- CAS의 핵심은 반환값 체크다. 0이 돌아오면 다른 누군가가 이미 처리한 것이므로, 후속 처리를 스킵해야 한다.
- 2대 이상의 서버를 운영한다면, 상태 전이에는 반드시 CAS나 분산 잠금을 적용해야 한다.