기술 블로그/MiriMiri

Redis를 사용한 MSA 환경에서 상품 재고 관리

개발자가 될 사람 2024. 5. 5. 00:33

주문 요청이 들어오면, 상품 서비스에서 요청을 받게 된다. 그러면 상품 서비스는 상품 재고를 검사하고 해당 상품이 현재 구매 가능한 시각인지 검사 그리고 재고를 감소시키는 작업을 수행한다.

 

시간 효율성을 위해 상품 재고를 Redis에 Cache하여 사용하려 한다.

 

트러블 슈팅


현재 상품 재고를 레디스에 캐싱하여 관리한다. 또한 동시성 제어를 위해 Redisson Lock을 사용한다.

주문 요청이 들어오면 상품 서비스에서 로직은 다음과 같다.

  1. 레디스에서 상품 재고 조회
    • 재고가 부족하다면 재고 부족 예외 발생
    • 레디스에 상품 재고 정보가 없다면 데이터베이스에서 조회 후 캐싱
  2. 데이터베이스로부터 상품 조회
  3. 구매 가능한 시간인지 검사
    • 구매가 불가능한 시간이라면 예외 발생
  4. 레디스 상품 재고 감소
  5. 데이터베이스 상품 재고 감소

 

기존 코드

@Override
@Transactional
public void processOrderForGoods(Long userId, Long goodsId, Integer quantity) {
    RLock lock = redissonClient.getLock(GOODS_LOCK_PREFIX + goodsId);

    try {
        if (!lock.tryLock(5, 1, TimeUnit.SECONDS)) {
            log.error("상품 재고 감소 LOCK 획득 실패");
            throw new CustomApiException("수요가 많아 주문 요청에 실패하였습니다.");
        }

        Integer goodsStock = redisStockService.getGoodsStock(goodsId);
        Goods goods = null;
        if (goodsStock == null) {
            goods = findGoodsByIdOrThrow(goodsId);
            goodsStock = goods.getStockQuantity();
            redisStockService.setGoodsStock(goodsId, goodsStock, 10, TimeUnit.MINUTES);
        }

        if (goodsStock < quantity) {
            throw new StockUnavailableException();
        }

        if (goods == null) {
            goods = findGoodsByIdOrThrow(goodsId);
        }

        // 예약구매 시작 시간 검사
        if (LocalDateTime.now().isBefore(goods.getReservationStartTime())) {
            throw new OrderNotAvailableException();
        }

        redisStockService.decreaseGoodsStock(goodsId, quantity);
        goods.decreaseStock(quantity);
        goodsRepository.save(goods);

        // 이벤트 발행 로직
        publishOrderCreatedEvent(userId, goodsId, quantity);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        log.error("스레드 인터럽트 에러, userId={}, goodsId={}", userId, goodsId, e);
        throw new CustomApiException("주문 처리 중 에러가 발생했습니다.");
    } finally {
        lock.unlock();
    }
}
  • 데이터베이스 상품 재고 변경을 위해 processOrderForGoods 메서드에 @Transactional 어노테이션을 추가한다.

 

1차 테스트

그리고 processOrderForGoods 메서드 테스트를 위해 테스트 코드를 다음과 같이 작성하고 실행하였다.

@SpringBootTest
@ActiveProfiles("test")
class GoodsServiceImplTest {

    @Autowired
    private RedissonLockStockFacade redissonLockStockFacade;

    @Autowired
    private GoodsRepository goodsRepository;

    @BeforeEach
    public void insert() {
        Goods goods = Goods.builder()
                .sellerId(1L)
                .goodsName("testGoods")
                .goodsDescription("testDescription")
                .goodsPrice(10000)
                .stockQuantity(200)
                .category(GoodsCategory.FASHION)
                .reservationStartTime(LocalDateTime.now().minusMinutes(10))
                .build();

        goodsRepository.saveAndFlush(goods);
    }

    @AfterEach
    public void delete() {
        goodsRepository.deleteAll();
    }

    @Test
    public void 동시에_100개의요청() throws InterruptedException {

        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    redissonLockStockFacade.processOrderForGoods(1L, 1L, 1);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        sleep(1000);

        Goods findGoods = goodsRepository.findById(1L).orElseThrow();

        // 100 - (100 * 1) = 0
        Assertions.assertThat(findGoods.getStockQuantity()).isEqualTo(100);
    }
}
  • 테스트 상품의 재고가 100개일 때, 100명의 사용자가 주문을 요청하여 재고가 감소되는 상황을 가정한다.
  • 이때, 데이터베이스로부터 상품 재고를 조회하여 기대하는 결과는 0개이다.

 

