
0. 들어가며
JPA를 활용하여 애플리케이션을 개발하다 보면 동시성 문제를 해결하기 위해 낙관적 락(Optimistic Lock)을 적용하는 경우가 있습니다. @Version 애노테이션을 사용하면 간단하게 해결되는 것처럼 보이지만, 흔히 낙관적 락을 "락을 걸지 않는다"고 표현하기 때문에 실제로 MySQL에서 어떻게 동작하는지 궁금해졌습니다.
MySQL에서 UPDATE 문을 실행할 때는 반드시 락을 획득해야 하는데, 그렇다면 JPA의 낙관적 락은 어떻게 "락 없이" 동작하는 것일까요? 이에 대한 궁금증을 해결하기 위해 찾아본 내용을 정리해보았습니다.
결론적으로, JPA의 낙관적 락은 애플리케이션 차원에서 제공하는 충돌 감지 메커니즘이며, MySQL에서는 UPDATE 수행 시 실제로 락을 거는 것이 맞습니다.
1. MySQL의 동시성 제어
1.1 MySQL의 락(Lock)
MySQL은 여러 트랜잭션을 효율적으로 관리하기 위해 다양한 메커니즘을 제공합니다. 대표적인 방식은 다음과 같습니다.
- Row Lock (레코드 락) : 특정 레코드에 대한 읽기/쓰기 작업을 제한하는 방식
- Table Lock (테이블 락) : 테이블 전체에 대해 읽기/쓰기 작업을 제한하는 방식
- MVCC (Multi-Version Concurrency Control) : Undo Log를 활용하여 특정 시점의 데이터를 스냅샷처럼 관리하여 Repeatable Read를 보장하는 방식
이러한 물리적 락은 MySQL 엔진이 트랜잭션 운영 중 자동으로 설정하거나, 명시적으로 설정할 수도 있습니다. 핵심은 MySQL의 락은 DB 차원에서 직접적으로 동작한다는 점입니다.
1.2 MySQL UPDATE시 X-Lock
트랜잭션이 UPDATE 문을 실행하면 해당 레코드에 대해 X-Lock(Exclusive Lock)을 획득하게 됩니다. 이 락은 일반적으로 UPDATE 문이 종료될 때까지 유지되며, 트랜잭션이 종료되면 해제됩니다.
- SELECT 문은 X-Lock이 걸린 레코드를 자유롭게 읽을 수 있습니다.
- 그러나 동일한 레코드에 대한 UPDATE / DELETE / SELECT ... FOR UPDATE 요청이 들어오면, X-Lock이 해제될 때까지 대기해야 합니다.
2. JPA에서의 비관적 락, 낙관적 락 쿼리 비교
그럼 JPA에서 비관적 락과 낙관적 락을 사용했을 때 나가는 쿼리를 비교해보겠습니다.
2.1 엔티티
우선 아래와 같은 엔티티가 있다고 가정해보겠습니다.
@Entity
public class Product {
@Id
@GeneratedValue
private Long id;
@Version
private Long version; // 낙관적 락 버전 필드
private String name;
private int stock;
}
2.2 비관적 락 쿼리
비관적 락 쿼리의 경우 아래 살펴볼 수 있는 것처럼 조회 시점에 SELECT … FOR UPDATE 구문을 사용합니다. 이를 통해서 X-Lock을 잡고 다른 UPDATE 요청이 들어올 수 없게 하죠. 또한 본인도 X-Lock을 획득해야하기 때문에 잠금이 걸려있다면 대기해야합니다. 이를 통해서 동시성 제어를 하는 것입니다.
-- 1) 엔티티 조회 시
SELECT p.id, p.version, p.name, p.stock
FROM product p
WHERE p.id = ?
FOR UPDATE;
-- 2) 트랜잭션 종료(또는 flush) 시 실제 UPDATE
UPDATE product
SET name = ?, stock = ?, version = ?
WHERE id = ?;
2.3 낙관적 락 쿼리
비관적 락과 다르게 낙관적 락 쿼리는 조회 시점에 일반적인 SELECT 문을 사용합니다. 즉, 락을 획득할 필요도 없고, 락을 잡지도 않습니다. 그렇지만 UPDATE 시점의 쿼리를 보면 version 컬럼에 대한 처리가 있는 것을 확인할 수 있습니다.
-- 1) 조회 시에는 보통 SELECT ... (버전 포함) 로우를 읽고, LockModeType 없음
SELECT p.id, p.version, p.name, p.stock
FROM product p
WHERE p.id = ?;
-- 2) flush() 시점에 UPDATE (버전 확인)
UPDATE product
SET name = ?, stock = ?, version = version + 1
WHERE id = ?
AND version = ?;
하지만 위에서 살펴봤던 것처럼 낙관적 락도 UPDATE 문이 있으니 이 시점에 레코드 락을 잡게됩니다.
3. JPA 낙관적 락 (Optimistic Lock)
그러면 JPA의 낙관적 락은 충돌을 어떻게 확인할까요?
위에서 낙관적 락 쿼리를 살펴봤는데, 이를 좀 더 확인해보면 version = ? 라는 조건이 있습니다. 이를 통해 다른 트랜잭션이 이미 UPDATE를 수행했다면 애플리케이션은 MySQL로부터 업데이트를 수행한 row가 0개라는 결과를 받게될 것 입니다. 이 때 JPA가 OptimisticLockException을 발생시키죠. 이 예외가 바로 낙관적 락을 사용했을 때 애플리케이션에서 처리해줘야하는 익숙한 예외입니다.
4. 정리
- 비관적 락은 트랜잭션이 시작될 때 SELECT ... FOR UPDATE를 통해 X-Lock을 획득하여 다른 트랜잭션이 같은 레코드를 수정하지 못하도록 합니다.
- 낙관적 락은 조회 시에는 락을 걸지 않지만, UPDATE 시 락을 잡고, version 필드를 이용하여 충돌을 감지하고 예외를 발생시킵니다.
- MySQL에서는 UPDATE 수행 시 자동으로 X-Lock을 획득하므로, 낙관적 락이 "락 없이 동작한다"는 표현은 다소 오해의 소지가 있습니다. 낙관적 락도 결국 UPDATE 시점에는 물리적인 락을 사용합니다.
'Spring > JPA' 카테고리의 다른 글
[JPA] findByXXX 와 findByXXXId 에서 생기는 차이 (0) | 2023.08.05 |
---|---|
[Spring & JPA] N + 1 문제에 대하여. (1) | 2023.02.01 |