목록으로

CAS 패턴으로 동시성 버그를 잡았다

8

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 → COMPLETEDWHERE status = 'PROCESSING'
PROCESSING → PAUSEDWHERE status = 'PROCESSING'
PROCESSING → FAILEDWHERE status = 'PROCESSING'
PROCESSING → CANCELLEDWHERE status = 'PROCESSING'
USER_PAUSED → PROCESSINGWHERE status = 'USER_PAUSED'
PAUSED → PROCESSINGWHERE 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나 분산 잠금을 적용해야 한다.