Spring

레이스 컨디션이란?

challnum 2024. 4. 18. 22:27

레이스 컨디션이란 Multi Thread 환경을 사용하다 보면 직면하는 문제이며

 

둘 이상의 프로세스 혹은 스레드가 공유 데이터에 엑세스 할 수 있고 동시에 변경을 시도 할 때 발생하는 문제이다.

 

Multi Thread란 하나의 프로세스 안에서 2개 이상의 Thread가 작업하는 것을 말하며 메모리의 Heap과 Data 영역을 서로 공유하게 된다. 그렇기 때문에 같은 Data를 동시에 작업을 할 수 있기 때문에 레이스 컨디션이 발생할 수도 있다.

 

정상적으로 Thread가 동작시에는 아래와 같이 Thread-1이 재고를 1 감소 시키고 Thread-2가 재고의 수량을 확인후 다시 1을 감소시키는 작업을 할 수 있다.

출처 : https://www.inflearn.com/course/동시성이슈-재고시스템/dashboard

 

하지만 아래와 같이 Thread-1이 재고를 감소시키고 DB에 반영되기전의 Stock의 상태를 가지고 Thread-2가 작업을 하여 총 2의 재고가 감소되어야 하지만 실제로는 1만이 감소하는 상황이 발생하는 것이 레이스 컨디션이다.

출처 : https://www.inflearn.com/course/동시성이슈-재고시스템/dashboard

먼저 예시 코드로 보자면 아래와 같이 볼 수 있다.

@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 {
                    stockService.decrease(1L, 1L);
                }finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

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

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

 

위의 코드에서는 Multi Thread 에서의 환경을 구현했고 총 Thread Pool의 갯수 32개 즉 동시에 작업을 할 Thread의 갯수는 최대 32개 이며 threadCount의 경우 100번 즉 100번을 작업하는데 최대 32개의 Thread가 동시에 접근할 수 있다는 의미이다. 위 코드로 테스트를 실행시킨 결과 위에서 설명했던 것처럼 예상했던 0과 다르게 90의 값이 출력되는 걸 볼 수 있다.

 

 

그렇다면 해결하기 위한 방법에는 어떤 게 있을까?

먼저 해결하기 위해서는 간단하게는 하나의 자원에 2이상의 Thread가 동시에 작업을 하기에 발생하는 문제라고 볼 수 있다. 그렇기 때문에 하나의 자원에 작업하는 Thread를 하나씩 작업하게 만들어 주면 해결이 된다.

 

1. Synchronized

Synchronized를 로직에 붙이게 되면 Thread에 하나씩 접근을 하도록 할 수 있다.

기존의 감소 시키는 로직

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

    stockRepository.saveAndFlush(stock);
}


synchronized가 추가된 감소시키는 로직

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

    stockRepository.saveAndFlush(stock);
}

 

그렇다면 해당 코드를 도입 후 테스트 코드를 돌려보게 되면 아래와 같이 일치하지 않는 결과가 나오게 된다.

 

분명 Synchronized 키워드를 메소드에 선언해주면 레이드 컨디션을 해결할 수 있다고 했지만 왜 위와 같은 결과가 나오게 되는지 생각을 해보자 그 이유는 @Transcational 어노테이션 때문이다.

@Transactional을 붙히게 된 경우 아래와 같이 맵핑할 클래스를 만들어 동작을 하게 되는데 코드를 보게 되면 @Transactional을 시작하고 decrease 메소드가 시작을 하고 @Transactional이 완료되기 전에 다른 @Transactional이 decrease 메소드를 실행 할 수 있기 때문에 발생하는 것이다.

public void decrease(Long id, Long quantity){
	startTransaction();
	stock.decrease(id, quantity);
    
	endTransaction();
}

 

그렇다면 @Transactional을 제거 한 후 사용하면 정상적으로 작동일 될까?

 

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

        stockRepository.saveAndFlush(stock);
    }

 

정답은 그러하다이다 앞에서 말했듯이 @Transactional을 시작하고 decrease 메소드가 시작을 하고 @Transactional이 완료되기 전에 다른 @Transactional이 decrease 메소드를 실행 할 수 있기 때문에 발생하는 것이어서 @Transactional를 주석처리 해주게 되면 정상적으로 테스트를 통과하는 것을 볼 수 있다.

 

 

