Redis 분산락으로 재고 감소 동시성 이슈 해결

Jeong_Hyun·2025년 2월 18일
0

문제

Redis 분산락으로 재고 감소 동시성 이슈 해결하기

지난 이커머스 프로젝트에서 주문 도메인을 맡아 진행했었다.
테스트를 통해 동시에 100개의 요청이 들어올 때 오류가 발생하는 것을 확인할 수 있다.
Race condition 문제를 겪어 동시성 이슈가 생기는 것을 알 수 있다.
그래서 이번 포스팅에서 해당 이슈 발생 원인 및 문제 해결 방법을 적어보려 한다.

해결 방법은 하나의 스레드가 작업이 완료된 이후에 다른 스레드가 데이터에 접근 할 수 있도록 만들면 된다.

해결 방법 선택

1. Java에서 지원하는 synchronized 사용

멀티 스레드 환경에서 데이터의 일관성과 동기화를 보장한다.
하지만 하나의 프로세스 안에서만 보장된다. 즉, 서버가 2대이상 일 때는 데이터 접근을 여러 대가 할 수 있게 되서 Race condition 문제가 발생한다.

2. DB에서 Rock 사용

2-1. Pessimistic Lock
실제로 데이터에 Lock 걸고 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없다. 데드락이 걸릴 수 있기 때문에 조심해야 한다. 충돌이 빈번한 경우 성능이 좋다.

2-2. Optimistic Lock
실제 Lock 사용하지 않고 버전을 이용해 맞춘다. update 시 현재 버전이 맞는지 확인하면서 update한다. 충돌 시 예외 처리 로직을 설계해야 한다.

MySQL로 네임드 락을 사용하면 Lock 관리 시 다른 데이터에 부담을 주기에 Redis를 선택했다. 메모리 내에서 데이터를 다루기 때문에 디스크 기반 db보다 빠른 응답 시간을 제공한다.

3. Redis 분산락

보통은 서버를 다중화해서 사용하게 되기 때문에 분산환경 속에서 동시성 이슈를 해결하기 위해 분산락이 필요하다. 공유 자원 자체에 Lock을 설정하는 것과 다르게 임계 영역에 Lock을 설정한다.

3-1. Lettuce 분산락

spring data redis 사용 시 기본 라이브러리로 제공하여 별도의 라이브러리 사용하지 않아도 된다. spin Lock 방식으로 동시에 많은 스레드가 Lock 대기 상태라면 부하를 줄 수 있다.

3-2. Redission 분산락

별도의 라이브러리를 사용해야 하지만 pub-sub 방식으로 락 해제가 되었을 때 한번 Lock 획득 시도하기에 부하를 줄여준다.

해결

Redission 활용 재고 감소 로직 작성

한 스레드가 자신이 점유하고 있던 Lock 해제 시 Channel에 작업이 끝났다고 메시지를 보낸다. Channel은 락을 획득하려는 스레드에게 Lock을 획득하라고 알려준다. Lock을 획득하려는 스레드들은 메시지를 받으면 Lock 획득을 시도한다.

RedisConfig
Redis서버가 떠 있다는 가정하에 위와 같이 RedissonClient를 Bean으로 등록해준다.

@Configuration
public class RedisConfig {

   @Bean
   public RedissonClient redissonClient() {
       Config config = new Config();
       config.useSingleServer().setAddress("redis://127.0.0.1:6380"); // Redis 서버 주소
       return Redisson.create(config);
   }
}

Service
Redisson을 활용하여 Lock을 획득하고 해제한다. Redisson은 동시성 제어를 위한 Lock 관련 클래스를 라이브러리 차원에서 제공하고 있으므로 개발자가 별도로 Repository를 작성할 필요는 없다. 하지만 비즈니스 로직 수행 전후로 Lock을 획득하고 해제하는 로직은 작성해야 한다.

// 재고 감소
   @Transactional
   public void reduceStock(OrderDetailDto orderDetailDto) {
       String lockKey = "lock:product:" + orderDetailDto.getProductDetailId();
       RLock lock = redissonClient.getLock(lockKey);

       try {
           boolean isLocked = lock.tryLock(30, 10, TimeUnit.SECONDS);
           if (!isLocked) {
               throw new GlobalException(ErrorCode.CONCURRENT_STOCK_UPDATE);
           }

           ProductDetail productDetail = productDetailRepository.findById(orderDetailDto.getProductDetailId())
                   .orElseThrow(() -> new GlobalException(ErrorCode.PRODUCT_DETAIL_NOT_FOUND));

           if (productDetail.getQuantity() < orderDetailDto.getQuantity()) {
               throw new GlobalException(ErrorCode.OUT_OF_STOCK);
           }
           productDetail.setQuantity(productDetail.getQuantity() - orderDetailDto.getQuantity());
           productDetailRepository.save(productDetail);

       } catch (InterruptedException e) {
           throw new GlobalException(ErrorCode.LOCK_ACQUIRE_FAILED);
       } finally {
           if(lock.isHeldByCurrentThread()){
               lock.unlock();
           }
       }
   }

Redis로 분산 락을 구현하는 방법으로 Redisson를 사용하는 방식을 구현해 보았다.

정리

Redis에 부하를 줄여주고 Lock 획득 재시도를 라이브러리 차원에서 제공하고 있어 개발자가 별도로 작성해줄 필요가 없지만 이 라이브러리의 사용법을 따로 익혀야한다는 점에서 러닝커브가 있다.

실무에서는 재시도가 필요하지 않다면 Lettuce를, 별도의 재시도 로직이 필요하다면 Redisson을 활용할 수 있다고 한다.

0개의 댓글