Spring/동시성 제어

Spring 낙관적 락 (Optimistic Lock)

kyoooooong 2025. 9. 8. 23:55

웹 서비스를 만들 때 “재고가 마이너스로 떨어졌네?”, “좋아요 숫자가 이상해!” 같은 경험 해보셨나요?

이건 여러 사람이 동시에 같은 데이터를 수정하려고 해서 발생하는 동시성 문제입니다.

이 문제를 해결하는 방법 중 하나가 바로 낙관적 락(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 응답

낙관적 락을 잘 활용해 빠르고 안전한 서비스를 키워나가세요! 🍀