하지만 Synchronized를 사용했을 때의 문제점도 존재한다. Synchronized의 경우 한 개의 프로세스안에만 보장되기에 서버가 하나의 경우에는 안전하지만 2개 이상의 경우 아래와 같은 10:00에 Server-1이 작업을 한 후 Server-2가 동시에 작업을 하게 되면 DB에 반영 되는 10:05에 보게 되면 감소 시킨 값이 3이 아니라 4가 존재하는 레이스 컨디션 문제가 발생하게 된다.

 

 

그렇다면 다른 방법인 DB Lock을 활용해서 Pessimistic Lock, Optimistic Lock, Named Lock으로 정합성 문제를 해결해보도록 하자. Lock을 걸게 되면 아래와 같은 형태로 동작을 하게 된다. 먼저 Thread -1이 작업을 하고 있기 때문에 Thread -2는 해당 DB에 접근을 할 수 없다. 그렇기 때문에 점유 중인 DB의 락이 풀리는 시점 즉 DB에 Transaction이 반영된 후에 DB에 접근을 할 수 있게 된다.

이런 식으로 Lock을 이용해 정합성 문제를 해결할 수 있다.

 

 

1. Pessimistic Lock(비관적 잠금)

 

기존의 코드와 다른 점이 있다면 findByIdWithPessimisticLock 메서드라는 커스텀 메서드를 선언하고 @Query 어노테이션으로 기능으로 어떤 기능을 쓰는지 명시해주고 @Lock 어노 테이션으로 Pessimistic Lock을 걸어두었다. 이 경우 어떻게 되는지 아래를 살펴보면 아래와 같이 100개의 요청이 성공적으로 통과되는걸 볼 수 있다. 하지만 이 경우 하나의 Transaction이 점유하고 있는 상황에서 Transaction이 접근 할 수 없기에 성능 저하가 발생할 수 있으며 다른 충돌이 빈번하게 일어날 것으로 예상되면 추천드립니다.

@Service
public class PessimisticLockStockService {

    private final StockRepository stockRepository;

    public PessimisticLockStockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

    @Transactional
    public void decrease(Long id, Long quantity){
        Stock stock = stockRepository.findByIdWithPessimisticLock(id);

        stock.decrease(quantity);

        stockRepository.save(stock);
    }
}


public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(@Param("id") Long id);
}

 

2. Optimistic Lock(낙관적 잠금)

 

다만Optimistic Lock은 실패했을 경우 재시도를 해야 하기에 재시도 로직을 직접 작성해야 하는 번거로움도 있고  Version이 다른 경우 RollBack을 이용하여 다시 로직을 짜서 Transaction이 접근하도록 해야 하기에  충돌이 빈번하게 일어나거나 예상이 된다면 Optimistic Lock을 사용하면 성능이 저하 되기 때문에 추천하지 않는다.

 

3. Named Lock

 

주의 할 점은 Transaction이 해제될 때 Lock이 자동으로 해제되지 않기 때문에 별도의 해제를 해주는 명령(release)을 넣어주거나 선점 시간이 끝나야 해제가 된다. 

새로 Repository를 하나 생성하고 getLock과 releaseLock을 선언해줘야 한다.

실무에서는 아래와 같이 같은 데이터 소스를 사용시에 커넥션 풀이 부족할 수 있다. 그렇기에 Stock같은 Entity를 공유해서는 안된다.

아래처럼 새로운 Repository를 생성 후 실행 하였을때

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);
}

 

아래와 같이 데이터의 정합성이 맞지 않는 오류가 발생했다. 그 이유는 MySQL 구문에 맞지 않게 Query문이 작성 되었기 때문인데 

 

이 경우 아래의 구문으로 해결을 하였다. 아래의 구문으로 수정 후 실행하려 한다면 아마 select get_lock(?1, 3000), select release_lock의 부분에서 오류가 발생할텐데 그 부분은 Mac기준으로 command + enter키를 눌러서 parameter 값을 key로 입력해주게 되면 ?1에 parameter의 값을 key로 고정해주어서 해결하게 하면 된다.

public interface LockRepository extends JpaRepository<Stock, Long> {

    @Query(value = "select get_lock(?1, 3000)", nativeQuery = true)
    void getLock(String key);

    @Query(value = "select release_lock(?1)", nativeQuery = true)
    void releaseLock(String key);

}