본문 바로가기

Spring boot

동시성 관리하기 3탄) Redis - Lettuce, Redisson

DB가 스케일 아웃된 상황에서 동시성 관리하기 !

동시성 관리하기 2-2탄에 이어서, 오늘은 Redis를 이용해 보겠습니다.

 

 

우선 같은 데이터 셋을 공유하는 분산 DB 상황에서는 아래 사진과 같은 상황이 벌어질 수 있습니다.

출처 : https://velog.io/@hgs-study/redisson-distributed-lock

  • 여러 요청들이 한 자원에 대해서 공유할 때, 각 분산 DB의 동기화가 여러 요청의 동기화 속도를 못 따라 가는 상황이 발생합니다.
  • 이에 대해 데이터 정합성은 깨지게 되고, 데이터 동시성 문제가 발생하게 됩니다.
  • 예를 들어, 위와 같이 한 번에 여러 구매 요청이 들어왔을 경우 수량이라는 자원을 동시에 사용할 경우 여러 수량의 커밋되거나 롤백되는 수량의 동기화가 다른 서버가 따라가지 못해서 정합성이 깨지고, 동시성 문제가 발생할 수 있습니다.

 

이를 방지하기 위해,

출처 : https://velog.io/@hgs-study/redisson-distributed-lock

위 사진과 같이,

  •  공유 자원인 수량을 레디스에 올려놓고 분산락(Distributed Lock)을 활용해서 데이터 동시성 문제를 해결할 수 있습니다.
  • 여러 요청마다 락을 점유하고 데이터 업데이트 하기 때문에 각 서버는 각 DB의 동기화를 기다리지 않아도 되며, 동시성 문제도 해결할 수 있습니다.

 

Redis의 분산 Lock 

Java의 Redis 클라이언트는 Jedis, Lettuce, Redisson 등이 있고, 각 특징이 서로 다릅니다.

이중에서 Lettuce와 Redisson을 사용해 분산 lock을 구현하겠습니다.

 

* Lettuce는 Jedis보다 TPS/CPU/Connection 개수/응답속도 등 전 분야에서 우위에 있다고 합니다. 

 

 

Lettuce 사용해 분산 lock 구현하기

Lettuce는 Netty기반의 Redis Client로, 요청을 논블로킹으로 처리하여 높은 성능을 가집니다.

spring-data-redis 의존성을 추가했다면, 기본적으로 Lettuce 기반의 Redis Client가 제공됩니다.

 

Lettuce를 사용한다면 SETNX 명령어를 통해서 분산 lock을 구현할 수 있습니다.

 

 

사용법

@RequiredArgsConstructor
@Repository
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Object key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(key.toString(), "lock", Duration.ofMillis(3000));
    }

    public Boolean unlock(Object key) {
        return redisTemplate.delete(key.toString());
    }
}

 

lock() 메서드 내부의 setIfAbsent()를 통해 SETNX를 사용합니다.

setIfAbsent() 메서드의 파라미터는 순서대로 key, value, timeout 을 받습니다.

value로는 "lock"을 설정해 주었지만, 다른 값이어도 상관없습니다.

 

 

 

이어서 재고감소 로직 전 후로 락을 획득하고 반환하는 로직이 필요하므로, 기존과 같이 Facade를 도입하도록 하겠습니다.

@RequiredArgsConstructor
@Service
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    public void decrease(Long id) {
        while (!redisLockRepository.lock(id)) {  // lock 못 잡으면 무한대기
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        
        try {                                  // lock을 잡고 while 문을 벗어났다면 decrease()
            stockService.decrease(id);
        } finally {
            redisLockRepository.unlock(id);
        }
    }
}

스핀락 방식으로 자원을 얻을 때까지 무한 대기하고 있습니다.

 

 

 

아래는 테스트 코드입니다.

@SpringBootTest
class LettuceLockStockFacadeTest {

    @Autowired
    private LettuceLockStockFacade lettuceLockStockFacade;

    @Autowired
    private StockRepository stockRepository;

    private Long stockId;

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

    @AfterEach
    void tearDown() {
        stockRepository.deleteAll();
    }

