이커머스 서비스를 개발하다 보면 가장 신경 쓰이는 부분은 바로 데이터 정합성이다. 혼자 테스트를 통해서 실행했던 기능들이 수천 명이 동시에 "결제"나 "선착순 쿠폰 발급"을 받으려고 할 때 순식간에 무너질 수 있다.
그래서 공부하면서 진행한 이커머스 프로젝트에서 발생한 동시성 이슈를 해결하기 위해 비관적 락(Perssimistic Lock), 낙관적 락(Optimistic Lock), 그리고 분산 락(Distributed Lock) 을 어떻게 적용했는지, 그리고 분산 락 적용 시 겪었던 트랜잭션 범위 문제를 어떻게 해결했는지 정리하려고 한다.
프로젝트에 적용하기 앞서, 세 가지 락의 특징을 간단히 짚고 넘어가자면
SELECT ... FOR UPDATE)현재 이 프로젝트에서는 각 비즈니스 로직 특성에 맞추어 다른 전략으로 진행했다.
주문과 결제는 포인트 차감, 재고 감소 등 데이터의 정확성이 최우선이다. 트래픽이 쿠폰만큼 순간적으로 폭발할 가능성은 적지만, 동시에 같은 주문 건을 처리하는 것을 막기 위해 비관적 락을 사용했다.
// Repository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Payment p where p.id = :id")
fun findByIdForUpdate(id: Long): Payment?
선착순 이벤트는 DB 락으로만 제어를 한다면 수많은 대기열이 DB 커넥션을 점유하면서 전체 서비스 장애로 이어질 수 있다. 따라서 Redis를 이용한 분산 락을 도입했다.
하지만, 여기서 "분산 락과 DB 트랜잭션의 범위" 문제가 있었다.
처음에는 아래와 같은 구조로 코드를 짰다. (데이터 정합성을 위해서 DB 락까지 이중으로 건 상황)
@Transactional 시작@Transactional 커밋이렇게 분산 락 + DB 비관적 락 을 혼용하게 되면 데드락 위험이 커지면서 성능도 떨어진다. 목표는 "DB 락 없이 분산락만으로 깔끔하게 처리하는 것" 이었다.
하지만 단순히 DB 락을 빼고 분산 락만 사용한다면 동시성 이슈가 다시 발생했다. 이유는 트랜잭션 커밋 시점과 락 해제 시점이 불일치했다.
이 문제를 해결하기 위해서는 "락의 범위가 트랜잭션의 범위보다 커야 한다." 즉, 트랜잭션이 완전히 커밋된 후에 락을 풀어야 한다.
이를 위해서 Facade 패턴을 적용하여 비즈니스 로직(트랜잭션)을 락으로 감싸는 구조로 리팩토링했다.
CouponService (트랜잭션 담당) 순수한 비즈니스 로직만 담당하며, @Transactional을 가진다.
@Service
class CouponService(
private val couponRepository: CouponRepository
) {
@Transactional // 트랜잭션은 여기서만 동작.
fun issueCoupon(couponId: Long, userId: Long) {
val coupon = couponRepository.findById(couponId)
?: throw IllegalArgumentException("쿠폰이 없습니다.")
coupon.decreaseQuantity() // 수량 감소
// 발급 로직
}
}
CoupnFacade (락 제어 담당) 트랜잭션 없이 락의 획득 및 해제만 담당하면서, 실제 로직은 Service에 위임한다.
@Component
class CouponFacade(
private val redissonClient: RedissonClient,
private val couponSerivce: CouponService
) {
fun issueCouponWithLock(userId: Long, couponId: Long) {
val lock = redissonClient.getLock("coupon_lock:$couponId")
try {
// 락 획득 시도
val available = lock.tryLock(10, 1, TimeUnit.SECONDS)
if (!available) {
return
}
couponService.issueCoupon(couponId, userId)
} catch (e: InterruptedException) {
throw RuntimeException(e)
} finally {
if(lock.isLocked && lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
}
이번 프로젝트를 통해서 상황에 맞는 동시성 제어 방식이 무엇인지 깊이 고민할 수 있었다.
단순히 "락을 걸었다"에서 끝나는게 아니라, DB의 격리 수준과 트랜잭션의 생명주기기까지 고려해야 완벽한 동시성 제어가 가능하단걸 알게되었다.