작지만 꾸준한 반복

동시성 문제를 해결해보자 - RDB 분산 락 본문

카테고리 없음

동시성 문제를 해결해보자 - RDB 분산 락

iamjooon2 2023. 12. 4. 17:58

이전 포스트에서 Java의 synchronized를 이용하여 동시성 문제를 해결해보았는데요

@Transactional 적용이 안된다는 점과, 스케일 아웃 환경에서 동시성 보장이 안된다는 문제가 있었습니다

 

이 두 문제를 해결할 수 있는 방법으로, 분산 락을 이용하는 방법을 소개해보려 합니다

 

분산된 환경에서 동일 자원에 접근하는 경우, 동시에 한 개의 프로세스만 접근 가능하도록 하기 위해 사용하는 락을 분산락(Distribution Lock)이라고 합니다.

 

처음 공부할 때 헷갈린 포인트가 X-lock이니, S-Lock등의 키워드였습니다.

모두 분산된 환경에서의 잠금을 구현하기 위한 방법들이고, 그 구현 방법으로 크게 낙관적 락(Optimistic Lock), 비관적 락(Pessmistic Lock), MySQL의 Named Lock 등이 있던 거였습니다

 

포스트에서 낙관적 락, 비관적 락, MySQL의 네임드 락에 대해 정리해보겠습니다

 

1. 비관적 락

비관적 락은 데이터베이스의 S-Lock이나 X-Lock등을 이용한 방법입니다

 

- S(Shared) Lock

S-Lock은 데이터베이스의 특정 행을 읽을 때 사용하는 락입니다.

S-Lock이 걸린 행에는, 다른 트랜잭션은 S-Lock만이 접근 가능하고, X-Lock은 접근 불가능합니다

 

-X(eXclusive) Lock

X-Lock은 데이터베이스의 특정 행에 쓰기작업을 할 때 사용하는 락입니다.

X-Lock이 걸린 행은 다른 트랜잭션에서는 S-Lock 혹은 X-Lock을 통해 접근할 수 없습니다.

 

그럼 JPA에서 비관적 락을 적용해보겠습니다.

public interface StockRepository extends JpaRepository<Stock, Long> {

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

    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticSLock(Long id);
    
}

 

@Lock 어노테이션을 통해 잠금을 설정할 수 있고, LockModeType을 통해 어떤 종류의 락을 설정할지 고를 수 있습니다.

@Query 메서드를 통해 실제 JPQL을 사용한 모습입니다.

 

이 메서드를 사용하는 서비스와 이 서비스의 테스트 코드는 다음과 같습니다.

@Service
public class StockPessimisticLockService {

    private final StockRepository stockRepository;

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

    @Transactional
    public void decrease(Long id) {
        Stock stock = stockRepository.findByIdWithPessimisticXLock(id);
        // Stock stock = stockRepository.findByIdWithPessimisticSLock(id);
        stock.decrease();

        stockRepository.save(stock);
    }
}

 

테스트는 아래와 같습니다

@SpringBootTest
@SuppressWarnings("NonAsciiCharacters")
class StockPessimisticLockServiceTest {

    @Autowired
    private StockPessimisticLockService stockPessimisticLockService;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    void setUp() {
        stockRepository.saveAndFlush(new Stock(1L, 100L));
    }

    @Test
    void 동시에_100개의_요청() throws InterruptedException {
        // given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // when
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    stockPessimisticLockService.decrease(1L);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        Stock stock = stockRepository.findById(1L).orElseThrow();
        assertThat(stock.getQuantity()).isZero();
    }
}

 

그리고 이 테스트를 실행했을 때,

 

테스트가 성공하는 것을 볼 수 있고, Hibernate 내에서 실제 나가는 쿼리를 보면, for update가 잘 붙는 것을 볼 수 있습니다.

 

비관적 락(pessimistic lock)의 경우, 충돌이 빈번하지 않다면 낙관적 락(optimistic lock)보다 더 좋은 선택일 수 있습니다.

데이터의 정합성이 완전히 보장되나, 데드락의 위험성이 존재한다는 단점이 있습니다.

 

 

2. 낙관적 락

낙관적 락은 데이터베이스에 실제 락을 사용하지 않고, 버전 칼럼을 통해 데이터베이스의 정합성을 맞추는 방법입니다.

읽어온 시점과 트랜잭션이 커밋되는 시점의 데이터의 정합성을 비교하는 방식으로 이루어집니다.

 

사용하고자 하는 테이블에 @Version 어노테이션을 추가해서 관리할 수 있습니다.

 

