프로젝트

동시성 제어 전략 선택기: 상황에 맞는 락을 고르는 기준

nahud 2025. 9. 24. 23:30
TL;DR: 주문 시스템 구현 중 동시성 제어가 필요한 상황에서 비관적 락과 낙관적 락을 섞어 사용했다. 쿠폰 발급에는 비관적 락을, 쿠폰 사용에는 낙관적 락을 선택한 이유와 그 과정에서 깨달은 선택 기준을 정리했다.

시작: “동시성? 그냥 synchronized 쓰면 되지 않을까?”

이커머스 주문 시스템을 구현하면서 처음으로 진짜 동시성 문제를 마주했다. 여러 사용자가 동시에 같은 상품을 주문하거나, 같은 쿠폰을 사용하려 할 때 데이터 정합성을 어떻게 보장할지 고민이 시작됐다.

처음엔 단순하게 생각했다. Java의 synchronized 키워드나 Spring의 @Transactional이면 충분하지 않을까? 하지만 곧 깨달았다. 이건 단일 서버, 단일 스레드 내에서만 유효한 방법이라는 걸.

// 이런 식으로는 해결되지 않는다
@Transactional
fun placeOrder(request: PlaceOrderRequest) {
    // 여러 서버에서 동시에 실행되면?
    val product = productRepository.findById(productId)
    product.deductStock(quantity) // 동시성 이슈 발생 지점
}

첫 번째 깨달음: “모든 곳에 비관적 락을 쓰자!”

동시성 문제를 제대로 인식한 후 첫 번째 반응은 확실한 방법을 쓰는 것이었다. 비관적 락(Pessimistic Lock)을 모든 곳에 적용하면 안전하지 않을까?

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
fun findByIdWithLock(@Param("id") id: Long): Product?

비관적 락은 확실했다. 데이터를 읽는 순간 락을 걸어서 다른 트랜잭션이 접근할 수 없게 만든다. 데이터 정합성은 100% 보장된다.

하지만 뭔가 찜찜했다. 비관적 락은 실제 DB 리소스를 점유하는 방식이라 성능에 영향을 줄 수 있다는 걸 알고 있었다. 정말 모든 상황에서 이렇게 강한 락이 필요할까?

두 번째 깨달음: “충돌이 적은 곳까지 비관적 락을?”

그때 쿠폰 사용 기능을 구현하다가 의문이 들었다. 쿠폰 사용은 재고 차감과 성격이 다르다.

  • 재고 차감: 한정된 자원, 충돌 가능성 높음
  • 쿠폰 사용: 사용자별 독립적 행위, 충돌 가능성 낮음

이미 발급된 쿠폰을 사용하는 건 개별 사용자만의 행위다. 같은 쿠폰을 동시에 사용할 가능성은 거의 없다. 오히려 비관적 락으로 인한 성능 저하가 더 큰 문제가 될 수 있겠다는 생각이 들었다.

// 쿠폰 사용에는 낙관적 락을 시도해보자
@Entity
class IssuedCoupon(
    @Id val id: Long,
    var isUsed: Boolean = false,
    @Version var version: Long = 0  // 낙관적 락
)

 

선택 기준이 생기기 시작했다

여러 기능을 구현하면서 패턴이 보이기 시작했다. 비관적 락과 낙관적 락을 선택하는 기준이 있다는 걸 깨달았다.

비관적 락을 선택한 경우들

1. 포인트 차감

@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findByUserIdWithLock(@Param("userId") userId: Long): Point?

이유: 포인트가 음수가 되면 안 됨. 금전과 관련된 민감한 영역

특징: 한정된 자원, 실패하면 안 되는 비즈니스 로직

 

2. 상품 재고 차감

@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findByIdWithLock(@Param("id") id: Long): Product?

이유: 재고를 초과해서 팔 수 없음. 정확성이 최우선

특징: 물리적 제약이 있는 자원

 

3. 쿠폰 발급

@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findByIdWithLock(@Param("id") id: Long): Coupon?

이유: 발급 한도를 초과하면 안 됨

