카테고리 없음

동시성 문제, 비관적 락 vs 낙관적 락 어떤 기준으로 선택할까

dami97 2025. 11. 21. 17:15
반응형

 

 

 

 

 

TL;DR

재고, 포인트, 쿠폰처럼 충돌 빈도가 높고, 실패 시 이미 수행한 작업을 롤백하고 처음부터 다시 시도해야 하는 경우 비관적 락이 더 적합하다고 판단했다. 낙관적 락은 충돌이 드물고, 실패해도 해당 작업만 다시 시도하면 되는 상황에 더 맞지 않을까 생각한다.

 


문제 상황

커머스 서비스에서 주문 기능을 구현하면서, 동시성 이슈를 마주했다.

@Transactional
fun createOrder(userId: Long, items: List<OrderItemCommand>, couponId: Long? = null): Order {
    val orderProducts = deductStock(items)      // 1. 재고 차감
    val totalAmount = calculateTotalAmount(orderProducts)
    val finalAmount = applyCoupon(couponId, totalAmount)
    pointService.deductPoint(userId, finalAmount) // 2. 포인트 차감

    val order = orderService.createOrder(userId, finalAmount)
    orderService.createOrderItems(order.id, orderProducts)
    paymentService.createPayment(order.id, userId, finalAmount)

    return order
}

동시성 문제가 발생할 수 있는 대표적인 예시다. 10명이 동시에 같은 상품을 주문하면 재고가 음수가 되는 현상이 발생할 수 있다.

 


어떤 상황에서 문제가 발생하는가

MySQL InnoDB의 기본 격리 수준인 REPEATABLE READ에서 두 트랜잭션이 동시에 같은 재고를 읽으면

[Thread A] SELECT quantity FROM stock WHERE product_id = 1  → 100
[Thread B] SELECT quantity FROM stock WHERE product_id = 1  → 100
[Thread A] UPDATE stock SET quantity = 95 WHERE product_id = 1
[Thread B] UPDATE stock SET quantity = 95 WHERE product_id = 1  -- Lost Update!

둘 다 100을 읽고, 둘 다 95로 업데이트한다. Lost Update 문제다.

 


해결 방법) Lock

동시성 문제를 해결하는 대표적인 방법은 두 가지다.

구분 비관적 락 (Pessimistic Lock) 낙관적 락 (Optimistic Lock)
철학 "충돌이 발생할 것이다" "충돌이 거의 없을 것이다"
방식 DB 레벨에서 row lock 애플리케이션 레벨에서 version 체크
충돌 시 대기 후 순차 처리 예외 발생 → 재시도 필요
성능 동시성 높으면 대기 시간 증가 충돌 많으면 재시도 비용 증가

 


비관적 락을 선택한 이유

1. 재고 조회

interface StockJpaRepository : JpaRepository<Stock, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Stock s WHERE s.productId = :productId")
    fun findByProductIdWithLock(productId: Long): Stock?
}
  • 인기 상품은 동시 주문이 몰릴 가능성이 높다고 생각했다. 충돌 빈도가 높을 것으로 예상했다.
  • 재고 차감 후 주문/결제까지 이어지는데, 중간에 실패하면 롤백 비용이 클 것이라 판단했다.
  • 낙관적 락으로 OptimisticLockException 발생 시, 재고 다시 조회 → 차감 → 주문 재시도... 사용자 경험이 나빠질 수 있다고 생각했다.

 

2. 포인트 조회

interface PointJpaRepository : JpaRepository<Point, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Point p WHERE p.userId = :userId")
    fun findByUserIdWithLock(userId: Long): Point?
}
  • 한 유저의 계정으로 여러 기기에서 동시에 주문할 수 있다고 생각했다.
  • 포인트가 음수가 되면 안 되기 때문에 정합성이 중요하다고 판단했다.
  • 포인트 수정 충돌로 실패하면 앞서 차감한 재고도 롤백해야 해서 재시도 비용이 클 것으로 보였다.

 

