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로 강제로 동시 요청을 만들어 검증하는 습관이 중요하다고 느꼈다.
물론 이 글에서 정리한 기준이 모든 상황에 맞지는 않을 수 있다. 프로젝트 특성에 따라 다른 선택이 더 나을 수도 있으니 참고 정도로 봐주면 좋겠다.