웹 서비스를 만들 때 “재고가 마이너스로 떨어졌네?”, “좋아요 숫자가 이상해!” 같은 경험 해보셨나요?
이건 여러 사람이 동시에 같은 데이터를 수정하려고 해서 발생하는 동시성 문제입니다.
이 문제를 해결하는 방법 중 하나가 바로 낙관적 락(Optimistic Lock)이에요.
동시성 문제가 뭔가요?
쇼핑몰에서 마지막 1개 남은 상품을 두 명이 동시에 주문하는 상황을 예로 들면,
- 철수: 상품 재고 확인 → “1개 있네!”
- 영희: 상품 재고 확인 → “1개 있네!”
- 철수: 재고를 1 빼고 저장 → “0개”
- 영희: 재고를 1 빼고 저장 → “-1개” 😱
이렇게 데이터가 꼬이는 게 바로 동시성 문제입니다.
낙관적 락이란?
낙관적 락은 말 그대로 “동시에 수정될 일이 많진 않겠지”라고 낙관하는 방식입니다.
- 데이터를 읽을 때는 자유롭게 가져옵니다.
- 저장 시점에만 버전 번호를 비교해서 다른 사용자가 먼저 수정했는지 확인합니다.
- 버전이 다르면 → 충돌 발생! → 예외 처리 및 재시도로 대응합니다.
즉, DB에 무거운 락을 직접 걸지 않고 가볍게 버전 필드로 동시성을 제어하는 방법이에요.
버전으로 관리하는 방법
낙관적 락은 버전 번호를 활용합니다:
*// 처음 상품 정보*
상품명: "아이폰 15"
재고: 1개
버전: 1번
*// 철수 주문*
재고: 0개
버전: 2번
*// 영희 주문 시도*
"내가 처음 본 버전은 1이었는데 지금은 2네? 누가 먼저 바꿨구나! 실패!"
1단계: 엔티티에 @Version 추가
import jakarta.persistence.*
@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
fun decreaseStockQuantity(quantity: Int) {
require(quantity >= 0) { "감소 수량은 0 이상이어야 합니다." }
require(stockQuantity >= quantity) { "재고가 부족합니다. 현재 재고: $stockQuantity" }
stockQuantity -= quantity
}
}
- @Entity: DB 테이블과 매핑되는 클래스
- @Id: 기본키
- @Version: JPA가 자동 관리하는 버전 필드. UPDATE 시 where version = ? 조건을 붙여 충돌을 감지합니다.
2단계: 레포지토리에 OPTIMISTIC 락 추가
import org.springframework.data.jpa.repository.*
import org.springframework.data.repository.query.Param
import jakarta.persistence.LockModeType
interface ProductRepository : JpaRepository<Product, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select p from Product p where p.id = :id")
fun findByIdWithOptimisticLock(@Param("id") id: Long): Product?
}
- @Lock(LockModeType.OPTIMISTIC): 조회 시점부터 낙관적 락을 적용.
- 협업 시 “이 메서드는 낙관적 락 기반”임을 코드 차원에서 명확히 보여줍니다.
3단계: 서비스에서 트랜잭션 + 재시도
import org.springframework.orm.ObjectOptimisticLockingFailureException
import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class ProductService(
private val productRepository: ProductRepository
) {
@Transactional
@Retryable(
value = [ObjectOptimisticLockingFailureException::class],
maxAttempts = 5,
backoff = Backoff(delay = 200) // 200ms 간격 재시도
)
fun orderProduct(productId: Long, quantity: Int) {
val product = productRepository.findByIdWithOptimisticLock(productId)
?: error("상품이 없습니다. id=$productId")
product.decreaseStockQuantity(quantity)
}
}
- 충돌 시 ObjectOptimisticLockingFailureException 발생
- @Retryable 덕분에 자동으로 재시도 (최대 5번, 200ms 간격)
- 트랜잭션 범위 내에서 안전하게 재고 감소 처리
좋아요 기능 예시
게시글 좋아요 증가도 동일한 방식으로 처리할 수 있습니다.
@Entity
@Table(name = "post")
class Post(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(nullable = false)
val title: String,
@Version
@Column(nullable = false)
val version: Long = 0L
) {
var likeCount: Int = 0
protected set
fun increaseLike() { likeCount += 1 }
}
interface PostRepository : JpaRepository<Post, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select p from Post p where p.id = :id")
fun findByIdWithOptimisticLock(@Param("id") id: Long): Post?
}
@Service
class PostService(
private val postRepository: PostRepository
) {
@Transactional
@Retryable(
value = [ObjectOptimisticLockingFailureException::class],
maxAttempts = 5,
backoff = Backoff(delay = 100)
)
fun likePost(postId: Long) {
val post = postRepository.findByIdWithOptimisticLock(postId)
?: error("포스트가 없습니다. id=$postId")
post.increaseLike()
}
}
👉 동시에 여러 명이 좋아요를 눌러도 한 명만 성공하고, 나머지는 충돌로 재시도하게 됩니다.
예외 처리: 충돌 시 409 응답
재시도를 다 했는데도 실패하면, 클라이언트에는 409 Conflict 응답을 돌려주는 것이 일반적입니다.
import org.springframework.http.*
import org.springframework.orm.ObjectOptimisticLockingFailureException
import org.springframework.web.bind.annotation.*
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ObjectOptimisticLockingFailureException::class)
fun handleOptimisticLock(): ResponseEntity<String> {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body("동시에 수정이 발생했습니다. 잠시 후 다시 시도해주세요.")
}
}
언제 낙관적 락을 쓰면 좋을까?
쓰면 좋은 경우
- 읽기는 많고 수정이 드문 작업 (예: 게시글 조회)
- 충돌이 드문 작업 (사용자 설정 일부만 수정 등)
- 성능이 중요한 상황 (비관적 락보다 빠름)
피해야 하는 경우
- 충돌이 자주 발생 (인기 상품 재고관리)
- 재시도가 어렵거나 즉시 정확해야 되는 작업 (결제, 포인트, 은행 등)
정리
- @Version 필드로 JPA가 버전 관리
- @Lock(OPTIMISTIC)으로 의도 명시
- Spring Retry로 충돌 시 자동 재시도
- 컨트롤러에서는 실패 시 409 Conflict 응답
낙관적 락을 잘 활용해 빠르고 안전한 서비스를 키워나가세요! 🍀
'Spring > 동시성 제어' 카테고리의 다른 글
| ReentrantLock의 이해와 활용 (0) | 2025.12.03 |
|---|---|
| CAS(Compare-And-Swap) 이해하기 (0) | 2025.11.06 |
| Lettuce를 활용한 Redis 분산 락 구현하기 (0) | 2025.10.18 |
| MySQL Named 락과 분산 락 이해하기 (0) | 2025.10.11 |
| Spring 비관적 락 (Pessimistic Lock) (0) | 2025.09.08 |