특징: 제한된 수량, 먼저 온 순서대로 처리

낙관적 락을 선택한 경우들

1. 발급된 쿠폰 사용

@Entity
class IssuedCoupon(
    @Version var version: Long = 0  // 낙관적 락
)

이유: 이미 발급된 쿠폰이라 충돌 가능성 낮음

특징: 사용자별 독립적 자원

 

2. 사용자 설정 업데이트

@Entity
class UserProfile(
    @Version var version: Long = 0  // 낙관적 락
)

이유: 사용자별 독립적 데이터, 충돌 가능성 낮음

특징: 개인 데이터, 동시 수정 가능성 낮음

비즈니스 맥락에 따른 선택: 쿠폰의 경우

가장 고민이 많았던 건 쿠폰이었다. 쿠폰 발급과 쿠폰 사용, 둘 다 쿠폰과 관련된 작업인데 다른 락 전략을 써야 할까?

// 쿠폰 발급: 비관적 락
fun issueCoupon(couponId: Long): IssuedCoupon {
    val coupon = couponRepository.findByIdWithLock(couponId) // 비관적 락
    // 발급 가능 수량 체크 후 발급
}
// 쿠폰 사용: 낙관적 락
@Entity
class IssuedCoupon(
    @Version var version: Long = 0  // 낙관적 락
)

결정 이유를 정리해 보니 명확했다:

  • 발급: 전체 사용자가 하나의 쿠폰 풀을 공유 → 충돌 가능성 높음 → 비관적 락
  • 사용: 개별 사용자가 자신만의 쿠폰 사용 → 충돌 가능성 낮음 → 낙관적 락

내가 찾은 선택 기준

여러 고민과 시행착오를 거쳐 나름의 기준을 정리했다:

 

비관적 락을 선택하는 경우

  • 한정된 자원 (재고, 포인트, 쿠폰 발급 한도)
  • 충돌 가능성이 높은 상황 (인기 상품, 한정 이벤트)
  • 데이터 정합성이 비즈니스에 치명적인 경우
  • 실패해도 다시 시도하기 어려운 작업

낙관적 락을 선택하는 경우

  • 사용자별 독립적 자원 (개인 쿠폰, 개인 설정)
  • 충돌 가능성이 낮은 상황
  • 약간의 오차를 허용할 수 있는 집계성 데이터
  • 실패 시 재시도가 쉬운 작업

여전히 고민되는 부분들

선택 기준을 세웠지만 여전히 애매한 경우들이 있다.

1. 경계선상의 케이스들

예를 들어, 사용자 프로필 업데이트는 어떻게 해야 할까? 충돌 가능성은 낮지만 데이터 손실이 발생하면 안 된다.

2. 성능과 안전성의 트레이드오프

비관적 락이 안전하다는 걸 알면서도 성능상 낙관적 락을 선택해야 하는 상황이 올 수 있다. 그 기준은 무엇일까?

3. 낙관적 락 재시도 설정

현재는 낙관적 락 실패 시 5번까지 재시도하도록 설정했는데, 이 횟수가 적절한지 확신이 서지 않는다. 너무 많으면 성능에 영향을 주고, 너무 적으면 불필요한 실패가 발생할 수 있다.

배운 점들

동시성 제어 전략을 선택하면서 몇 가지를 깨달았다.

  1. 기술적 완벽함보다 비즈니스 요구사항이 우선이라는 것. 모든 곳에 비관적 락을 쓰면 안전하지만, 실제로는 그럴 필요가 없는 경우가 많다.
  2. 성능과 안전성은 반드시 트레이드오프라는 것. 중요한 건 그 균형점을 비즈니스 맥락에서 찾는 것이다.
  3. 상황에 따라 다른 전략을 쓸 수 있다는 것. 쿠폰 발급과 사용이 좋은 예였다.

앞으로는 새로운 기능을 구현할 때마다 이 기준들을 적용해 보고, 실제 운영 데이터를 보면서 선택이 올바랐는 지 검증해 나갈 예정이다.