Redis를 활용한 동시성 이슈 제어 : Lettuce vs Redisson

최인준·2023년 12월 27일
5
post-thumbnail

서론

이번 프로젝트에서 동시성 이슈가 생길 수 있는 상황이 있어서 이를 해결해야 한다.

먼저 동시성 이슈를 해결하지 않으면 발생할 수 있는 문제를 간략히 소개하자면 다음과 같다.

  1. Race Condition
    • 두개 이상의 스레드가 임계 영역에 동시에 접근하면 예상과 다른 결과가 나올 수 있다.
  2. DeadLock
    • 두개 이상의 스레드들이 서로의 lock을 기다리다 아무 스레드도 실행되지 않을 수 있다.

Redis를 활용한 Lock 구현을 선택한 이유

동시성 이슈를 해결하기 위해서 lock을 구현하여 요청이 들어온 스레드의 원자성을 보장해주어야 한다.

이를 위해 내가 처음에 생각했던 것은 크게 두가지 방법이다.

바로 MySQL을 통한 lock과 Redis를 통한 lock이다. 나는 이 둘 중 Redis를 통해 lock을 구현하기로 결정했다.

이유는 다음과 같다.

  1. 프로젝트는 멀티 스레드 환경에서 구동이 되고 이번 프로젝트는 서버가 하나지만 보통은 서버를 다중화해서 사용한다. 이런 분산환경 속에서 동시성 이슈를 해결하기 위해서는 분산락이 필요하다.

  2. 분산락은 Redis를 통해 구현하는게 적절하다고 생각했다.

    MySQL의 네임드락은 lock을 걸려있는 지 지속적으로 확인해야하고 이러한 session에 대한 cost가 큼

    → Redis는 분산형 메모리 내 데이터 저장소로 분산환경에서의 lock을 구현하는데 적합

    → 메모리 내에서 데이터를 다루기 때문에 디스크 기반의 데이터베이스보다 빠른 응답시간 제공

    → session에 대해 신경 쓸 필요X

이러한 이유들로 Redis를 통해 lock을 구현하기로 결정했다 ‼️

Redis를 활용하기로 결정하였다면 고려해야할 것이 하나 더 남았다.

Redis의 Lettuce 라이브러리, Redisson 라이브러리 둘 중 어떤 라이브러리를 활용할 지 결정해야 한다.

Lettuce vs Redisson

어떤 라이브러리를 사용해도 목적은 같다. 분산락을 구현하는 것이다.

이 둘의 차이를 간략히 보면 다음과 같다.

LettuceRedisson
분산 락 기능 제공X(직접 구현 필요)O
라이브러리 크기상대적으로 작음상대적으로 큼
구현 방식spin lockpub-sub 구조

Lettuce는 spin lock 방식으로 구현하므로 lock이 해제되었는 지 주기적으로 retry를 해야하므로 이 부분에서 부하가 커질 수 있다.

Redisson은 분산락을 쉽게 사용할 수 있는 기능을 포함해 분산 시스템에서 사용하기 위한 다양한 기능을 제공하고 pub-sub구조이기 때문에 lock이 해제되면 lock 해제를 기다리던 스레드들에게 알려주는 구조로 구현할 수 있다.

일반적으로 Redisson을 이용하는 것이 분산락을 구현했을 때 성능이 더 좋다고 알려져있지만 그렇다고 해서 무조건 Redisson을 선택하는 것은 잘못된 생각인 것 같다.

둘의 차이를 이해하고 현재 프로젝트의 요구사항에 따라 선택한 결과 Lettuce를 활용하기로 하였다.

  1. 분산락을 구현하는 것만을 목적으로 Redis를 활용할 것인데 분산락에 대한 많은 고급기능을 제공하는 Redisson 라이브러리는 이 프로젝트에 불필요하게 무겁다고 생각했다.
  2. 그리고 이 프로젝트에서는 lock-unlock 하는 과정이 짧다. 그렇기에 lock에 대한 retry를 하기 위해 루프를 많이 돌 일이 없기 때문에 부하가 크지 않을 것이라고 생각했다.

그래서 이런 결정을 내렸고 추후에 요구사항이 수정되어 로직을 수정해야 할 때 둘 라이브러리에 대해 다시 고려해보는 방향으로 나아가기로 했다.

Lettuce의 setnx

*docker에 redis 이미지를 pull 하여 실습해볼 수 있다.

