@Service
@RequiredArgsConstructor
public class StockService {
private final RedisTemplate<String, String> redisTemplate;
private final RedissonClient redissonClient;
private final ProductApiRepository productApiRepository;
public boolean tryReserve(Long orderId, Long productId, Long quantity) {
String lockKey = "lock:product:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 5초 대기, 10초 유지 (주석과 실제 코드 불일치 발견)
if (!lock.tryLock(5, 10, TimeUnit.SECONDS)) {
log.warn("재고 락 획득 실패. lockKey: {}", lockKey);
return false;
}
// Source of Truth: DB에서 실제 재고 조회
Long dbAvailable = productApiRepository.findById(productId)
.map(Product::getStockQuantity)
.orElse(0L);
// Redis Hash에서 현재 예약된 수량 합계 계산
String reservationKey = "product:reservations:" + productId;
Map<Object, Object> reservations = redisTemplate.opsForHash().entries(reservationKey);
long totalReserved = reservations.values().stream()
.mapToLong(val -> Long.parseLong(val.toString()))
.sum();
// 핵심 로직: 예약 가능 재고 = DB 재고 - 현재 예약량
if ((dbAvailable - totalReserved) >= quantity) {
redisTemplate.opsForHash().put(reservationKey, String.valueOf(orderId), String.valueOf(quantity));
redisTemplate.expire(reservationKey, 24, TimeUnit.HOURS);
return true;
}
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
@Transactional
public void confirmStock(List<OrderProductRequestDto> orderProductRequestDtos) {
// MultiLock으로 여러 상품 원자적 처리 (데드락 방지)
List<RLock> locks = orderProductRequestDtos.stream()
.map(dto -> redissonClient.getLock("lock:product:" + dto.getProductId()))
.toList();
RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
try {
multiLock.lock();
for (OrderProductRequestDto dto : orderProductRequestDtos) {
long productId = dto.getProductId(), quantity = dto.getQuantity();
// JPA 비관적 락으로 DB 레벨 보호
Product product = productApiRepository.findByIdWithPessimisticLock(productId)
.orElseThrow(() -> new IllegalStateException("Product not found: " + productId));
// 최종 재고 검증 후 차감
if (product.getStockQuantity() < quantity) {
throw new IllegalStateException("Not enough stock for product: " + productId);
}
product.decreaseStock(quantity);
}
} finally {
multiLock.unlock();
}
}
public interface ProductApiRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithPessimisticLock(Long id);
}
@Transactional
public void confirmCoupon(Long couponId) {
RLock lock = redissonClient.getLock("lock:coupon:" + couponId);
try {
lock.lock();
Coupon coupon = couponApiRepository.findByIdWithPessimisticLock(couponId)
.orElseThrow(() -> new IllegalStateException("cannot find coupon: " + couponId));
if (coupon.getStatus() != CouponStatus.AVAILABLE) {
throw new IllegalStateException("already used or not available coupon: " + couponId);
}
coupon.use();
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
// 여러 상품 주문 시 데드락 방지
RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
multiLock.lock(); // 모든 락을 원자적으로 획득