1차 테스트 결과

실제 결과는 0개가 아닌 6개가 출력된다.

 

그러나, 레디스에 캐시된 상품 재고를 확인하면 기대하는 바와 같이 0개로 출력되는 것을 확인할 수 있다.

 

 

원인 파악

내가 생각한 문제의 원인은 Redisson Lock과 Transaction의 범위가 다르다는 것이다.

코드를 확인해보면 Transaction은 processOrderForGoods 메서드 전체를 범위로 하고 있지만, Lock의 범위는 processOrderForGoods 메서드의 일부이다.

 

이러한 이유로 Redisson Lock은 분산 환경에서의 동시성을 제어하지만, 데이터베이스 트랜잭션과는 별개로 작동하게 된다.

따라서, 락을 획득한 후 데이터베이스 작업을 수행하는 동안 동일한 데이터베이스 트랜잭션 내에서 작업이 이루어져야 한다. 만약 락을 획득한 후 다른 트랜잭션에서 데이터를 변경한다면, 락이 의도한 대로 동작하지 않는다.

 

수정된 코드

문제 해결 방법은 간단하다.

단순히 Redisson Lock 범위 내에서 Transaction이 동작하도록 하면 된다.

 

RedissonLockStockFacade

@Component
@Slf4j
public class RedissonLockStockFacade {

    private static final String GOODS_LOCK_PREFIX = "goods-lock-";

    private final RedissonClient redissonClient;
    private final GoodsService goodsService;

    public RedissonLockStockFacade(RedissonClient redissonClient, GoodsService goodsService) {
        this.redissonClient = redissonClient;
        this.goodsService = goodsService;
    }

    public void processOrderForGoods(Long userId, Long goodsId, Integer quantity) {
        RLock lock = redissonClient.getLock(GOODS_LOCK_PREFIX + goodsId);

        try {
            if (!lock.tryLock(5, 1, TimeUnit.SECONDS)) {
                log.error("상품 재고 감소 LOCK 획득 실패");
                throw new CustomApiException("수요가 많아 주문 요청에 실패하였습니다.");
            }

            goodsService.processOrderForGoods(userId, goodsId, quantity);
        }catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("스레드 인터럽트 에러, userId={}, goodsId={}", userId, goodsId, e);
            throw new CustomApiException("주문 처리 중 에러가 발생했습니다.");
        } finally {
            lock.unlock();
        }
    }
}
  • 락 범위 내에서 트랜잭션이 동작할 수 있도록 별도의 RedissonLockStockFacade 클래스와 메서드를 생성하고 이전의 GoodsService의 processOrderForGoods 메서드를 호출해주면 된다.

 

GoodsService - processOrderForGoods

@Override
@Transactional
public void processOrderForGoods(Long userId, Long goodsId, Integer quantity) {
    Integer goodsStock = redisStockService.getGoodsStock(goodsId);
    Goods goods = null;
    if (goodsStock == null) {
        goods = findGoodsByIdOrThrow(goodsId);
        goodsStock = goods.getStockQuantity();
        redisStockService.setGoodsStock(goodsId, goodsStock, 10, TimeUnit.MINUTES);
    }

    if (goodsStock < quantity) {
        throw new StockUnavailableException();
    }

    if (goods == null) {
        goods = findGoodsByIdOrThrow(goodsId);
    }

    // 예약구매 시작 시간 검사
    if (LocalDateTime.now().isBefore(goods.getReservationStartTime())) {
        throw new OrderNotAvailableException();
    }

    redisStockService.decreaseGoodsStock(goodsId, quantity);
    goods.decreaseStock(quantity);
    goodsRepository.save(goods);

    // 이벤트 발행 로직
    publishOrderCreatedEvent(userId, goodsId, quantity);
}

 

2차 테스트 결과

테스트 코드는 이전과 동일하다.

 

데이터베이스에 저장된 상품 재고와 레디스에 캐시된 상품의 재고가 동일한 것을 확인할 수 있다.

 

데이터베이스와 레디스 간의 데이터 일관성을 맞추는 것이 중요하다!!

 


트랜잭션 범위 줄이기 vs. 데이터베이스 호출 횟수 줄이기