노트

낙관적 잠금과 비관적 잠금

Optimistic Locking

백엔드#db#jpa · 연결된 개념 6개

쉽게 말하면

낙관적 잠금은 공유 문서를 누구나 고치게 두되, 저장할 때 내가 열었을 때의 버전 번호가 아직 그대로인지 확인하는 방식이에요. 그사이 누가 먼저 저장했으면 내 저장을 거절해서 남의 수정을 덮어쓰지 않게 해요.

비유의 한계 이름과 달리 실제로 잠그는 건 없어요. 충돌을 막는 게 아니라 나중에 알아챌 뿐이라 실패한 쪽은 다시 읽고 재시도해야 해요. 충돌이 잦으면 재시도만 늘어서 FOR UPDATE로 미리 잠그는 편이 나아요.

낙관적 잠금(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

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • 최종 일관성

    최종 일관성(eventual consistency)은 "지금 당장은 저장소마다 값이 다를 수 있지만, 새 변경이 멈추면 결국 같아진다"는 보장이다. 원본 DB와 검색 색인·캐시·다른 서비스처럼 물리적으로 분리된 저장소를 한 트랜잭션으로 묶을 수 없을 때 받아들이는 일관성 모델(consistency model)이다.

  • PostgreSQL 내부 동작(MVCC·VACUUM·WAL·플래너)

    PostgreSQL은 행을 덮어쓰지 않고 새 버전을 쓰며(MVCC), 옛 버전은 VACUUM이 치우고, 커밋은 WAL에 남는 순간 확정되며, 플래너는 통계만 보고 계획을 고른다. 운영 이슈 대부분이 이 넷에서 나온다.

  • 데이터 무결성

    데이터 무결성(data integrity)은 저장된 데이터가 정확하고 일관되며 믿을 수 있는 상태로 유지되는 것이다. 재고가 100개로 보이는데 실제로 50개라면 무결성이 깨진 것이다. 관계형 DB는 이를 제약 조건(constraint)으로 강제한다.

  • 언어적 안티패턴

    이름, 타입, 주석이 말하는 것과 코드가 실제로 하는 일이 어긋나는 것. 읽는 사람을 잘못된 추측으로 이끈다.

  • 비밀번호 저장: 인코딩·암호화·해싱

    비밀번호는 원문으로 저장하지 않고, 느린 단방향 해시(one-way hash)로 바꿔 저장한다. 로그인할 때는 입력값을 같은 방식으로 해시해 저장된 값과 비교한다. 인코딩·암호화·해싱은 비슷해 보이지만 목적이 다르다.

보기 옵션