OptimisticLockingFailureException: 낙관적 락은 어떻게 발생하는가?
개요
프로젝트를 진행하면서 OptimisticLockingFailureException이 발생했을 때, 이를 처리하는 방법만 알고 있었다. 하지만 “왜 발생하는가?”, “어떤 흐름으로 발생하는가?”에 대한 궁금증이 생겼고, 이를 해결하기 위해 낙관적 락의 동작 원리를 깊이 있게 파헤쳐보았다.
낙관적 락(Optimistic Lock)이란?
낙관적 락은 “충돌이 자주 발생하지 않을 것”이라고 가정하는 락 메커니즘이다. 데이터베이스 레벨의 락을 걸지 않고, 애플리케이션 레벨에서 버전 정보를 이용해 충돌을 감지한다.
비관적 락 vs 낙관적 락
비관적 락(Pessimistic Lock)
- 데이터를 읽는 시점에 락을 건다
- 다른 트랜잭션의 접근을 막는다
SELECT ... FOR UPDATE사용- 충돌이 빈번한 경우에 적합
낙관적 락(Optimistic Lock)
- 데이터를 읽을 때는 락을 걸지 않는다
- 업데이트 시점에 버전을 확인해 충돌을 감지한다
@Version어노테이션 사용- 충돌이 드문 경우에 적합
OptimisticLockingFailureException은 언제 발생하는가?
낙관적 락은 버전 정보를 활용한다. 엔티티에 @Version 필드가 있으면, JPA는 다음과 같은 방식으로 동작한다
- 엔티티를 조회할 때 현재 버전 정보를 함께 가져온다
- 엔티티를 수정하고 저장할 때, 버전을 1 증가시킨다
- UPDATE 쿼리의 WHERE 절에 버전 조건을 추가한다
- 만약 다른 트랜잭션이 먼저 수정해서 버전이 달라지면, UPDATE가 0개의 행에 영향을 미친다
- 이때
OptimisticLockingFailureException이 발생한다
발생 흐름 예제
엔티티 정의
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
class Product(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
var name: String,
var stock: Int,
@Version
var version: Long? = null // 낙관적 락을 위한 버전 필드
) {
fun decreaseStock(quantity: Int) {
require(this.stock >= quantity) { "재고가 부족합니다." }
this.stock -= quantity
}
}
발생 시나리오
두 명의 사용자(A, B)가 동시에 같은 상품을 구매하는 상황을 가정해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
class OrderService(
private val productRepository: ProductRepository
) {
@Transactional
fun purchase(productId: Long, quantity: Int) {
// 1. 상품 조회 (버전 정보도 함께 가져옴)
val product = productRepository.findById(productId)
.orElseThrow { IllegalArgumentException("상품을 찾을 수 없습니다.") }
// 2. 재고 감소
product.decreaseStock(quantity)
// 3. 저장 (여기서 버전 체크가 이루어짐)
productRepository.save(product)
}
}
발생 흐름 상세
시간순 흐름
| 시간 | 사용자 A | 사용자 B |
|---|---|---|
| T1 | Product 조회 (id=1, stock=10, v=1) | |
| T2 | Product 조회 (id=1, stock=10, v=1) | |
| T3 | stock = 9로 감소 | |
| T4 | stock = 9로 감소 | |
| T5 | UPDATE (WHERE version=1) | |
| -> 성공 (stock=9, version=2) | ||
| T6 | UPDATE (WHERE version=1) | |
| -> 실패 (현재 version=2) | ||
| -> OptimisticLockingFailureException 발생 |
실제 실행되는 SQL
사용자 A의 UPDATE (성공)
1
2
3
4
UPDATE product
SET stock = 9, version = 2
WHERE id = 1 AND version = 1;
-- 1개 행 업데이트 성공
사용자 B의 UPDATE (실패)
1
2
3
4
5
UPDATE product
SET stock = 9, version = 2
WHERE id = 1 AND version = 1;
-- 0개 행 업데이트 (version이 이미 2로 변경되어 있음)
-- OptimisticLockingFailureException 발생
JPA 내부 동작 원리
JPA(Hibernate)는 다음과 같은 방식으로 낙관적 락을 구현한다
1. UPDATE 쿼리 생성
Hibernate는 @Version 필드가 있으면 자동으로 WHERE 절에 버전 조건을 추가한다
1
2
3
4
5
// Hibernate 내부 로직 (의사 코드)
val updateQuery = "UPDATE $tableName" +
" SET $setClause" +
", version = version + 1" +
" WHERE id = ? AND version = ?" // 버전 체크
2. 업데이트 결과 검증
1
2
3
4
5
6
7
8
9
10
11
12
// AbstractEntityPersister (Hibernate)
fun update(...) {
val rows = st.executeUpdate()
// 수정된 row가 0개
if (rows == 0) {
// 버전 불일치로 업데이트 실패
throw StaleStateException(
"Row was updated or deleted by another transaction"
)
}
}
OptimisticLockingFailureException 처리 방법
1. 재시도 처리
Spring Retry @Retryable 어노테이션을 사용하여 재시도 할 수 있도록 처리
2. 사용자에게 알림
1
2
3
4
5
6
7
8
9
10
11
12
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(OptimisticLockingFailureException::class)
fun handleOptimisticLock(e: OptimisticLockingFailureException): ResponseEntity<ErrorResponse> {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(ErrorResponse(
"다른 사용자가 동시에 수정했습니다. 다시 시도해주세요."
))
}
}
비관적 락과의 비교: 언제 무엇을 사용할까?
낙관적 락을 사용해야 하는 경우
- 충돌이 드물게 발생하는 경우
- 읽기 작업이 많고 쓰기 작업이 적은 경우
- 성능이 중요한 경우 (락으로 인한 대기 없음)
- 사용자 인터페이스에서 수정 중 다른 사용자의 변경을 감지해야 하는 경우
비관적 락을 사용해야 하는 경우
- 충돌이 자주 발생하는 경우
- 반드시 선점해야 하는 중요한 자원인 경우
- 재시도 비용이 큰 경우
1
2
3
4
// 비관적 락 예제
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
fun findByIdWithPessimisticLock(@Param("id") id: Long): Optional<Product>
마치며
낙관적 락의 동작 원리를 이해하고 나니, OptimisticLockingFailureException이 왜 발생하는지 명확히 알 수 있었다. 단순히 예외를 처리하는 것을 넘어, 언제 낙관적 락을 사용해야 하고 어떻게 적절히 처리해야 하는지 판단할 수 있게 되었다.
동시성 이슈는 실제 운영 환경에서 자주 마주치는 문제다. 낙관적 락과 비관적 락의 차이를 이해하고, 상황에 맞는 전략을 선택하는 것이 중요하다.