낙관적 잠금(Optimistic Locking)은 충돌이 드물다고 보고 읽을 때 잠그지 않는 동시성 제어 방식이다. 쓸 때 읽어 둔 버전(또는 타임스탬프)과 지금 값을 비교해, 그사이 다른 쪽이 바꿨으면 쓰기를 실패시킨다. 두 사용자가 같은 행을 읽고 차례로 저장해 먼저 저장한 변경이 사라지는 갱신 손실(lost update)을 막는다.
버전 컬럼과 조건부 UPDATE
행마다 version 컬럼을 두고, 비교와 증가를 UPDATE 한 문장에 묶는다.
-- 읽기: id = 42, version = 7을 받아 둔다
SELECT id, title, body, version FROM post WHERE id = 42;
-- 쓰기: 읽어 둔 version이 그대로일 때만 바꾸고 1 올린다
UPDATE post
SET title = $1, body = $2, version = version + 1
WHERE id = 42 AND version = 7;- 영향받은 행 수가 1이면 성공이다. 0이면 그사이 누가 버전을 올린 것이므로 충돌로 판정한다
- 비교와 쓰기가 한 문장이라 둘 사이에 다른 쓰기가 끼어들 틈이 없다. 버전을 SELECT로 따로 확인한 뒤 UPDATE하면 그 틈에서 다시 갱신 손실이 생긴다
- 실패하면 최신 값을 다시 읽어 재시도하거나, 사람이 편집한 화면이면 "다른 사람이 먼저 수정했다"고 알리고 다시 불러오게 한다. 사람이 고친 내용을 자동으로 다시 덮어쓰면 막으려던 갱신 손실이 그대로 생긴다
- 타임스탬프도 쓸 수 있지만 Hibernate 문서는 버전 번호보다 덜 믿을 만하다고 본다
비관적 잠금과 비교
비관적 잠금(Pessimistic Locking)은 충돌이 난다고 보고 읽는 순간 잠근다. PostgreSQL에서는 SELECT ... FOR UPDATE가 가져온 행을 잠가서, 다른 트랜잭션의 UPDATE·DELETE·SELECT FOR UPDATE를 현재 트랜잭션이 끝날 때까지 기다리게 한다. 행 잠금은 일반 SELECT는 막지 않는다.
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- 다른 쓰기는 여기서 대기
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;| 낙관적 잠금 | 비관적 잠금 | |
|---|---|---|
| 잠그는 시점 | 잠그지 않음, 쓸 때 비교 | 읽을 때 행 잠금 |
| 충돌하면 | 늦게 쓴 쪽이 실패, 재시도 | 늦게 온 쪽이 대기 |
| 잘 맞는 곳 | 읽기가 많고 충돌이 드묾 | 같은 행에 쓰기가 몰림 |
| 비용 | 충돌이 잦으면 재시도 낭비 | 대기·교착 상태(deadlock), 트랜잭션이 길수록 동시 처리량 감소 |
- 낙관적 잠금은 DB 트랜잭션 여러 개에 걸친 작업에 특히 쓸모가 있다. 화면을 열어 두고 몇 분 뒤 저장하는 편집 화면처럼, 그동안 행을 잠가 둘 수 없는 경우다
- 인기 상품 재고 차감처럼 같은 행에 쓰기가 몰리면 낙관적 잠금은 실패와 재시도가 꼬리를 문다. 이때는
FOR UPDATE로 미리 잠그거나UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0처럼 읽기와 쓰기를 한 문장에 담는 편이 낫다. PostgreSQL은 Read Committed에서도 늦게 온 UPDATE가 앞 트랜잭션이 커밋한 새 행으로 WHERE 조건을 다시 평가하므로, 재고가 0 밑으로 내려가지 않는다 - 격리 수준과의 관계: PostgreSQL 기본인 Read Committed에서는 읽고 나서 쓰는 사이의 갱신 손실이 막히지 않는다(ACID). Repeatable Read 이상에서는 트랜잭션 시작 뒤 다른 트랜잭션이 바꾼 행을 고치거나 잠그려 하면
could not serialize access오류로 롤백된다. PostgreSQL 문서는 이때 어떤 SQL을 낼지 정하는 로직까지 포함해 트랜잭션 전체를 다시 실행하라고 한다
JPA @Version
Jakarta Persistence는 엔티티의 버전 필드에 @Version을 붙여 낙관적 잠금 실패를 감지한다. 엔티티당 하나까지 두고, 타입은 int·short·long(래퍼 포함)·java.sql.Timestamp·Instant·LocalDateTime 중 하나다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@Version
private long version; // 애플리케이션이 직접 바꾸지 않는다
}- 변경 감지로 UPDATE가 나갈 때 영속성 공급자가 버전을 비교하고 올린다. 위의 조건부 UPDATE와 같은 판정이다
- 충돌하면 Jakarta Persistence 기준
OptimisticLockException이 난다(Hibernate 네이티브 API는StaleObjectStateException). API 호출, flush, 커밋 중 어디서든 날 수 있고 진행 중인 트랜잭션은 롤백 대상으로 표시된다. 그래서 재시도는 같은 트랜잭션 안이 아니라 트랜잭션을 새로 열어 다시 읽는 것부터 한다 - Hibernate 문서는 버전 번호를 애플리케이션이 바꾸는 것을 금지한다
HTTP에서는 ETag와 If-Match
같은 생각을 HTTP로 옮긴 것이 조건부 요청이다. 서버가 응답에 ETag로 버전을 주고, 클라이언트는 저장할 때 If-Match에 그 값을 실어 보낸다. 값이 다르면 서버는 요청을 실행하지 않고 412 Precondition Failed로 응답한다. RFC 9110도 If-Match의 목적을 여러 클라이언트가 같은 리소스를 동시에 바꿀 때의 "lost update" 문제를 막는 것으로 설명한다.
PUT /posts/42
If-Match: "7"
→ 412 Precondition Failed (그사이 버전이 바뀜)서버 안에서는 ETag 비교와 저장을 위의 조건부 UPDATE로 묶어야 비교와 쓰기 사이의 틈이 생기지 않는다.
혼동 주의
프론트엔드의 낙관적 업데이트(Optimistic UI, useOptimistic)와 이름만 비슷하다. 그쪽은 서버 응답 전에 화면을 먼저 바꾸는 표시 기법이고, 이 노트는 동시에 쓰는 두 요청 중 하나를 실패시키는 서버 쪽 동시성 제어다. 실패한 요청의 재전송이 안전한지는 멱등성과 함께 따진다.
명세: RFC 9110 §13.1.1 — If-Match · §15.5.13 — 412 Precondition Failed · Jakarta Persistence 3.2 — @Version
출처: PostgreSQL — Explicit Locking: Row-Level Locks · PostgreSQL — SELECT: The Locking Clause · PostgreSQL — Transaction Isolation · PostgreSQL — Serialization Failure Handling · Hibernate ORM User Guide — Optimistic locking · Jakarta Persistence 3.2 — OptimisticLockException · MDN — 412 Precondition Failed