낙관적 락이란?
"충돌은 잘 안 날거야" 라고 낙관적으로 가정하고 실제로 충돌이 발생하면 그때 처리하는 동시성 제어 방식을 말한다.
작동 방식
- 데이터를 읽을 때 버전 번호(@Version)를 함께 읽음
- 데이터를 수정한 뒤, 업데이트 시점에 버전 번호를 비교
- DB에 있는 버전 번호랑 다르면 실패(다른 누군가가 먼저 수정했음을 의미)
충돌 시
ObjectOptimisticLockingFailureException 예외가 발생한다.
재시도하거나 사용자에게 충돌 메시지를 보여주는 등 예외 처리가 필요하다.
장점
성능적으로 유리
DB에 직접 락을 걸지 않기 때문에 비관적 락보다 성능상 이점이 존재한다.
데드락 X
마찬가지로 DB에 직접 락을 걸지 않기 때문에 데드락이 발생하지 않는다.
분산 환경에 적용 가능
JVM 수준이 아닌 DB 수준에서 버전 충돌을 감지하기 때문에 여러 개의 WAS에서도 문제가 발생하지 않는다.
단점
충돌 시 예외 처리 필요
동시에 많은 쓰레드가 접근하면 충돌이 자주 발생하게 된다. 이때 오류에 대한 처리를 해주거나 재시도 로직이 필요하다.
낮은 동시성에만 적합
충돌 빈도가 높아지면 성능적인 이점이 없어진다.
아래는 재시도 로직을 적용한 후 동시에 50명이 재고를 차감하는 테스트 결과이다.
무려 약 18초나 걸린다.
예시 코드
📝 build.gradle.kts
재시도 로직을 위한 spring-retry를 추가해준다.
dependencies {
// Spring Boot
implementation("org.springframework.boot:spring-boot-starter-web")
// JPA
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// MariaDB
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
// Retry
implementation("org.springframework.retry:spring-retry")
}
📝 RetryConfig
Retry를 사용하겠다는 설정이다.
@Configuration
@EnableRetry
class RetryConfig {
}
📝 Product
@Version 어노테이션이 붙은 버전 관리용 필드를 추가해준다.
Int, Long, Short, Timestamp, Date 타입을 사용 가능하다.
@Entity
@Table(name = "optimistic_product")
class Product(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(nullable = false)
val name: String,
@Version
@Column(nullable = false)
val version: Long = 0L,
) {
var stockQuantity: Int = 0
protected set
/**
* 재고 감소
*
* @param quantity 감소할 재고 수량
*/
fun decreaseStockQuantity(quantity: Int) {
// 재고 수량이 부족하거나 음수인 경우 예외 처리
if (stockQuantity < quantity || quantity < 0) {
throw IllegalArgumentException("재고가 없습니다.")
}
this.stockQuantity -= quantity
}
}
📝 ProductRepository
@Lock 어노테이션을 이용해 낙관적 락을 사용하겠다고 명시해준다.
간단한 예시이므로 JPQL을 사용했지만 QueryDSL로도 가능하다.
interface ProductRepository : JpaRepository<Product, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT p FROM Product p WHERE p.id = :id")
fun findByIdWithOptimisticLock(id: Long): Product?
}
📝 ProductService
재고 감소 로직 자체는 JPA가 자동으로 낙관적 락을 지원해주기 때문에 별다른 처리가 필요하지 않다.
다만, 버전 충돌이 발생할 경우를 대비해 @Retryable을 사용하여 자동으로 재시도를 수행하도록 처리하였다.
@Service
class ProductService(
private val productRepository: ProductRepository
) {
@Transactional
@Retryable(
value = [ObjectOptimisticLockingFailureException::class],
maxAttempts = 20,
backoff = Backoff(delay = 1000)
)
fun decreaseStockQuantity(
productId: Long,
quantity: Int
) {
// 상품 조회
val product = productRepository.findByIdWithOptimisticLock(productId)
?: throw IllegalArgumentException("Product not found with id: $productId")
// 재고 감소
product.decreaseStockQuantity(quantity)
}
}
LockModeType
LockModeType에는 여러 가지 옵션이 존재하지만, 이번 글에서는 낙관적 락과 관련된 타입만 살펴보겠다.
🔒 LockModeType.OPTIMISTIC
낙관적 락을 구현할 때 가장 일반적으로 사용하는 타입니다.
업데이트 쿼리가 실제로 실행될 때 버전 필드가 증가하며, 이를 통해 충돌 여부를 판단한다.
⚠️ LockModeType.OPTIMISTIC_FORCE_INCREMENT
보다 적극적인(공격적인) 버전 증가 전략이다.
단순히 엔티티를 조회하는 것만으로도 버전 필드를 무조건 증가시킨다.
이는 다른 트랜잭션이 동일한 엔티티에 대해 작업하지 못하도록 강하게 제약하고자 할 때 사용한다.
결론
낙관적 락은 DB에 직접 락을 거는 방식이 아니라, 버전 필드를 활용하여 동시성을 제어하는 방식이다.
DB에 물리적인 락을 걸지 않기 때문에 락 경합이 적고 성능상 이점이 있을 수 있다.
하지만 충돌이 발생했을 때 예외 처리나 재시도 로직이 필수이기 때문에, 공용 자원을 동시에 여러 사용자가 자주 갱신하는 경우에는 적합하지 않다.
따라서 낙관적 락은 충돌 가능성이 낮고, 데이터의 일관성이 중요한 경우에 적합한 동시성 제어 전략이다.
'Programming > SpringBoot' 카테고리의 다른 글
Spring Boot 동시성 제어 (4) - MySQL Named 락 (0) | 2025.04.09 |
---|---|
Spring Boot 동시성 제어 (3) - 비관적 락 (0) | 2025.04.08 |
Spring Boot 동시성 제어 (1) - synchronized (0) | 2025.04.08 |
[SpringBoot] AMQP란? (0) | 2025.01.26 |
[SpringBoot] JPA 동적 스키마 (1) | 2024.11.06 |