Zettelkasten

UPDATE WHERE 조건부 갱신은 SELECT FOR UPDATE보다 lock 대기 없이 동시성 경합을 해결한다

·수정 2026.05.28·수정 2

요약

  • SELECT FOR UPDATE는 트랜잭션 종료까지 row lock을 유지하여 후속 요청이 대기한다
  • UPDATE ... WHERE 조건 은 affected rows 수로 즉시 판단하여 lock 대기 없이 경합을 해결한다
  • 빈번히 호출되는 API에서는 UPDATE WHERE가 응답 지연 없이 더 적합하다

본문

문제: TOCTOU Race Condition

# 두 동시 요청이 같은 init_time(과거)을 읽고 둘 다 지급 진행
with transaction.atomic():
    init_time = user.voice_status_user.init_time  # SELECT (no lock)
    if init_time is None or timezone.now() >= init_time:
        self.daily_ticket_init(user)  # 둘 다 여기 진입

MySQL READ COMMITTED에서 SELECT는 row lock을 걸지 않는다. 두 트랜잭션이 동시에 같은 값을 읽고, 각각 독립적으로 지급을 수행한다.

해결 1: SELECT FOR UPDATE

with transaction.atomic():
    voice_status = UserVoiceStatus.objects.select_for_update().get(user=user)
    if voice_status.init_time is None or now >= voice_status.init_time:
        ...
  • transaction.atomic() 안에서만 사용 가능
  • 트랜잭션이 commit/rollback될 때까지 row lock 유지
  • 후속 요청은 lock이 풀릴 때까지 blocking 대기

해결 2: UPDATE WHERE (조건부 갱신)

with transaction.atomic():
    updated = UserVoiceStatus.objects.filter(
        user=user
    ).filter(
        Q(init_time__isnull=True) | Q(init_time__lte=now)
    ).update(
        init_time=next_init_time,
        today_call=0,
        today_chat=0,
    )
    if updated:  # affected rows > 0이면 내가 선점
        self.daily_ticket_init(user)
  • 단일 UPDATE 문이 MySQL 레벨에서 원자적으로 실행
  • 첫 번째 요청이 row를 갱신하면, 두 번째 요청의 WHERE init_time <= now 조건이 불일치 → affected rows = 0 → 즉시 스킵
  • lock 대기 없음, transaction.atomic() 없이도 UPDATE 자체가 원자적

비교

관점 SELECT FOR UPDATE UPDATE WHERE
후속 요청 대기 (blocking) 즉시 스킵 (non-blocking)
트랜잭션 필수 필수 (atomic() 내부) UPDATE 자체 원자적
의도 표현 "잠그고 → 읽고 → 판단 → 쓰기" "조건 맞으면 선점, 아니면 패스"
적합 케이스 Read-Modify-Write (읽은 값 기반 계산 필요) 조건 판단 + 상태 전이 (flag 토글)

언제 SELECT FOR UPDATE가 더 나은가

읽은 값을 기반으로 복잡한 계산을 해야 하는 경우 (예: 잔액 기반 차감)에는 SELECT FOR UPDATE가 적합하다. UPDATE WHERE는 "조건이 맞는지"만 판단하므로, 읽은 값으로 계산이 필요한 패턴에는 쓸 수 없다.

데드락 관점에서의 트레이드오프

UPDATE 쿼리도 내부적으로 행 락(exclusive lock)을 잡으므로 데드락이 발생할 수 있다. 두 트랜잭션이 서로 다른 순서로 같은 행을 UPDATE하면 교착 상태에 빠진다.

select_for_updateskip_locked/nowait 옵션으로 데드락을 제어할 수 있다는 장점이 있다. 하지만 SELECT 시점에 락을 잡고 트랜잭션 종료까지 유지하므로, 락 보유 시간이 길어진다.

.update()          →  |--- UPDATE (락 획득+해제) ---|
select_for_update  →  |--- SELECT (락 획득) ---|--- 로직 ---|--- UPDATE (락 해제) ---|
.update() select_for_update + .update()
락 보유 시간 짧음 (UPDATE 실행 중에만) 김 (SELECT ~ UPDATE 사이 전체)
데드락 제어 불가 (무조건 대기) skip_locked: 건너뜀, nowait: 즉시 에러

단순 필터 + 바로 update 패턴이면 .update()가 락 시간 짧아서 효율적이고, 데드락 제어가 반드시 필요한 경우에만 select_for_update의 트레이드오프를 감안하여 사용한다.

관련 노트

참고