Lettuce는 Redis의 setnx 명령어를 통해 lock을 구현할 수 있다.

setnx는 키-값을 저장하는 명령어인데 해당 키가 존재하면 명령어 처리에 실패하고 존재하지 않으면 키-값 저장에 성공한다. (nx가 NotExist의 줄임이다.)

이와 같이 1 이라는 key 이름으로 lock 이라는 value을 등록하면 처음에는 아무런 키-값이 존재하지 않기 때문에 등록에 성공하고 1을 리턴받는다.

그 다음으로 또 1 이라는 키 이름을 등록하려하면 기존에 등록한 키가 있기 때문에 실패하고 0을 리턴 받는다.

키를 삭제하려면 del lock 을 실행하면 된다.

이제 이 setnx와 del 명령어를 통해 직접 spin lock 형태로 lock을 구현해볼 것이다.

Lettuce lock 구현

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

이제 lock-unlock을 직접 구현해보자.

@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(generated(key), "lock", Duration.ofMillis(3_000));
    }

    public Boolean unlock(Long key){
        return redisTemplate.delete(generated(key));
    }

    private String generated(Long key) {
        return key.toString();
    }
}

Redis에서 키-값 쌍을 저장하기 위해 RedisTemplate을 활용하였다.

RedisTemplate은 추상화와 직렬화의 편리함을 제공해준다. 저장할 키-값을 String으로 저장하기로 했으므로 opsForValue() 메소드를 사용하였다.

  • opsForValue()는 String을 serialize/deserialize 하기 위한 메소드이다.
  • setIfAbsent()는 위의 터미널에서 실행한 setnx 명령어다. → 인자로 받은 key와 “lock”이라는 value로 키-값쌍 저장을 시도한다.

unlock 메소드는 인자로 받은 key 값의 key-value 쌍을 메모리에서 삭제하는 메소드이다.

lock-unlock 구현을 마쳤고 실제 비즈니스 로직에서 구현한 것을 통해 spin lock 형태를 만들어 볼 것이다.

@Component
@Slf4j
@RequiredArgsConstructor
public class LettuceLockStockFacade {
    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    public void decrease(Long key, Long quantity) throws InterruptedException{
        while (Boolean.FALSE.equals(redisLockRepository.lock(key))){
            Thread.sleep(10);
        }

        stockService.decrease(key, quantity);
        redisLockRepository.unlock(key);
    }

}

프로젝트의 코드는 아니고 예제 코드이다.

특정 상품의 재고를 감소시키는 비즈니스 로직이 있다고 해보자.

우선 key 인자로 상품 Id와 상품의 재고를 받는다. 재고를 감소시키는데에 있어 동시성 이슈가 발생할 수 있으므로 방금 구현한 lock-unlock을 활용하여 spin lock 형태를 만들어야 한다.

메소드 내에 루프 조건을 보면 방금 구현한 상품아이디로 키를 설정하여 lock 을 호출한다.

이 결과값이 false가 나온다면 이미 lock을 다른 스레드가 차지한 상태인 것이다. 그렇다면 일정 주기를 기다렸다가 lock 획득을 재시도 해야 하므로 그 시간만큼 기다렸다가 다시 lock 획득을 시도하는 방식이다.

구현한 것이 잘 작동되는 지 테스트 코드를 돌려봐야 한다.

@Test
    @DisplayName("동시에 100개의 재고 감소 요청이 들어오면 재고가 0이 된다.")
    public void lettuceTest() 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 {
                    lettuceLockStockFacade.decrease(1L, 1L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

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

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

100개의 상품이 있는 상태고 100개의 재고 감소 요청이 동시에 들어왔을 때를 테스트 해보자.

결과는 성공!

결론

lettuce를 이용하여 동시성 이슈 제어를 구현해보았다.

간단한 로직에 대하여 구현한 lock을 활용해보았는데 이제 이것을 바탕으로 현재 프로젝트 중인 비즈니스 로직에서 사용해보아야 한다.

실제 프로젝트에 적용해보았을 때 어떤 문제가 발생할 지 모르겠지만 적용해볼 예정이다.

추후에 로직이 복잡해질 가능성이 있기 때문에 그 때는 프로젝트에 Lettuce와 Redisson을 적용했을 때의 성능 차이를 테스트 해봐야 할 것 같다.

1개의 댓글

comment-user-thumbnail
2024년 1월 10일

redis 배경사진이 2개가 보여요 !

답글 달기