두 트랜잭션이 읽고 → 고치고 → 쓰기를 겹쳐서, 한쪽의 변경이 다른 쪽에 덮여 사라지는 현상.
어떻게 사라지나
재고 10개
T1: SELECT stock → 10
T2: SELECT stock → 10
T1: UPDATE stock = 10 - 1 = 9 커밋
T2: UPDATE stock = 10 - 1 = 9 커밋 ← T1 의 차감이 사라졌다
2개를 팔았는데 재고는 9다
격리수준을 올려도 잘 안 막힌다. 두 트랜잭션 모두 정상적으로 커밋된 값을 읽고 정상적으로 썼기 때문이다. 그래서 따로 막아야 한다.
두 가지 전략
-- 비관적 락(pessimistic): 읽는 순간 잠근다
BEGIN;
SELECT stock FROM product WHERE id = 42 FOR UPDATE; -- 행 잠금
UPDATE product SET stock = stock - 1 WHERE id = 42;
COMMIT;
-- 낙관적 락(optimistic): 잠그지 않고, 쓸 때 버전이 그대로인지 확인
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 42 AND version = 7;
-- 영향 행이 0이면 누가 먼저 바꾼 것 → 재시도
| 비관적 락 | 낙관적 락 | |
|---|---|---|
| 언제 | 충돌이 잦을 때 | 충돌이 드물 때 |
| 방식 | 먼저 잠그고 작업 | 쓸 때 버전 확인 |
| 비용 | 락 경합·데드락 | 재시도 로직 필수 |
가장 단순한 해법 — DB가 계산하게 한다
-- 읽지 않고 바로 계산시키면 읽기-쓰기 경합 자체가 없다
UPDATE product SET stock = stock - 1 WHERE id = 42 AND stock > 0;
현재값 기반 누적(재고 차감·조회수 증가)은 이 방식이 가장 안전하고 빠르다. AND stock > 0으로 음수 방지까지 한 번에 된다.
면접 함정
- ❌ "격리수준을 SERIALIZABLE로 올리면 해결" → 되긴 하지만 전체 성능을 크게 희생한다. 문제 지점만 락·버전으로 막는 게 정석이다.
- ❌ "낙관적 락이 더 좋다" → 충돌이 잦으면 재시도가 폭증해 오히려 느려진다. 충돌 빈도로 고른다.
낙관적 락의 재시도까지 포함한 형태
-- 애플리케이션 의사코드
for (int i = 0; i < 3; i++) {
int v = SELECT version FROM product WHERE id = 42;
int n = UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 42 AND version = :v;
if (n == 1) break; // 성공
// n == 0 → 그사이 남이 바꿨다 → 다시 읽고 재시도
}
n == 0을 성공으로 착각하는 것이 가장 흔한 버그다. JPA 같은 ORM은 이 경우 예외를 던져 주지만, 직접 SQL을 쓸 때는 영향 행 수를 반드시 확인해야 한다.
FOR UPDATE의 변형들
-- 기다리지 않고 즉시 실패 — 사용자에게 "다른 사람이 수정 중" 을 바로 알릴 때
SELECT * FROM product WHERE id = 42 FOR UPDATE NOWAIT;
-- 잠긴 행은 건너뛴다 — 작업 큐를 여러 워커가 나눠 가질 때
SELECT * FROM job_queue WHERE status='READY'
ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED;
SKIP LOCKED는 DB를 작업 큐로 쓸 때의 핵심이다. 워커 여러 대가 같은 테이블에서 서로 다른 행을 집어 가므로 경합 없이 병렬 처리된다. PostgreSQL 9.5+, MySQL 8.0+에서 쓸 수 있다.
왜 원자적 UPDATE가 가장 나은가
- 읽고-고쳐-쓰기 — 왕복 2번 + 그 사이에 경합 창이 열린다
- 원자적 UPDATE — 왕복 1번 + 경합 창이 없다 (DB 가 행 락 안에서 계산)