3. 쿠폰 조회

interface UserCouponJpaRepository : JpaRepository<UserCoupon, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT uc FROM UserCoupon uc WHERE uc.id = :id")
    fun findByIdWithLock(id: Long): UserCoupon?
}
  • 쿠폰은 단 한 번만 사용 가능하다.
  • 10개 기기에서 동시에 같은 쿠폰으로 주문해도, 1건만 성공해야 한다고 생각했다.
  • 낙관적 락으로 재시도하면 이미 used = true라 어차피 실패할 것 같았다. 재시도 의미가 없어 보였다.

 


동시성 테스트

비관적 락이 제대로 동작하는지 CountDownLatch로 검증.

쿠폰 동시 사용 테스트

@DisplayName("동일한 쿠폰으로 여러 기기에서 동시에 주문해도, 쿠폰은 단 한번만 사용되어야 한다")
@Test
fun whenSameCouponUsedConcurrently_thenOnlyOneSucceeds() {
    // arrange
    val threadCount = 10
    val executorService = Executors.newFixedThreadPool(threadCount)
    val latch = CountDownLatch(threadCount)
    val successCount = AtomicInteger(0)
    val failCount = AtomicInteger(0)

    // act
    repeat(threadCount) {
        executorService.submit {
            try {
                couponService.useCouponAndCalculateFinalAmount(userCoupon.id, BigDecimal("10000.00"))
                successCount.incrementAndGet()
            } catch (e: CoreException) {
                failCount.incrementAndGet()
            } finally {
                latch.countDown()
            }
        }
    }

    latch.await()
    executorService.shutdown()

    // assert
    assertThat(successCount.get()).isEqualTo(1)
    assertThat(failCount.get()).isEqualTo(threadCount - 1)
}

10개 스레드 중 1개만 성공, 나머지 9개는 "사용할 수 없는 쿠폰입니다" 예외 발생.

 

재고 동시 차감 테스트

@DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 재고가 정상적으로 차감되어야 한다")
@Test
fun whenMultipleOrdersDeductStockConcurrently_thenStockShouldBeDeductedCorrectly() {
    // arrange
    val threadCount = 10
    val deductQuantity = 5
    // ... 생략

    // assert
    val updatedStock = stockJpaRepository.findById(stock.id).orElseThrow()
    val expectedQuantity = 100 - (deductQuantity * successCount.get())

    assertThat(updatedStock.quantity).isEqualTo(expectedQuantity)
}

초기 재고 100개, 10개 스레드가 각 5개씩 차감 → 최종 재고 50개. Lost Update 없음.

 


그렇다면 낙관적 락은 언제?

낙관적 락이 더 적합한 상황도 있다고 생각한다. 특히 충돌이 드물고, 재시도해도 사용자가 불편함을 느끼지 않는 경우에 더 나은 선택일 수 있다.

 

예시 1) 장바구니 수량 변경

@Entity
class CartItem(
    val userId: Long,
    val productId: Long,
    var quantity: Int,

    @Version
    var version: Long = 0,
)

@Service
class CartService(private val cartItemRepository: CartItemRepository) {

    @Retryable(value = [OptimisticLockException::class], maxAttempts = 3)
    fun updateQuantity(cartItemId: Long, newQuantity: Int) {
        val cartItem = cartItemRepository.findById(cartItemId)
        cartItem.quantity = newQuantity
        cartItemRepository.save(cartItem)  // version 충돌 시 예외 발생
    }
}
  • 같은 유저가 여러 탭에서 동시에 장바구니를 수정하는 경우는 매우 드물 것이라 생각한다.
  • 충돌이 발생해도 재시도 비용이 거의 없을 것 같다. 단순히 수량만 다시 읽어서 업데이트하면 된다.
  • 장바구니 수정은 독립적인 작업이라고 본다. 다른 트랜잭션에 영향을 주지 않아서 재시도해도 롤백할 게 없다.
  • 비관적 락을 걸면 오히려 불필요한 대기 시간만 발생할 수 있다고 생각한다.

 

