예약 수를 하나 늘리는 코드에 @Transactional 을 붙였는데도 정원을 넘겨 예약이 잡히는 경우가 있다. 트랜잭션은 분명히 열려 있고 커밋도 정상이다. 문제는 트랜잭션이 막는 것과 동시성이 요구하는 것이 다르다는 데 있다.
트랜잭션은 원자성을 보장한다. 묶인 연산이 전부 반영되거나 전부 취소된다. 하지만 두 트랜잭션이 같은 값을 동시에 읽고 각자 고쳐 쓰는 상황은 원자성만으로 막히지 않는다. 경쟁하는 쓰기를 직렬화하는 건 락(Lock)의 몫이다. 트랜잭션과 락은 대체재가 아니라 서로 다른 문제를 푼다.
Lost Update
정원이 100 인 슬롯에 예약이 들어오는 상황을 보자. 서버는 현재 예약 수를 읽고, 정원 미만이면 1 늘려 저장한다. 읽고-고치고-쓰는 read-modify-write 순서다.
현재 예약 수가 99, 한 자리가 남았다. 요청 두 개가 거의 동시에 들어온다.
sequenceDiagram
participant R1 as 요청 1
participant DB
participant R2 as 요청 2
R1->>DB: SELECT count → 99
R2->>DB: SELECT count → 99
Note over R1,R2: 둘 다 99 < 100 통과
R1->>DB: UPDATE count = 100
R2->>DB: UPDATE count = 100
Note over DB: 두 건 예약됐지만 count = 100 (초과 예약)
두 요청 모두 99 를 읽고, 둘 다 “99 < 100” 검사를 통과해 100 으로 저장했다. 예약 수는 100 으로 기록됐지만 실제로는 두 건이 추가로 잡혔다. 한 자리를 두 명이 가져간 것이다. 나중 저장이 먼저 저장을 덮으면서 갱신 하나가 사라졌다. 이것이 Lost Update 다. 트랜잭션 경계 안에서 일어나도 트랜잭션은 이를 막지 않는다. 각 트랜잭션은 자기 안에서 완결적이기 때문이다.
격리 수준을 올려도 근본 해결은 아니다. Read Committed 나 Repeatable Read 의 일반 읽기는 각 트랜잭션에 자기 시점의 값을 보여줄 뿐, 경쟁하는 두 쓰기를 직렬화하지 않는다. read-modify-write 를 직렬화하려면 락이 필요하다.
비관적 락
가장 직관적인 해결은 읽는 순간부터 그 행을 잠그는 것이다. 비관적 락(Pessimistic Lock)은 충돌이 잦다고 가정하고, 데이터를 읽을 때부터 락을 걸어 다른 트랜잭션의 접근을 대기시킨다.
JPA 에서는 조회 메소드에 락 모드를 지정한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Slot> findById(Long id);
PESSIMISTIC_WRITE 는 배타 락이고, JPA 는 이를 DB 의 SELECT ... FOR UPDATE 로 번역한다.
SELECT * FROM slot WHERE id = ? FOR UPDATE
먼저 락을 잡은 트랜잭션이 커밋할 때까지 두 번째 요청은 조회에서 대기한다. 대기가 풀리면 두 번째 요청은 첫 트랜잭션이 커밋한 최신 값, 즉 갱신된 예약 수를 읽는다. 스냅샷이 아니라 현재 확정된 값을 읽기 때문에, 첫 요청의 결과를 반영한 상태에서 정원을 다시 검사한다. read-modify-write 가 직렬화되면서 Lost Update 가 사라진다.
대가는 대기다. 같은 행을 노리는 트랜잭션이 줄을 서므로 처리량이 떨어진다. 서로 다른 트랜잭션이 여러 행을 반대 순서로 잠그면 데드락(Deadlock)이 생길 수도 있다. 그래서 비관적 락은 실제로 충돌이 잦은 자원, 요청이 몰리는 인기 예약 슬롯 같은 곳에 어울린다.
낙관적 락
충돌이 드물다면 매번 잠그는 건 과하다. 낙관적 락(Optimistic Lock)은 충돌이 거의 없다고 가정하고, 락을 걸지 않은 채 진행하되 저장 시점에 충돌 여부를 검사한다.
JPA 에서는 버전 컬럼을 둔다.
@Version
private Long version;
엔티티를 수정하면 JPA 는 읽은 시점의 버전을 조건에 넣어 UPDATE 한다.
UPDATE slot SET count = ?, version = version + 1 WHERE id = ? AND version = ?
두 트랜잭션이 같은 버전을 읽고 각자 저장하면, 먼저 커밋한 쪽이 버전을 올린다. 두 번째 UPDATE 는 WHERE version = ? 가 더는 일치하지 않아 0 행을 갱신하고, JPA 는 OptimisticLockException 을 던진다. 충돌을 감지한 쪽은 최신 값을 다시 읽어 재시도한다.
DB 락을 걸지 않으므로 경합 비용이 없다. 다만 충돌이 잦으면 재시도가 반복돼 오히려 손해다. 그래서 낙관적 락은 충돌이 드문 워크로드, 대부분의 요청이 서로 다른 자원을 건드리는 경우에 어울린다.
락과 트랜잭션 경계
두 락 모두 트랜잭션 없이는 성립하지 않는다. 락의 수명은 트랜잭션의 수명과 같아서, 획득한 락은 커밋이나 롤백으로 트랜잭션이 끝날 때 풀린다. 그래서 트랜잭션이 열려 있지 않은 상태에서 비관적 락 조회를 실행하면 JPA 는 조용히 넘어가지 않고 TransactionRequiredException 을 던진다. 락을 걸겠다고 선언했는데 그것을 담을 트랜잭션이 없으면 즉시 실패한다.
락이 트랜잭션 위에서만 동작한다는 건, 트랜잭션이 실제로 열려 있어야 한다는 전제로 이어진다. 어노테이션을 붙였다고 트랜잭션이 항상 열리는 것은 아니지만, 그 부분은 별도 주제다.
락이 막아주지 않는 것도 있다. 락은 경쟁하는 접근을 직렬화할 뿐, 그 접근이 옳은지는 검사하지 않는다. “예약 수가 정원 미만이어야 한다” 같은 불변식(Invariant)은 여전히 코드가 직접 검사해야 한다. 특히 위험한 건 파생 상태다. 예약 가능 여부를 매번 예약 수와 정원으로 계산하지 않고 enable 같은 플래그로 따로 저장해두면, 원본 조건과 플래그가 어긋나는 창이 생긴다. 락은 그 불일치를 메워주지 않는다.
채택 기준
비관적 락과 낙관적 락은 우열이 아니라 충돌 빈도에 대한 가정의 차이다.
비관적 락이 적합한 경우:
- 같은 자원에 요청이 몰려 충돌이 잦다 (인기 예약 슬롯, 재고 차감)
- 재시도 비용이 크거나 재시도가 곤란한 작업
- 충돌 시 실패보다 대기가 나은 경우
낙관적 락이 적합한 경우:
- 충돌이 드물어 대부분의 요청이 서로 다른 자원을 건드린다
- 읽기가 많고 쓰기 경합이 낮다
- 대기 없이 처리량을 우선하고, 드문 충돌은 재시도로 흡수할 수 있는 경우
실무에서는 둘을 섞기도 한다. 대부분의 경로는 낙관적 락으로 두고, 충돌이 집중되는 소수 자원에만 비관적 락을 건다.
정리
트랜잭션은 원자성을, 격리 수준은 가시성을, 락은 직렬성을 담당한다. 세 가지가 각각 다른 문제를 풀기 때문에, 트랜잭션을 붙였다고 동시성 문제가 사라지지 않는다. read-modify-write 경쟁이 만드는 Lost Update 는 락으로 직렬화해야 막힌다.
비관적 락은 미리 잠가 충돌을 대기로 바꾸고, 낙관적 락은 진행한 뒤 충돌을 재시도로 흡수한다. 어느 쪽이 나은지는 충돌이 얼마나 잦은지가 결정한다. 그리고 락이 직렬화해준 뒤에도, 그 접근이 지켜야 할 불변식은 코드의 몫으로 남는다.
참고
- RDB Transaction 의 ACID 가 실제로 보장하는 것 — 트랜잭션이 보장하는 원자성의 출발점
- 격리 수준이 실제로 막는 것 — 기본 읽기로는 못 막는 Lost Update 를 락이 메운다
- @Transactional 은 프록시를 거쳐야 동작한다 — 락은 트랜잭션이 실제로 열려 있어야 동작한다