다양한 방법으로 선착순 동시성 문제 해결하기 (락 , 레디스)

Hyuk·2023년 9월 25일
0

HappyScrolls 개발기

목록 보기
17/24
post-thumbnail

들어가며

다양한 방법으로 동시성 문제를 고민해보고, 나아가 부하가 큰 상황에서의 해결 방법도 고민해보았습니다.

위와 같은 코드가 있다.
현재 프로젝트에는 수량을 확인하고 0개보다 많이 있으면 물건을 구매하고 수량을 1개 줄이는 코드가 있다.

아래의 테스트코드를 실행하면 코드가 제대로 동작하지 않음을 알 수 있다.
총 수량이 100개인 물건이 있고, 100개의 쓰레드를 동작시켜서 수량을 1씩 낮추는 테스트이다. 테스트가 종료되면 수량이 0개가 남음을 기대한다.

하지만 결과는 0개가 아니다.


심지어 실행마다 결과가 달라지는 것을 알 수 있다.

이는 동시성 문제가 발생하기 때문이다.

동시성

현재 상황을 간단하게 설명하자면 위 그림과 같다.
처음 quantity의 값은 42.
이때 User1과 User2가 각각 물건을 구매하려는 상황이다.
처음 User1이 DB로부터 quantity를 읽어오고 그 값은 42와 같다.
User2도 DB로부터 quantity를 읽어오고 그 값은 42다. 이때 중요한건 아직 User1이 커밋을 하기 전이라는 것이다.

User1이 먼저 커밋을 하게 된다. quantity는 41이 된다.
그 이후 User2가 커밋을 하게 된다. quantity는 또 41이 된다.
원래대로라면 40이 되어야 했지만 동시성 문제로 41이 되어버린다.

1. synchronized

가장 처음에 시도한 방법은 synchronized를 붙이는 것이다. 이렇게 붙이면 한 쓰레드만이 함수에 접근할 수 있다.


테스트를 통과하는것을 알 수 있다.
간단하게 해결가능했지만, 분산서버의 경우 적용이 불가능하다. 어플리케이션 단에서 적용하는 방법인데, 서버가 나뉘면 여러 서버에서 여러 어플리케이션이 돌아가므로 당연히 적용불가능한 방법이다.

2. 비관적 락

두번째 방법은 비관적 락이고 현재 프로젝트에 적용한 방법이다.

위와 같이 함수 위에 락을 명시해준다. 이렇게 구성하면 배타락을 얻게되어 다른 트랜잭션은 락을 얻을 수 없어서 한 트랜잭션이 끝나기 전까지 접근할 수 없게되어서 정합성 문제를 해결할 수 있다.
비관적 락

비관적 락은 락을 소유하게 되면서 다른 트랜잭션의 접근이 아예 불가능하므로 성능저하가 일어난다. 자원을 공유해도 정합성이 틀어지지 않을수도 있기 때문에 필요 이상의 비용이 들수도 있다.

3. 낙관적 락

낙관적 락은 아래 그림과 같다.
락을 걸지 않아도 되는 상황이라고 가정을 먼저 한다.
그리고 정보에 버전을 명시해주고 데이터가 바뀔 때마다 버전도 변경해준다.
그렇다면 바뀌기 전 버전을 들고 있던 트랜잭션은 버전이 맞지 않으므로 update에 실패하고 롤백이 된다.

실패하면 OptimisticLockException를 던지게 되고, 이를 통해 복구 및 재시도를 시켜주면 된다.

낙관적 락은 기본적으로 락이 아니고 논리적인 락에 해당하므로 성능면에서 우위를 점한다. 하지만 실패시 롤백해줘야하는 단계가 개발을 더 해야해서 번거로운 점도 있고, 롤백이 자주 발생하면 오히려 비관적 락보다 성능이 안좋아진다.

충돌이 자주 일어날 것 같은 곳엔 비관적 락이 더 잘 어울린다고 한다.

