[Redis] 분산락 갱신 유실 문제

greenlemonT·2025년 2월 11일

Redis

목록 보기
3/3

https://www.youtube.com/watch?v=UOWy6zdsD-c&t=424s
토스증권의 영상을 참고하였습니다

🔹 분산락 타임아웃이 발생하는 이유

  • 분산락을 무제한으로 유지하면 데드락(Deadlock) 현상이 발생할 수 있음.
  • 따라서 분산락에는 타임아웃이 설정되어 일정 시간이 지나면 자동 해제됨.
  • 하지만 트랜잭션이 끝나기도 전에 분산락이 해제되면, 다른 요청이 해당 자원을 점유하면서 갱신 유실(Update Loss) 문제가 발생할 수 있음.
  • jpa에서는 쿼리쓰기 지연으로 인해 자주 발생할수있음

🔹 갱신 유실이 발생하는 시나리오

  • 1: 분산락을 해제하기 전에 트랜잭션이 커밋됨 (정상적인 경우)
  • 2: 분산락을 해제한 후에 트랜잭션이 커밋됨 (경합 발생) ⚠️→ 트랜잭션이 끝나기도 전에 다른 요청이 같은 데이터를 변경할 수 있음.
  • 3: 여러 트랜잭션이 동시에 같은 데이터를 변경하면서 충돌 발생→ 한 트랜잭션의 변경 내용이 다른 트랜잭션에 의해 덮어씌워질 수 있음.

🔹 낙관적 락 vs 비관적 락 비교

구분낙관적 락 (Optimistic Lock)비관적 락 (Pessimistic Lock)
경합 처리 방식트랜잭션 충돌 시 예외 발생 후 재시도트랜잭션이 완료될 때까지 락을 유지
성능충돌이 적을 때 성능 좋음트랜잭션이 길어질수록 성능 저하
예제@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)@Lock(LockModeType.PESSIMISTIC_WRITE)

낙관적 락 사용

@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) 사용

@Repository
public interface StockRepository extends JpaRepository<Stock, UUID> {

    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) // 낙관적 락 사용
    @Query("SELECT s FROM Stock s WHERE s.product.productId = :productId")
    Optional<Stock> findByProduct_ProductIdWithLock(UUID productId);
}
  • JPA의 @Lock(OPTIMISTIC_FORCE_INCREMENT)을 사용하여 충돌 감지
  • 데이터를 조회할 때 버전(version) 필드를 자동 증가시켜 동시성 문제 해결
  • 버전 불일치 발생 시 OptimisticLockException 예외 발생 → 이를 감지하여 자동 재시도 수행

@Retryable을 이용한 재시도 로직

@Service
@RequiredArgsConstructor
public class StockRetryService {

    private final StockRepository stockRepository;

    // 예약 구매: 재고 1 감소 메서드
    @Retryable(
            retryFor = {StaleObjectStateException.class, OptimisticLockException.class, ObjectOptimisticLockingFailureException.class},
            maxAttempts = 500, // 최대 500번 재시도
            backoff = @Backoff(100) // 100ms 대기 후 재시도
    )
    @Transactional
    public void decreaseStockQuantityWithRetry(UUID productId) {
        Stock stock = stockRepository.findByProduct_ProductIdWithLock(productId)
                .orElseThrow(() -> new CustomException(CommerceErrorCode.STOCK_DATA_NOT_FOUND_FOR_PRODUCT));
        stock.decreaseStock();  // 수량 1 감소
        stockRepository.save(stock);
    }
}

@Retryable을 사용하는 이유

  • JPA의 낙관적 락 충돌 예외(OptimisticLockException)가 발생하면 자동으로 재시도
  • 500번까지 최대 재시도하며, 충돌이 해결될 때까지 100ms 간격으로 재시도
  • 트랜잭션 단위로 충돌 감지 후 롤백 후 다시 시도하여 경합이 발생해도 최종적으로 한 요청만 성공하도록 보장

🔹@Version을 사용할 때 JPA의 동작 방식

@Version이 있는 엔티티(Stock)에서 업데이트가 발생하면, JPA는 자동으로 WHERE version = ? 조건을 추가함

@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("SELECT s FROM Stock s WHERE s.product.productId = :productId")
Optional<Stock> findByProduct_ProductIdWithLock(UUID productId);
  • JPA가 자동으로 WHERE version = ?을 추가한 UPDATE 쿼리를 실행한다.
  • @Version 필드가 존재하기 때문에 UPDATE 쿼리를 실행할 때 version을 체크하고, 충돌이 발생하면 OptimisticLockException을 발생시킨다.

📌 낙관적 락(@Version)+ 분산 락(Redisson)

RLock lock = redissonClient.getLock("stock:" + productId);
try {
    if (!lock.tryLock(10, 2, TimeUnit.SECONDS)) {  
        throw new CustomException(CommerceErrorCode.LOCK_ACQUISITION_FAILED);
    }

    // 낙관적 락과 함께 사용 (버전 증가)
    Stock stock = stockRepository.findByProduct_ProductIdWithLock(productId)
            .orElseThrow(() -> new CustomException(CommerceErrorCode.STOCK_DATA_NOT_FOUND_FOR_PRODUCT));

    stock.decreaseStock();  // 재고 감소
    stockRepository.save(stock);  // 업데이트 시 WHERE version = ? 자동 추가

} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}
  • 분산 락(Redisson) 을 먼저 획득하여 멀티 서버에서 하나의 요청만 처리 가능
  • 낙관적 락(@Version) 을 적용하여, 단일 서버 내에서 동시 접근 충돌 방지

→ 즉, 멀티 서버 환경 + JPA 동시성 제어 를 동시에 해결하는 방식!

0개의 댓글