동시성 이슈 문제 - MySql 사용

yookyungmin·2023년 8월 11일
0

프로젝트를 진행하면 동시에 이체가 이루어지거나 출금이 이루어지거나 이체가 이루어질때 발생하게 되면 어떻게 해결해야 할까에 대한 의문이 생겼다.

해결 방법을 찾던 중 좋은 강의를 찾았는데 재고시스템을 통해서 동시성 이슈를 해결하는 방법을 통해 우선 학습을 해보았다.

재고 감소 서비스 로직

@Transactional //synchronize을 쓸떈 주석처리
    public void decrease(Long id, Long quantity){
        //stock 조회
        //재고를 감소한뒤
        //갱신한값을 저장
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }

문제 상황

   @Test
    public void 동시에_100개_요청() throws InterruptedException {
        int threadCount = 100; //동시에 여러 요청을 보내야 하기 때문에 멀티 스레드 사용
        ExecutorService executorService = Executors.newFixedThreadPool(32); //
        //멀티스레드 사용 , 비동기로 실행하는 작업을 단순하게 사용할수 있는 api
        CountDownLatch latch = new CountDownLatch(threadCount);//100개 요청 끝날떄까지 기다려야 하므로
        ////다른 스레드에서 수행되는 작업이 완료될때까지 대기할수있도록 하는 클래스
        for(int i = 0; i<threadCount; i++){
            executorService.submit(() ->{
                try{
                    stockService.decrease(1L, 1L);
                }finally {
                    latch.countDown(); //쓰레드 끝날떄마다 카운트를 감소
                }
            }); //100개의 요청
        }
        latch.await(); //카운트가 0이 되면 대기가 풀리고 이후 쓰레드가 실행
        Stock stock = stockRepository.findById(1L).orElseThrow();
        //100 - (1*100) = 0 1개씩 100번 감소기떄문에 0개 이상 예상이 될것
 Assertions.assertThat(stock.getQuqntity()).isEqualTo(0L);
 }


0개의 갯수만 남을것을 예상 했지만 전혀 다른 결과가 나왔다.
이것은 레이스 컨디션이란 문제이다

레이스 컨디션

두 개 이상의 스레드가 공유 데이터에 엑세스 할수 있고, 동시에 변경을 하려 할때 발생하는 문제라고 한다.

이런 문제의 해결 방법은 하나의 스레드가 작업이 완료한 이후에 다른 스레드의 접근이 가능하도록 해야 한다.

첫번째 문제 해결 방법

  • synchronized

@Transactional
public synchronized void decrease(Long id, Long quantity){
        //stock 조회
        //재고를 감소한뒤
        //갱신한값을 저장
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }

decrease 메서드 선언부 앞에 synchronized를 붙여 한개의 쓰레드만 접근이 가능하게 하였다.

어라? 0개를 예상 했지만 50개가 나와 버렸다
why? 이유는 바로
@Transactional의 동작 방식 때문에 우리가 만든 클래스를 새로 만들어서 실행하기 때문이란다.
메서드가 실행되고
트랜잭션 종료 시점에 DB가 업데이트 되는 과정에서 다른 쓰레드가 메서드를 또 호출 할수 있는데 업데이트 갱신전에 정보를 쓰레드가 가져가서 메서드를 실행하기 때문에 이전과 같은 오류가 나타날수 있다고 한다.
이 문제는 @Transactional 어노테이션 앞에 주석을 처리 하는 방법으로 해결했다.

synchronized 사용의 문제점

1.각 하나의 프로세스 안에서만 보장이 됨. 서버가 한대일땐 db접근을 서버 한대만 해서 괜찮지만
서버 두개이상부터 문제가 있을수 있다.
여러 쓰레드에서 동시에 접근이 가능하게 되면서 레이스 컨디션 발생
결국엔 synchronize도 완벽한 해결방법이 못된다

Mysql을 이용한 다양한 방법

Pessimistic Lock 비관적 락

  • 실제로 데이터에 lock을 걸어서 정합성을 맞추는 방법이다. exclusive Lock을 걸게 되면 다른 트랜잭션에서는 이 해제 되기전에 데이터를 가져갈 수 없게 됩니다. 데드락이 걸릴수 있기 떄문에 주의하여 사용해야 합니다.
    데드락 : 둘이상의 프로세스 또는 스레드들이 아무것도 진행하지 않는 상태로 서로 영원히 대기하는 상황
@Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(Long id);


이 부분이 데이터에 Lock을 걸고 가져오는 부분이다.
장점 : 충돌이 빈번하다면 Optimistic Lock보다 성능이 좋을 수 있다.
Lock을 통해 update를 제어하기 때문에 데이터 정합성이 보장된다
단점 : 별도의 Lock을 잡기 때문에 성능 감소가 있을 수 있다.

Optimisti Lock 낙관적 락

  • 실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법입니다. 먼저 데이터를 읽은 후에 update를 수행할 떄 현재 내가 읽은 버전이 맞는지 확이하여 업데이트 됩니다. 내가 읽은 버전에 수정 사항이 생겼을 경우 Application에서 다시 읽은 후에 작업을 수행해야 합니다.
//OptimisticLock 실패했을때 재시도를 해야 하므로
@Component
public class OptimisticLockStockFacade {
    private final OptimisticLockStockService optimisticLockStockService;
    public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
        this.optimisticLockStockService = optimisticLockStockService;
    }
    public void decrease(Long id, Long quantity) throws InterruptedException {
        while(true){
            try{
                optimisticLockStockService.decrease(id, quantity);
                break; //정상 적으로 업데이트 되면 while문 빠져나가기
            }catch (Exception e){
                Thread.sleep(50); //수량 감소에 실패하게 된다면 0.05초 있다 재시도
            }
        }
    }
}

장점 : 별도의 Lock을 잡지 않아서 Pessimistic Lock보다 성능 이점이 있다.
단점 : 업데이트에 실패 했을 때 재시도 로직을 개발자가 직접 작성을 해야함
충돌이 빈번하게 일어나거 빈번하게 일어날것이라 예상 된다면 Pessimistic Lock 을 사용하기 추천

Named Lock

  • 이름을 가진 metadata locking 입니다. 이름을 가진 lock을 획득한 후 해제할때까지 다른 세션은 lock을 획득할 수 없도록 합니다. 주의할 점으로는 transaction이 죵료될 때 lock이 자동으로 해제 되지 않습니다. 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제됩니다.
//편의성을 위해 stock엔티티를 사용하는데 실무에선 별로의 jdbc사용해야함
public interface LockRepository extends JpaRepository<Stock, Long> {
    @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
    void getLock(String key);
    @Query(value = "select release_lock(:key)", nativeQuery = true)
    void releaseLock(String key);
}
//명령어 모두 작성하였따면 실제 로직 전후 Lock 획득 해제를 해주어야 하기 떄문에 facade클래스 추가해야함
@Transactional(propagation = Propagation.REQUIRES_NEW) //부모의 Transaction 과 별도의 실행이 되어야 하기 떄문에 Propagation 변경
    public void decrease(Long id, Long quantity){
        //stock 조회
        //재고를 감소한뒤
        //갱신한값을 저장
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }
@Component
public class NamedLockStockFacade {
    private final LockRepository lockRepository;
    private final StockService stockService;
    public NamedLockStockFacade(LockRepository lockRepository, StockService stockService) {
        this.lockRepository = lockRepository;
        this.stockService = stockService;
    }
    public void decrease(Long id, Long quantity){
        try{
            lockRepository.getLock(id.toString()); //락 획득
            stockService.decrease(id, quantity);
        }finally {
            lockRepository.releaseLock(id.toString()); //모든 로직이 종료되면 락 해제
        }
    }
}

주로 분산락을 구현할 때 사용
Pessimistic Lock보다 타임아웃을 손쉽게 구현
대이터 삽입시 정합성을 맞출때도 사용, 트랜잭션 종료시에 Lcok 해제, 세션관리를 잘 해주어야 함

Redis와 비교해서
mysql을 이미 사용중이라면 별도의 비용 발생없이 사용 가능
어느정도의 트래픽까지는 문제없이 활용 가능
Redis보다는 성능이 좋지 않다.

참고
https://www.inflearn.com/course/lecture?courseSlug=%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C&unitId=174916&category=questionDetail&tab=curriculum

0개의 댓글