    @Test
    void decrease_with_100_request_name_lock() 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 {
                    lettuceLockStockFacade.decrease(stockId);
                } catch (Exception e) {
                    System.out.println(e);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        Stock stock = stockRepository.getById(stockId);
        assertThat(stock.getQuantity()).isEqualTo(0);
    }
}

 

재고 감소가 올바르게 이루어집니다.

 

 

 

Redisson 사용해 분산 lock 구현하기

Redisson은 아래와 같이 자체 TTL을 제공하고 있습니다.

  • RedissonLock.java의 tryLockInnerAsync메서드를 확인해보면 Lua Script를 사용해서 자체 TTL을 적용하는 것을 확인할 수 있습니다
  • hincrby 명령어는 해당 field가 없으면 increment 값을 설정합니다.
  • pexpire 명령어는 지정된 시간(milliseconds) 후 key 자동 삭제합니다.

 

또한 Redis는 Pub/Sub 기능을 제공해주고 있습니다. 이를 사용하면 Lettuce처럼 스핀락 방식을 사용하지 않고, 분산 lock을 구현할 수 있습니다.

Redisson에서는 Pub/Sub 기반의 분산 lock을 이미 제공해주고 있으므로, 직접 구현하기 보다 이를 사용하도록 하겠습니다.

 

 

 

사용법

먼저 Redisson 의존성을 추가해주도록 하겠습니다.

implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'

 

 

 

Redission에서는 이미 분산 lock을 구현하여 제공해주므로, 이를 바로 사용하는 Facade를 만들어주겠습니다.

* lock.tryLock() 함수는 wait time과 lease time을 파라미터로 전달 받는다.

wait time 동안 lock의 획득을 시도하고, 이 시간이 초과되면 lock의 획득에 실패한 후 tryLock 함수는 false를 리턴한다.

lock의 획득에 성공한 이후엔 lease time이 지나면 자동으로 lock을 해제한다.

@RequiredArgsConstructor
@Component
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final StockService stockService;

    public void decrease(Long id) {
        RLock lock = redissonClient.getLock(id.toString());

        try {
            boolean acquireLock = lock.tryLock(10, 1, TimeUnit.SECONDS);
            if (!acquireLock) {
                System.out.println("Lock 획득 실패");
                return;
            }
            stockService.decrease(id);
        } catch (InterruptedException e) {
        } finally {
            lock.unlock();
        }
    }
}

 

 

 

테스트코드는 다음과 같습니다.

@SpringBootTest
class RedissonLockStockFacadeTest {

    @Autowired
    private RedissonLockStockFacade redissonLockStockFacade;

    @Autowired
    private StockRepository stockRepository;

    private Long stockId;

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

    @AfterEach
    void tearDown() {
        stockRepository.deleteAll();
    }

    @Test
    void decrease_with_100_request_name_lock() 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 {
                    redissonLockStockFacade.decrease(stockId);
                } catch (Exception e) {
                    System.out.println(e);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        Stock stock = stockRepository.getById(stockId);
        assertThat(stock.getQuantity()).isEqualTo(0);
    }
}

 

재고 감소가 올바르게 이루어집니다.

 

 

 

 

 

Lettuce  vs  Redisson 차이

Lettuce

1. Spring-data-redis를 이용하면 Lettuce는 기본 제공이므로 구현이 간단합니다.

2. SETNX 를 이용해 사용자가 직접 스핀락 형태로 구성합니다. 락이 점유 시도를 실패했을 경우 계속 락 점유 시도를 하게 되면서 레디스는 계속 부하를 받을 수 있습니다.

3. 추가적으로, 만료시간을 제공하고 있지 않아, lock을 점유한 서버가 장애나면 다른 서버들은 무한대기 상태에 빠집니다.

 

Redisson

1. 사용자가 직접 스핀락을 구현할 필요 없이 Lock 획득 재시도를 기본으로 제공해 줍니다.

2. Pub-Sub으로 동작해 Lettuce에 비해 Redis에 부하가 덜 갑니다.

3. 하지만 별도의 의존성을 추가해줘야 합니다.

 

 