예시 2) 게시글 조회수 증가

@Entity
class Post(
    val title: String,
    var viewCount: Long = 0,

    @Version
    var version: Long = 0,
)

@Service
class PostService(private val postRepository: PostRepository) {

    @Async
    @Retryable(value = [OptimisticLockException::class], maxAttempts = 3)
    fun incrementViewCount(postId: Long) {
        val post = postRepository.findById(postId)
        post.viewCount++
        postRepository.save(post)
    }
}
  • 조회수는 정확도보다 성능이 더 중요하다고 본다. 1~2 정도 차이는 크게 문제되지 않을 것 같다.
  • 비동기로 처리하면 사용자 응답에 영향을 주지 않을 것이다.
  • 충돌 나면 재시도하고, 그래도 실패하면 그냥 넘어가도 괜찮다고 생각한다.
  • 비관적 락을 걸면 인기 게시글에서 병목 현상이 발생할 수 있다고 생각한다.

 

예시 3) 상품 정보 수정 (관리자)

@Entity
class Product(
    var name: String,
    var price: BigDecimal,

    @Version
    var version: Long = 0,
)
  • 관리자가 상품 정보를 수정하는 상황이라면
  • 동시에 수정하는 경우가 드물 것이라 예상된다
  • 충돌 시 "다른 사람이 수정했습니다. 다시 시도해주세요" 안내가 가능하다
  • 재시도 비용이 낮다고 생각한다 (단순히 다시 폼 제출)

 


핵심

재시도가 "저렴"한가?

내 생각에 낙관적 락의 핵심은 재시도 비용인 것 같다.

상황 재시도 비용 적합한 락
장바구니 수량 변경 낮음 (수량만 다시 읽기) 낙관적 락
조회수 증가 매우 낮음 (실패해도 무방) 낙관적 락
재고 차감 후 주문 높음 (주문 전체 재시도) 비관적 락
포인트 + 재고 + 쿠폰 매우 높음 (전부 롤백) 비관적 락

재시도할 때 롤백할 게 없거나, 사용자가 기다리지 않아도 되면 낙관적 락이 더 효율적이지 않을까 생각한다.

 

낙관적 락 적합 조건

조건 설명
충돌 빈도 낮음 동시 접근이 드문 데이터
재시도 비용 낮음 실패해도 다시 시도하면 그만
읽기 위주 쓰기보다 읽기가 훨씬 많음

 


정리) 선택 기준

충돌이 자주 발생하는가?
├── Yes → 비관적 락
└── No → 실패 시 재시도 비용이 큰가?
         ├── Yes → 비관적 락
         └── No → 낙관적 락
도메인 충돌 빈도 재시도 비용 선택
재고 (Stock) 높음 높음 (주문 전체 재시도) 비관적 락
포인트 (Point) 중간 높음 (재고 롤백 필요) 비관적 락
쿠폰 (UserCoupon) 낮음 재시도 무의미 비관적 락
상품 정보 (Product) 낮음 낮음 낙관적 락

 

 


마무리

처음엔 "락 걸면 성능 떨어지지 않나?"라는 걱정이 있었다. 하지만 정합성이 깨진 데이터를 수습하는 비용이 훨씬 클 수 있다고 생각하게 되었다.

동시성 문제는 테스트 환경에서 재현하기 어려운 것 같다. CountDownLatch로 강제로 동시 요청을 만들어 검증하는 습관이 중요하다고 느꼈다.

물론 이 글에서 정리한 기준이 모든 상황에 맞지는 않을 수 있다. 프로젝트 특성에 따라 다른 선택이 더 나을 수도 있으니 참고 정도로 봐주면 좋겠다.