4. update 쿼리 이용

 @Modifying(clearAutomatically = true, flushAutomatically = true)
 @Query(value = "update Product p set p.quantity = p.quantity - 1 where p.id = :id")
 void decreaseQuantity(Long id);

위와 같이 update쿼리를 이용하는 방법도 가능합니다. 저장되어 있는 값을 직접 변경해주기 때문에 경쟁 상태를 락 없이 피할 수 있습니다. 하지만 수량이 0 밑으로 떨어지는 경우를 대처하기 힘들다는 단점이 있습니다. 또한 비즈니스 로직이 쿼리에 포함된다는 단점이 있어 쿼리지향적인 코드가 될 우려가 있습니다.

지금까지의 상황을 보면 비관적 락, 낙관적 락을 사용하여 락을 이용해 문제를 해결하는 것이 합리적으로 보입니다.
하지만 만약 지금 이 물건이 인기가 있거나 혹은 한정판으로 출시되어 순식간에 사람들이 몰릴 여지가 있는 상황이라면 문제가 생길 수 있습니다.

5. 레디스를 이용한 분산 락의 활용

(사실 주인공)

요즘 유행하는 레플처럼 발매시간을 미리 알려주고 한정 수량만 판매하는 경우에는 발매 시간에 어마어마한 사람들이 몰립니다.

이러한 상황은 서버의 증설을 요구로 합니다. 이전의 글을 통해 서버 인스턴스를 늘리는 과정을 학습하였고, 복제 DB를 이용하여 부하를 분산시키는 구조를 설계하였습니다.

하지만 레플은 평상시의 부하보다 훨씬 더 강한 부하를 가할지도 모릅니다. 따라서 DB의 커넥션 풀이 고갈될수도 있고, 기존의 락과 분산 시스템을 동시에 이용하면 데드락 현상이 일어날 확률도 늘어납니다.
이에 레디스를 이용하여 문제를 해결하기로 했습니다.

레디스를 이용하여 분산락을 적용하였습니다.

분산락을 이용하면 데이터베이스의 락을 이용하지 않기에 데이터베이스가 받는 부하를 줄일 수 있습니다. 또한 Redisson을 이용하여 redis가 받는 부하 또한 줄일 수 있었습니다.

적용 코드

@Transactional
public void test(Long id) {

	RLock lock = redissonClient.getLock(id.toString());

    try {
           lock.tryLock(10, 1, TimeUnit.SECONDS);
    
            Product product= productRepository.findById(1l).get();
            if (product.getQuantity() > 0) {
                product.decreaseQuantity();
                productRepository.saveAndFlush(product);
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
    }finally {
            	lock.unlock();
     }
}

레디스에서 락을 이용하는 방법은 크게 두가지가 있습니다. Lettuce와 Redisson을 이용하는 방법입니다.
Lettuce는 분산락을 지원하지 않기 때문에 스핀락 형태의 락을 이용해야 합니다. 스핀락은 락을 획득하지 못한 경우 락을 획득할 때 까지 반복해서 redis에 요청을 보내게 됩니다. 이러한 방식은 redis에 부하가 늘어나게 되어 좋지 않습니다.
또한 부하를 줄이기 위해 락 획득 재시도 기간을 길게 설정하면, 락을 획득할 수 있는 상황에서도 무조건 설정시간동안 기다리게 되어서 비효율적일 수도 있습니다.

Redisson을 이용하면 pub/sub 의 형태로 락이 구성됩니다. 락을 획득한 쓰레드에서 과정을 마치고 락을 놓아주게 되면 구독하고 있던 클라이언트들에게 알림을 보내는 형태입니다. 기존에 락 획득을 위해 요청하는 과정이 없어지게되어 불필요한 부하가 없어지는 장점이 있습니다.

이렇게 동시성 문제를 해결하기 위해 여러 방안을 살펴보고, 추가적을 발생가능한 문제까지 고려하여 방안을 채택하였습니다.

profile
🙂 🙃 🙂 🙃

0개의 댓글