데이터베이스를 다룰 때, 운영체제를 배웠을 때 등 공부하다보면 락(lock)이라는 개념을 자주 마주한다. 상황에 따라 낙관적 락과 비관적 락이라는 개념이 등장하는데 얄팍하게 알고있던 이 개념을 한 번 정리해 보려고 한다. 목차는 아래와 같다.
- 락이 필요한 이유
- 낙관적 락과 비관적 락, 각각이 무엇이고 무엇을 써야할까
- Spring에서 락 구현하기
1. 락이 필요한 이유
아래와 같은 상황이 생겼다고 해보자.
- 주문 A가 재고를 읽습니다 → "10개네"
- 주문 B도 재고를 읽습니다 → "10개네"
- 주문 A가 1개 팔고 저장합니다 → "9개로 줄여야지"
- 주문 B도 1개 팔고 저장합니다 → "9개로 줄여야지"
실제로는 2개가 팔렸으나, 결과적으로 재고는 9개가 된다. 실제 서비스에서 이런 오류가 발생한다면 꽤 치명적일 것이다. 이런 일이 발생하는 이유는, 모두 같은 출발점을 읽고, 서로의 변경을 모른 채 각자 계산을 했기 때문일 것이다. 이렇게 나중 저장이 앞 저장을 덮어쓰는 현상을 갱신 손실 (lost update) 라고 한다.
위같은 일은 생각보다 빈번하게 일어난다. 재고 뿐 아니라 계좌 잔액, 좋아요 수, 포인트 차감 같은 데서 똑같이 발생할 수 있다. 이러한 현상을 막기 위해 락이 필요하다. 전략에 따라 두 가지 전략으로 나뉘고, 여기서 나오는 게 낙관적 락과 비관적 락이다.
2. 낙관적 락과 비관적 락 - 정의와 활용
이름이 꽤 직관적이라 추측이 가능하다.
- 비관적(pessimistic): "충돌은 자주 일어날 거야." → 그러니 일단 잠그고 시작
- 낙관적(optimistic): "충돌은 거의 안 일어날 거야." → 그러니 잠그지 말고 진행하다가, 저장할 때만 충돌이 났는지 확인
비관적 락 (Pessimistic Lock)
비관적 락(Pessimistic Lock)은 트랜잭션이 충돌할 가능성이 높다고 '비관적'으로 가정하고, 데이터를 읽거나 수정하기 전에 데이터에 대한 권한(락)을 미리 선점하는 방식이다. 데이터를 읽는 순간 잠가버려 작업이 끝날 때까지 다른 작업은 해당 데이터에 접근할 수 없게 된다.
예를 들면, 책을 빌리는 상황을 생각할 수 있다. 내가 어떤 책을 빌리면, 반납하기 전까지 다른 사람은 그 책을 빌리지 못하는 상황을 생각하면 된다.
SQL 쿼리문으로는 아래와 같이 락을 걸 수 있다.
SELECT * FROM product WHERE id = 1 FOR UPDATE;
비관적 락의 종류
- 공유 락 (Shared Lock / S-Lock): 다른 트랜잭션에서 읽기(SELECT)는 허용하지만, 수정(UPDATE, DELETE)은 불가능하게 막는 락
- 배타 락 (Exclusive Lock / X-Lock): 다른 트랜잭션이 읽지도, 쓰지도 못하게 혼자서 독점하는 락 (주로 수정할 때 사용)
비관적 락의 장단점
장점
- 충돌이 원천 차단되므로 갱신 손실이 발생하지 않아 데이터 무결성이 보장된다.
- 재시도 로직을 구현할 필요가 없다.
단점
- 락이 걸린 동안 다른 요청이 대기해야 하므로 처리량이 떨어져 처리 속도가 느려진다.
- 충돌이 거의 발생하지 않는 상황에서도 잠금 비용을 치러야 한다.
- 데드락이 발생할 수 있다.
낙관적 락 (Optimistic Lock)
낙관적 락(Optimistic Lock)은 트랜잭션 대부분이 서로 충돌하지 않는다고 '낙관적'으로 가정하는 방식이다. 무조건 락을 거는 비관적 락과 달리 저장 시점에 충돌 여부만 체크한다. 데이터베이스가 제공하는 락 메커니즘을 사용하지 않고, 코드 단에서 버전 관리를 통해 동시성을 제어한다는 특징을 갖는다.
데이터에 버전 번호를 하나 붙여두고, 저장할 때 내가 처음 읽었던 버전 번호와 동일한지 확인해 중간에 누가 끼어들었었는지를 확인한다.
UPDATE product
SET stock = 9, version = version + 1
WHERE id = 1 AND version = 3; -- 내가 읽었을 때 버전이 3이었음
- 그새 아무도 안 건드렸다면 버전은 여전히 3 → 조건이 맞아 1개 수정 성공, 버전은 4로 올라감
- 그새 누가 먼저 저장했다면 버전은 이미 4 → 조건(version = 3)에 맞는 행이 없어 0개 수정 → 충돌이 났다고 판단
이처럼 낙관적 락은 충돌을 막는 게 아니라 감지(detect)만 한다는 특징을 갖는다. 충돌을 탐지하면 그 작업은 실패시키기 때문에 재시도 로직이 추가로 필요하다.
낙관적 락의 장단점
장점
- 평소 잠그는 비용이 거의 없어 처리 속도가 빠르다.
- 데드락 위험이 없다.
단점
- 충돌이 빈번하게 일어나는 경우 실패한 트랜잭션을 롤백하고 재시도 해야하기 때문에 그만큼의 서버 자원이 낭비된다.
- 그래서 충돌이 많은 경우 비효율적이다.
비관적 락과 낙관적 락, 무엇을 사용해야할까?
위 내용에서 느낄 수 있었듯, 충돌이 얼마나 자주 발생하는가가 선택의 기준이 된다.
충돌이 드문 경우 - 낙관적 락
예를 들어 사용자가 자기 프로필을 수정하는 경우, 같은 데이터를 동시에 건드릴 일이 거의 없다. 이런 곳에 비관적 락을 쓰면 충돌도 안 날 작업들까지 매번 잠그느라 손해일 수 있어 낙관적 락이 효율적이다.
충돌이 잦은 경우 - 비관적 락
인기 상품의 한정 수량 선착순처럼 같은 데이터에 요청이 몰리는 경우, 낙관적 락을 쓰면 다들 충돌나서 재시도만 반복하게 된다. 차라리 줄을 세워 처리하는게 더 빠르다.
즉, 충돌이 예외적인 상황이면 낙관적 락을, 충돌이 자주 날 수 있다면 비관적 락을 사용하는 게 좋다.
🔗 트랜잭션 격리 수준을 높이면?
락을 보면서 잠깐 들었던 생각은, 트랜잭션 격리 수준(isolation level)을 높이면 안되나? 였다. 여기서 격리 수준은 동시에 도는 트랜잭션이 서로의 중간 상태를 얼마나 보게 할지를 정하는 설정이다. 낮은 쪽부터 네 단계가 있고, 위로 갈수록 더 엄격하게 격리된다.
READ UNCOMMITTED
가장 느슨하다. 다른 트랜잭션이 아직 커밋하지 않은 값까지 그대로 읽히기(dirty read) 때문에, 그 값이 나중에 롤백되면, 존재한 적도 없는 데이터를 본 셈이 된다.
READ COMMITTED
커밋된 값만 읽는다(dirty read 방지). 다만 한 트랜잭션 안에서 같은 행을 두 번 읽을 때, 그사이 누군가 커밋해 버리면 두 결과가 달라질 수 있다.(non-repeatable read).
예를 들어 내가 상품 가격을 1000원으로 읽었는데, 잠시 뒤 같은 트랜잭션에서 다시 읽었더니 (그새 누가 바꿔서) 1200원이 나오는 식이다.
REPEATABLE READ
한 트랜잭션 안에서 같은 행을 몇 번 읽어도 항상 같은 값이 보장된다(non-repeatable read 방지). 단, 예를 들어
재고 100개 이상인 상품처럼 범위로 조회할 때는 없던 행이 새로 끼어드는 현상이 남을 수 있다.(phantom read).
SERIALIZABLE
가장 엄격한 격리 수준으로, 여러 트랜잭션이 한 줄로 줄 서서 차례대로 실행된 것처럼 동작한다(phantom read까지 방지). 대신 그만큼 동시 처리가 느려진다.
SERIALIZABLE을 제외한 나머지 단계는 전부 읽기와 관련한 격리이다. 우리가 다룬 lost update는 읽고 → 애플리케이션에서 계산하고 → 다시 쓰는 흐름에서 생기기 때문에, 읽기 격리를 높인다고 깔끔하게 사라지지 않는다.
예를 들어 MySQL의 기본 격리 수준은 REPEATABLE READ인데, 이 수준에서도 두 트랜잭션이 각자 재고 10을 읽고, 각자 9를 계산해서 쓰면 둘 다 10에서 시작한다는 건 동일하기 때문에 lost update가 그대로 일어날 수 있다.
SERIALIZABLE까지 올리면 막을 수 있긴 하지만, 관계 없는 작업까지 느려지고, 재시도가 늘어나기 때문에 과한 설정일 수 있다. 따라서 락을 사용한다.
3. Spring에서 락 구현하기
JPA를 쓰면 두 방식을 어노테이션 몇 개로 적용이 가능하다.
낙관적 락 : @Version
엔티티에 버전 필드를 추가하면 된다.
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private int stock;
@Version // 이 한 줄이면 낙관적 락
private Long version; // Long, Integer, Timestamp 등 가능 (숫자형 권장)
}
이후 Product를 수정하면 JPA가 알아서 버전을 비교하고 올려준다!
@Version은 언제 올라갈까 — flush 타이밍
JPA에는 영속성 컨텍스트(persistence context) 라는 1차 캐시가 있다. 엔티티를 조회하면 여기 올라오고, 수정하더라도 바로 DB에 반영되지 않는다. 변경사항을 모아두었다가 특정 시점에 한 번에 내보낸다. 이렇게 내보내는 것을 flush라고 부른다.
flush가 일어나는 시점은 보통 셋이다.
- 트랜잭션이 커밋될 때
- JPQL 같은 쿼리가 실행되기 직전
- 직접 flush()를 호출할 때
flush 시점에 JPA는 바뀐 엔티티(dirty entity)를 찾아 UPDATE 쿼리를 만든다.
여기서 알아두어야 할 것은, 버전을 개발자가 올리지 않는다는 것이다. 엔티티가 dirty 상태가 되면 JPA가 flush할 때 알아서 버전을 올려준다. flush 시점에 확인되기 때문에 충돌 감지 또한 flush 시점에 일어난다.
비관적 락 : @Lock
조회할 때 잠그고 읽으라고 지정한다.
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
}
LockModeType에 따라 실제로 나가는 SQL이 달라진다.
- PESSIMISTIC_WRITE : SELECT ... FOR UPDATE (배타 락, 남의 읽기·쓰기 모두 대기)
- PESSIMISTIC_READ : SELECT ... FOR SHARE (공유 락, 남의 읽기는 허용·쓰기는 대기)
- PESSIMISTIC_FORCE_INCREMENT : 배타 락 + 버전 증가
기다리는 시간이 무한정이면 곤란하니 락 타임아웃을 줄 수 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) // 3초
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);