본문 바로가기

재고 시스템 동시성 이슈 처리하기

2022. 9. 29.

과제를 수행하던 중 동시성 이슈에 대한 처리가 부족하다는 피드백을 받았다.

그동안 말로만 들었고 실무에서도 경험할 일이 없어서 어떻게 문제를 해결하는지 모르고 있었다. 그러다보니 어떤 경우에 동시성 이슈를 처리해야하는지 몰랐고, 과제를 수행할 때 동시성 이슈를 고려하지 못했다.

동시성이슈의 대표적인 예로 재고시스템 로직을 들수가 있는데, 인프런강의를 통해 직접 개발하면서 개념을 습득할 수 있었다.

먼저 DB의 재고 값을 감소 시키는 서비스 로직이다.

@Service
public class StockService {

    ...

    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}
    @Test
    public void 동시에_100개요청() throws InterruptedException {

        Stock stock = new Stock(1L, 100L);
        repository.saveAndFlush(stock); // 재고 100개 저장

        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32); // 
        CountDownLatch latch = new CountDownLatch(threadCount); // 메인쓰레드가 다른 스레드 작업이 끝날때 까지 기다려 주기 위해 사용했다.

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    service.decrease(1L, 1L); 스래드 100개를 이용해서 비동기로 재고를 1개씩 감소시켜준다.
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        Stock stock = repository.findById(1L).orElseThrow();

        assertEquals(0L, stock.getQuantity());
    }

100개의 재고가 존재하고 해당 재고를 100번 감소시키면 기대 결과 값은 0개다.
하지만 결과는 fail

동시성 처리를 안해줬기 떄문에 다음과같은 결과가 나왔다.

문제를 해결하기 위해서는 3가지 방법이 있다.

  1. application에서 Synchronized를 이용해 문제를 해결
  2. MySql에서 lock을 걸어줘서 문제를 해결
  3. Redis에서 lock을 걸어줘서 문제를 해결

Synchronized의 문제점

  • 한 서버에서 여러 스레드의 접근을 제어할 수 있어서 레이스 컨디션을 해결할 수 있지만 서버가 여러개일 경우에는 레이스 컨디션이 발생할 수 있는 문제가 있다.
  • 여러대의 서버를 이용할 경우 DB 락 설정이나, Redis 락을 이용하면 문제를 해결 할 수 있다.

MySql과 Redis의 비교

  • 성능: MySql < Redis
  • 비용: MySql < Redis

Lettuce VS Redisson

  • Lettuce를 이용할 때 락 획득하기 위해 계속 요청을 해준다. 이 spin lock 방식 때문에 성능저하가 발생할 수 있으므로 적절한 딜레이를 통해 개선해줘야 한다. 기본적으로 제공해주는 기능이므로 사용하기 편하다.
  • Redisson을 이용하기 위해서는 별도의 라이브러리를 설치해 이용해야 한다. 라이브러리를 학습해야 사용할 수 있다.
  • 락 획득시 재시도가 필요하면 Redisson을 고려할만하고, 재시도가 필요하지 않다면 Lettuce 사용을 고려할만하다.
# Redisson
    public void decrease(Long key, Long quantity) {
        RLock lock = redissonClient.getLock(key.toString());

        try {
            final boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS); // 락 획득을 기본적으로 제공해준다. 웨이팅 시간을 적절히 설정하지 않는다면 락을 얻지 못할 수도 있다.

            if (!available) {
                System.out.println("lock 획득 실패");
                return;
            }

            stockService.decrease(key, quantity); // 락 획득성공시 재고 감소 수행
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
#Lettuce

    public void decrease(Long key, Long quantity) throws InterruptedException {
        while (!repository.lock(key)) { // 락 획득할 때까지 대기
            Thread.sleep(100); // 요청시간에 텀을 둬서 성능 저하를 예방한다.
        }
        try {
            service.decrease(key, quantity); // 락 획득 성공시 재고 감소
        } finally {
            repository.unlock(key);
        }
    }

    // 락 획득요청과 락 해지 - 기본적으로 제공해주지 않기 때문에 따로 구현해줘야한다.
    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

이제 문제를 해결할 수 있는 기술을 알게 되었고 Redis 이용방법도 알게되었다.
앞으로 동시성 이슈가 발생할수 있는 서비스 로직을 작성할 때 위 방식들의 장단점을 고려하여 이슈가 발생하지 않도록 작성해야겠다.

댓글