낙관적 락과 비관적 락은 여러 트랜잭션이 같은 데이터를 동시에 고칠 때 충돌을 다루는 두 가지 전략이다. 비관적 락은 "충돌이 날 테니 미리 잠근다", 낙관적 락은 "웬만하면 안 부딪치니 일단 진행하고 커밋 직전에 확인한다"로 요약된다. 어느 쪽이 옳고 그른 게 아니라, 충돌 빈도에 따라 갈린다.
재고 차감, 포인트 사용, 좌석 예약처럼 같은 행을 여러 요청이 동시에 노리는 상황에서 이 선택은 생각보다 중요하다. 잘못 고르면 데이터가 덮어쓰기로 사라지거나(lost update), 반대로 불필요한 대기로 처리량이 무너진다.
비관적 락 — 먼저 잠근다
DB 차원에서 행을 잠그고 시작한다. PostgreSQL·MySQL 모두 SELECT ... FOR UPDATE로 대상 행에 배타 락을 건다. 다른 트랜잭션은 그 행을 수정하려면 락이 풀릴 때까지 기다린다.
BEGIN;
-- 이 행을 잠근다. 다른 트랜잭션은 여기서 대기
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
-- 애플리케이션에서 재고 확인 후
UPDATE products SET stock = stock - 1 WHERE id = 42;
COMMIT;
확실하다. 잠근 동안은 아무도 못 건드리니 로직이 단순해진다. 대신 락을 잡고 있는 동안 다른 요청이 줄을 선다. 트랜잭션이 길면 대기가 길어지고, 잠금 순서가 엇갈리면 데드락이 난다. 나는 결제·재고처럼 정확성이 최우선이고 경합 지점이 좁은 곳에 이걸 쓴다.
대기하지 않고 바로 실패시키고 싶으면 FOR UPDATE NOWAIT, 잠깐 시도만 하려면 SKIP LOCKED가 유용하다. 특히 SKIP LOCKED는 큐 워커가 서로 다른 작업을 집어가게 만드는 고전적인 기법이다.
낙관적 락 — 나중에 확인한다
락을 걸지 않는다. 대신 버전 컬럼을 두고, 내가 읽은 버전이 커밋 시점에도 그대로인지 확인한다. 그사이 누가 바꿨으면 버전이 달라져 있고, 그러면 내 UPDATE는 0행에 적용되어 실패한다.
-- version = 7을 읽었다고 하자
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 42 AND version = 7;
-- 영향받은 행이 0이면 = 그사이 누가 먼저 고쳤다
-- → 애플리케이션에서 재조회 후 재시도
DB 락이 없으니 대기가 없고 처리량이 높다. 문제는 충돌이 나면 재시도 로직을 내가 짜야 한다는 것이다. 충돌이 드문 곳에선 이 비용이 거의 0에 수렴하지만, 경합이 심한 핫 로우에선 재시도가 폭주해 오히려 느려진다. JPA의 @Version, Django의 조건부 update가 이 방식을 지원한다.
어느 걸 고를까
| 기준 | 비관적 락 | 낙관적 락 |
|---|---|---|
| 충돌 빈도 | 높을 때 유리 | 낮을 때 유리 |
| 대기 | 있음(블로킹) | 없음 |
| 재시도 코드 | 불필요 | 필요 |
| 데드락 위험 | 있음 | 거의 없음 |
| 대표 상황 | 재고·결제 | 문서·프로필 편집 |
내가 실제로 쓰는 기준
처음엔 무조건 비관적 락이 안전하다고 믿었다. 그런데 조회가 폭주하는 인기 상품 하나에 FOR UPDATE가 몰리자 그 행이 병목이 되어 전체가 느려지는 걸 겪었다. 그 뒤로는 이렇게 나눈다.
- 충돌이 드문 편집성 작업(내 프로필, 내 문서) → 낙관적 락. 사용자끼리 같은 행을 동시에 고칠 일이 별로 없다.
- 충돌이 구조적으로 잦은 자원 차감(한정 수량, 잔액) → 비관적 락, 또는 아예 원자적
UPDATE ... SET stock = stock - 1 WHERE stock > 0로 DB에 판단을 맡긴다. - 어느 쪽이든 트랜잭션은 짧게. 락을 잡은 채 외부 API를 호출하는 건 절대 금물이다. 몇 번 데인 뒤로 이 원칙만은 지킨다.
결국 락 전략은 "정확성 대 처리량"의 저울질이다. 데이터 특성과 실제 경합을 보고 정하되, 둘을 섞어 쓰는 것도 얼마든지 가능하다.
자주 묻는 질문
낙관적 락의 version 컬럼은 어떤 타입이 좋나요?
단조 증가하는 정수(bigint)나 타임스탬프가 흔합니다. 정수 증가가 가장 단순하고 오해의 여지가 없습니다. 갱신마다 1씩 올리고, WHERE에 이전 버전을 조건으로 넣기만 하면 됩니다.
SELECT FOR UPDATE는 읽기도 막나요?
기본적으로 일반 SELECT는 막지 않습니다. MVCC 덕분에 스냅샷 읽기는 그대로 됩니다. 막히는 것은 같은 행에 대한 다른 FOR UPDATE나 UPDATE/DELETE입니다.
낙관적 락에서 재시도는 몇 번까지 해야 하나요?
보통 3~5회 정도 지수 백오프로 재시도하고, 그래도 실패하면 사용자에게 충돌을 알립니다. 재시도가 자주 실패한다면 그 지점은 낙관적 락에 맞지 않는다는 신호이니 비관적 락이나 원자적 업데이트로 바꾸는 걸 검토하세요.
분산 환경에서도 이 방식이 통하나요?
단일 DB 트랜잭션 안에서는 그대로 통합니다. 여러 서비스·DB에 걸친 분산 트랜잭션이라면 사가(Saga) 패턴이나 분산 락(예: Redis 기반)까지 함께 고려해야 합니다.

댓글 0