@Entity
public class Stock {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    @Version
    private Long version;

    protected Stock() {
    }

    public Stock(Long productId, Long quantity) {
        this.productId = productId;
        this.quantity = quantity;
    }

    public void decrease() {
        if (this.quantity < 0) {
            throw new IllegalArgumentException();
        }
        this.quantity -= 1;
    }
}

 

위와 같이  @Version 어노테이션을 이용할 수 있고, 이제 이걸 사용하는 Pessimistic Lock을 적용해보겠습니다

 

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithOptimisticLock(Long id);

}

 

@Lock 어노테이션을 이용하고, Optimistic 옵션을 추가해줍니다

낙관적 락은 @Version 어노테이션이 달린 컬럼을 관리해야할 버전으로 인식 후, 자동으로 비교 및 업데이트해줍니다

즉 데이터베이스단에 거는 잠금이 아닌, 어플리케이션 레벨에 거는 잠금입니다

 

낙관적 락은 동시성 감지만 가능합니다. 성공해야한다면, 예외를 감지했을 때 재시도를 하기 위해

해당 Service를 사용하는 파사드를 하나 만들어, 재시도 처리를 이곳에서 처리하도록 합니다.

@Service
public class StockOptimisticLockService {

    private final StockRepository stockRepository;

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

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

        stockRepository.save(stock);
    }

}

@Service
public class StockOptimisticLockServiceFacade {

    private final StockOptimisticLockService stockOptimisticLockService;

    public StockOptimisticLockServiceFacade(StockOptimisticLockService stockOptimisticLockService) {
        this.stockOptimisticLockService = stockOptimisticLockService;
    }

    public void decrease(Long id) {
        while (true) {
            try {
                stockOptimisticLockService.decrease(id);

                break;
            } catch (Exception e) {
                try {
                    Thread.sleep(50);
                } catch (InterruptedException ex) {
                    throw new RuntimeException(ex);
                }
            }
        }
    }
    
}

 

기존 테스트에서 사용하는 서비스만 파사드로 변경해준 뒤, 실행해 보면

 

 

테스트도 성공적으로 통과하는 것을 볼 수 있고, 쿼리에도 version 행을 이용하는 것을 볼 수 있습니다.

 

낙관적 락은, 실제로 DB상에서 락을 잡는 것이 아닌 데이터베이스의 버전을 통해 사용하기 때문에 비관적 락보다 성능상 이점이 있습니다.

고로 충돌이 빈번할 것이라 예상된다면, 낙관적 락이 좋은 선택지가 될 수 있겠죠?

 

3. 네임드 락

네임드 락은 MySQL에서 지원해주는 락 중 하나의 종류인데요,

테이블, 레코드가 아닌 임의의 문자열에 대해 잠금을 획득할 수 있습니다

 

적용 방법은 간단합니다

실제 MySQL에서 GET_LOCK()을 통해 잠금 획득하는 방법을 그대로 이용하는 건데요

코드와 함께 보시죠!

 

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

}

 

nativeQuery 옵션을 통해, JPQL이 아닌 SQL 문법을 사용함을 명시한 다음,

value 옵션에 쿼리를 작성해줍니다.

 

사용할 서비스에 다음과 같은 메서드를 작성해줍니다.

@Transactional
public void decrease(Long id) {
    try {
        lockRepository.getLock(id.toString());

        stockService.decrease(id);
    } finally {
        lockRepository.releaseLock(id.toString());
    }
}

 

 

락을 획득한 다음 재고를 감소시킨 후 최종적으로 락을 해제해줍니다.

 

(user-level lock은 자동으로 해제되지 않기 때문에 꼭 명시적으로 해제해줘야 합니다.)

 

네임드 락 역시, 분산락을 구현할 때 많이 사용하지만

비관적 락은 timeout 설정이 어렵지만, 네임드 락이 timeout을 더 쉽게 구현할 수 있습니다.

 

하지만 트랜잭션 종료시, 락 해제 관리를 꼭 해줘야합니다.

 

네임드 락은 MySQL의 임의의 문자열에 대해 잠금을 획득하지만(변수같은 느낌인 것 같습니다)

비관적 락은 레코드 수준에 락을 걸기 때문에, 다른 트랜잭션이 같은 레코드에 접근할 수 없다는 차이가 있는 것 같습니다...

 

코드는 이 곳 에서 확인할 수 있습니다

 

레퍼런스

https://www.inflearn.com/course/%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/dashboard

https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html

https://techblog.woowahan.com/2631/