참고 사항

1. 재시도가 필요하지 않은 Lock은 Lettuce 활용하자!

public void demo() {
     Boolean lock = redisRepository.lock(key);
     if(!lock) {
          // 재시도가 필요하지 않으므로 다른로직을 수행하지 않습니다.
          // 로깅이 필요하다면 로깅을 추가할 수 있습니다.
          // 에러 상황이라면 throw 를 할 수도 있겠습니다.
          return;
     }
     // 비즈니스 로직
}
 
 

2.  MySQL 비관적 Lock 과 Redis의 분산 Lock

In-memory DB인 Redis는 Disk 기반으로 동작하는 MySQL보다 성능이 뛰어납니다. 

비관적 Lock을 사용하는 경우, 실제 Lock을 잡아 이후 커넥션들은 대기해야 합니다. 

이때 Lock을 잡고 있는 Transaction 작업이 길어진다면 커넥션 수가 모자르게 되어, 쿼리가 느려지고 서비스가 느려질 수 있습니다.

반면, Redis를 사용한다면 커넥션이 대기하는 상황이 사라지므로 이런 문제를 예방할 수 있습니다. 

 

하지만 커넥션 대기가 길어지지 않을 상황(커넥션 풀이 충분하거나 적절한만큼의 요청이 들어올 때) 또한 싱글 DB에서의 성능은 사용자가 크게 체감하지 못할 가능성이 크므로, 성능보다는 현재 인프라 상황을 고려하는 것이 좋을 수가 있습니다.

 

Redis를 사용하지 않는 프로젝트에서 분산 lock을 위해 Redis를 사용하려 한다면, 별도의 Redis 구축 비용과 학습 비용이 발생합니다. 

그에 비해 이미 MySQL을 사용하고 있다면 별도의 인프라 구축 비용과 학습 비용이 발생하지 않습니다. 

 

따라서 여러 조건과 상황을 고려해 적절한 기술을 도입해야 합니다.

 
 

3. Sorted Set

지금까지 배운 Redis의 Lettuce, Redisson을 이용해도 순서를 보장할 수 없습니다.

시간 순서대로 들어온 선착순 보장 시스템(쿠폰 대기열, 좌석 예약)에서는 Redis의 Sorted Set을 이용해 들어온 순서대로 Set에 넣고 순차적으로 처리하는 방법을 사용해야 합니다. 

 

 

 

 

재미있겠네요 !!

다음 4탄은 Redis의 Sorted Set으로 돌아오겠습니다~!

 

 

 

 

참고 )

Jedis, Lettuce 비교 : https://jojoldu.tistory.com/418

 

Jedis 보다 Lettuce 를 쓰자

Java의 Redis Client는 크게 2가지가 있습니다. Jedis Lettuce 둘 모두 몇천개의 Star를 가질만큼 유명한 오픈소스입니다. 이번 시간에는 둘 중 어떤것을 사용해야할지에 대해 성능 테스트 결과를 공유하

jojoldu.tistory.com

 

Redis Sorted Set : 

https://ttl-blog.tistory.com/1581?category=906282

 

[Spring] 동시성 문제 해결방법 (4) - Redis 사용(Lettuce, Redisson)

🤔 서론 이번 글에서는 Redis를 사용하여 분산락을 구현해 보도록 하겠습니다. Java의 Redis 클라이언트로는 Jedis, Lettuce, Redisson 등이 있으며, 각각의 특징이 서로 다릅니다. 이중에서 Jedis는 제외하

ttl-blog.tistory.com

 

 

https://m.blog.naver.com/fbfbf1/222979268307

 

Redisson 이용 동시성 문제 해결

Redisson 락을 획득하지 못했을 때 Lettuce에 비해 재시도 횟수가 적어서 Redis에 부하가 적다. Rediss...

blog.naver.com

 

https://velog.io/@hgs-study/redisson-distributed-lock

 

Redisson 분산락을 이용한 동시성 제어

Redis 클라이언트인 Redisson 분산락(Distributed Lock)을 이용해서 동시성을 제어하는 포스팅을 진행해봤습니다 (예제 포함)

velog.io