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_update는 skip_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의 트레이드오프를 감안하여 사용한다.
관련 노트
- SELECT FOR UPDATE는 행 수준 잠금을 획득한다
- SELECT FOR UPDATE는 Read-Modify-Write 패턴에서만 필요하다
- Time-of-Check to Time-of-Use(TOCTOU)는 검사 시점과 사용 시점 사이의 경쟁 조건이다.
- 동시성 카운터 업데이트는 Fanout 패턴으로 row lock 경합을 분산한다
- INSERT ON DUPLICATE KEY UPDATE는 UNIQUE 인덱스 충돌을 에러가 아닌 UPDATE 트리거로 바꾸는 절이다
- Lock의 critical section은 최소한으로 작게 유지해야 한다