재고 시스템으로 알아보는 동시성 이슈

인상 깊었던 내용

“동시성이 왜 발생했을까?”

📝

레이스 컨디션(Race Condition) 이란 ?

  • 둘 이상의 스레드가 공유 데이터에 액세스할 수 있고 동시에 변경하려고 할 때 발생하는 문제

    • 둘 이상의 스레드 : 요청
    • 공유 데이터 : 재고 데이터
    • 동시에 변경하려고 할 때 : 업데이트 할때
    • 발생하는 문제 : 값이 정상적으로 바뀌지 않는 문제
  • 해결방법

    • 하나의 스레드만 데이터에 액세스 할 수 있도록 한다.

동시성 해결 방법

  1. Java 언어 자체적으로 지원하는 Synchronized 이용하기

자바 자체적으로 지원하는 Synchronized 를 이용해서 문제를 해결하기 위해서는 Transactional 어노테이션을 사용할 수 없다.

문제점

여러 서버가 한 번에 접근한다면 어떻게 될까? syncrhonized 예약어는 각 프로세스 내부에서만 보장이 되기 때문에 다른 프로세스에서 접근하는 행위는 막을 수 없게된다. -> 결국 여러 스레드에서 동시에 접근하게 되어 Race Condition 이 발생한다.

실제 운영 환경에서는 서버를 여러대 두고 사용하는 것이 범용적이기 때문에 synchronized 예약어로 동시성 문제를 해결하진 않는다.

만약 Transactional 을 사용하면 어떻게 될까?

  1. MySQL 자체 Lock 사용하기

낙관적락과 비관적락을 사용하는 기준은 “동시에 수정을 하는일이 빈번하게 일어나는가?” 이다.

이러한 관점에서 봤을 때 충돌가능성이 많다면 비관적락이 더 좋은 선택이 될 수 있다.

🧪

1,000명의 사용자가 동시에 A 라는 상품을 구매할 때 “충돌이 빈번하게 일어난다” 라고 생각할 수 있다.

반대로 1,000명의 사용자가 A 라는 상품을 구매하지만 구매시간이 각자 다를 때 (예를들어 사용자 1은 1시에, 사용자 2는 1시 1분에 사용자 3은 1시 2분에…) “충돌이 비교적 적게 일어난다” 라고 생각할 수 있다.

낙관적락의 구현은 JPA를 사용한다면 크게 어렵지 않게 구현할 수 있기때문에 실무에서도 사용된다.

Redis 활용하기

Lettuce 는 setnx 명령어를 통해 데이터를 저장하는데, set 하는 key 가 존재하지 않을 때만 데이터를 저장한다. 이 원리로 Lock 을 제어한다.

public void decrease(Long id, Long quantity) throws InterruptedException {  
    while (!redisLockRepository.lock(id)) {  
        Thread.sleep(100);  
    }  
    try {  
        stockService.decrease(id, quantity);  
    } finally {  
        redisLockRepository.unlock(id);  
    }}

@Component  
public class RedisLockRepository {  
  
    private final RedisTemplate<String, String> redisTemplate;  
  
    public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {  
        this.redisTemplate = redisTemplate;  
    }  
  
    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));  
    }  
  
    private String generateKey(Long key) {  
        return key.toString();  
    }  
  
  
}

Redisson 은 Pub/Sub 구조로 Lettuce 가 무한으로 락을 획득하려고 재시도하는 단점을 개선할 수 있는 방식이다. 이는, 채널을 하나 두고 스레드 1이 락을 획득한 뒤 값을 저장하고 락을 풀게되면 채널에게 락을 다 썼다는 내용을 보내게 되고, 다른 스레드는 이 채널을 구독하며 기다리고 있다가 락을 다 썼다는 메시지를 본 후에 락 획득을 시도한다.

Redisson 은 비즈니스 로직에 직접적으로 락 제어 코드를 작성해야하는 복잡성이 있지만, 의존성 하나만 설치하면 별도의 작업 없이 다중 서버에서의 동시성을 해결할 수 있게된다.

@Component  
public class RedissonLockStockFacade {  
  
    private RedissonClient redissonClient;  
  
    private StockService stockService;  
  
    public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {  
        this.redissonClient = redissonClient;  
        this.stockService = stockService;  
    }  
  
    public void decrease(Long id, Long quantity) {  
        RLock lock = redissonClient.getLock(id.toString());  
  
        try {  
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);  
  
            if (!available) {  
                System.out.println("Lock 획득 실패");  
                return;  
            }  
  
            stockService.decrease(id, quantity);  
        } catch (InterruptedException e) {  
            throw new RuntimeException(e);  
        } finally {  
            lock.unlock();  
        }    }  
}

Lettuce vs Redisson

분산 락 선택 가이드

Mysql vs Redis

새롭게 배운 내용

자바에서 멀티스레드 환경으로 테스트 하는 방법

Executors 를 활용하여 newFixedThreadPool(n) -> 강의에선 n = 32 로 사용함 이 반환 값은 ExecutorService 가 됨

ExecutorService executorService = Executors.newFixedThreadPool(32);