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번까지 재시도하도록 설정했는데, 이 횟수가 적절한지 확신이 서지 않는다. 너무 많으면 성능에 영향을 주고, 너무 적으면 불필요한 실패가 발생할 수 있다.
배운 점들
동시성 제어 전략을 선택하면서 몇 가지를 깨달았다.
- 기술적 완벽함보다 비즈니스 요구사항이 우선이라는 것. 모든 곳에 비관적 락을 쓰면 안전하지만, 실제로는 그럴 필요가 없는 경우가 많다.
- 성능과 안전성은 반드시 트레이드오프라는 것. 중요한 건 그 균형점을 비즈니스 맥락에서 찾는 것이다.
- 상황에 따라 다른 전략을 쓸 수 있다는 것. 쿠폰 발급과 사용이 좋은 예였다.
앞으로는 새로운 기능을 구현할 때마다 이 기준들을 적용해 보고, 실제 운영 데이터를 보면서 선택이 올바랐는 지 검증해 나갈 예정이다.
'프로젝트' 카테고리의 다른 글
| 하나로 묶인 트랜잭션을 쪼개다: 이벤트 기반 주문 시스템 리팩토링기 (2) | 2025.09.25 |
|---|---|
| Cache-Aside 적용기: DB 부하 감소와 응답 속도 안정화 (2) | 2025.09.25 |
| 좋아요 수 집계 테이블 도입 고민기: 설계 단계에서의 선택 (0) | 2025.09.24 |
| Value Class 도입기: String에서 타입 안전성까지 (0) | 2025.09.22 |