요약
- SELECT FOR UPDATE는 조회한 행에 배타적 잠금(Exclusive Lock)을 걸어 다른 트랜잭션의 수정을 막는다
- 동시성 제어가 필요한 상황에서 레이스 컨디션을 방지하는 비관적 잠금(Pessimistic Lock) 기법이다
본문
기본 원리
SELECT FOR UPDATE는 SELECT 쿼리 실행 시 해당 행에 **배타적 잠금(X-Lock)**을 건다. 이 잠금은 트랜잭션이 커밋되거나 롤백될 때까지 유지된다.
-- 트랜잭션 A
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 이 시점에 id=1 행에 잠금 획득
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
잠금의 동작 방식
-
배타적 잠금 (Exclusive Lock)
- 다른 트랜잭션의 SELECT FOR UPDATE 대기
- 다른 트랜잭션의 UPDATE/DELETE 대기
- 일반 SELECT는 허용 (MVCC 환경에서)
-
잠금 범위
- 인덱스를 사용한 조회: 해당 행만 잠금
- 풀 테이블 스캔: 스캔한 모든 행에 잠금 (주의!)
사용 시나리오
-- 재고 차감 예시: 레이스 컨디션 방지
BEGIN;
SELECT quantity FROM products WHERE id = 100 FOR UPDATE;
-- quantity가 충분한지 확인 후
UPDATE products SET quantity = quantity - 1 WHERE id = 100;
COMMIT;
Outbox Poller에서의 필수 사용
메시지 발송 시 SELECT FOR UPDATE는 필수다. 여러 Poller 인스턴스가 동시에 같은 pending 레코드를 읽으면:
- 둘 다 queuing으로 상태 변경 시도
- 둘 다 Queue에 메시지 발송
- 중복 발행 발생
-- Outbox Poller 예시: SKIP LOCKED로 경합 없이 분산 처리
BEGIN;
SELECT * FROM outbox
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- 상태 변경 후 Queue 발송
UPDATE outbox SET status = 'QUEUING' WHERE id IN (...);
COMMIT;
-- Queue.send() 실행
SKIP LOCKED 옵션으로 이미 다른 Poller가 잡은 행은 건너뛰어 경합 없이 병렬 처리 가능.
옵션들
| 옵션 | 설명 |
|---|---|
FOR UPDATE |
배타적 잠금, 대기 |
FOR UPDATE NOWAIT |
잠금 불가 시 즉시 에러 |
FOR UPDATE SKIP LOCKED |
잠금된 행 건너뛰기 (Poller에 적합) |
주의사항
- 데드락 가능성: 여러 행을 순서 없이 잠그면 데드락 발생 가능
- 인덱스 필수: WHERE 절에 인덱스가 없으면 테이블 전체 잠금 위험
- 트랜잭션 길이: 잠금 시간이 길어지면 동시성 저하
참고
이 문서를 참조하는 노트 (8)
- Django ORM 고급 기능
- InnoDB는 Next-Key Lock으로 팬텀 리드를 방지한다
- Outbox Poller는 상태 변경을 먼저 하고 Queue에 넣는다
- SELECT FOR UPDATE는 Read-Modify-Write 패턴에서만 필요하다
- Transactional Outbox 패턴은 DB와 외부 시스템 간 일관성을 보장한다
- UPDATE WHERE 조건부 갱신은 SELECT FOR UPDATE보다 lock 대기 없이 동시성 경합을 해결한다
- transaction.on_commit으로 큐 enqueue를 감싸면 롤백 시 고아 잡을 막는다
- 단식부기는 잔액 변화만 기록해 시스템 전체 자금 흐름을 추적하지 못한다