본문 바로가기
Back-End/강의

[재고시스템으로 알아보는 동시성이슈 해결방법] 3. Redis Distributed Lock

by 7533ymh 2022. 8. 30.

동시성 이슈 해결방법

  • 이번 시간에는 Redis Distributed Lock을 활용한 해결방법을 알아보자.

해결법3. Redis

Lettue

  • setnex 명령어를 활용한 분산락
    • setnex
      • 데이터베이스에 동일한 key가 없을 경우에만 저장
      • 반환값 : 1, 0
        • 1 : 성공
        • 0 : 실패
  • spin lock 방식으로 실패시 처리로직을 구현해야한다.
    • 다른 스레드가 lock을 소유하고 있다면 그 lock이 반환될 때까지 계속 확인하며 기다리는 것

Redission

  • pub-sub 기반으로 Lock 구현 제공
  • 실패에 따른 처리 로직을 구현할 필요가 없다.

pub-sub인데 채널은 어떻게 정해지지?

  • tryLock으로 락을 획득할 때 subscribe가 진행된다.




채널 이름은 Redis의 정보에서 뭔가 가져와서 하는 것 같은 데 정확히는 모르겠다. 좀 더 찾아봐야 할듯 !


해결법3 - 적용

1. Lettue

  • 먼저 간단하게 redis-cli로 setnx을 사용해보자.
  • 처음 setnx를 통해 키가 1이고 값이 lock인 값을 저장하려고 하면 키가 1인 값이 없기 때문에 저장하고 반환값으로 1을 받게 된다.
  • 하지만 그 다음 다시 setnx를 통해 저장하려고 하면 이미 해당 키로 저장이 되어있기 때문에 반환값으로 0을 받으며 저장을 할 수 없게 된다.
  • 이러한 방식을 통해 lock을 사용하며 MySQL의 Named Lock와 유사한 점이 있다.
    • 차이점이라면 Session을 신경쓰지 않아도 되는 것이다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
@Component
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {![[Untitled 11.png]]
        this.redisTemplate = redisTemplate;
    }

    public Boolean lock(Long key) {
        return redisTemplate.opsForValue()
            .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

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

    private String generateKey(Long key) {
        return key.toString();
    }
}
  • 강의에서는 RedisTemplate를 사용했는 데 StringRedisTemplate를 사용해도 무방하다.
    • 또한, localhost:6379 라면 따로 Redis설정을 하지 않아도 Spring에서 자동적으로 잡게 된다.
  • Lettuce 방식도 실패 로직에 대해 구현을 해줘야 하기 때문에 Facade 패턴을 적용한다.
@Component
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
        this.redisLockRepository = redisLockRepository;
        this.stockService = stockService;
    }

    public void decrease(Long id, Long quantity) throws InterruptedException {
        while (!redisLockRepository.lock(id)) {
            Thread.sleep(100); // 부하를 줄이기 위한 텀
        }

        try {
            stockService.decrease(id, quantity);
        } finally {
            redisLockRepository.unlock(id);
        }
    }
}
  • lock을 획득하는 과정에서 Redis에게 부하가 있을 수 있으니 100ms 이후에 다시 요청하도록 텀을 준다.

  • 이후 테스트를 해보면 통과하는 것을 확인할 수 있다.
  • Lettuce를 사용하면 구현이 간단하다는 장점이 있지만 spin lock방식이므로 Redis에 부하를 줄 수 있다.
    • 그렇기 때문에 lock 획득 재시도 간에 텀을 주도록 하였다.

2. Redisson

Lettuce와 같은 자바 레디스 클라이언트이며 Netty를 사용해서, 비동기 논블록킹 I/O를 제공하는데, 특이하게도 레디스의 명령어를 직접 제공하지 않고 Lock과 같은 특정한 구현체의 형태를 제공한다.

  • 먼저 간단하게 redis-cli로 pub-sub을 사용해보자.
  • subscribe 채널명 명령어로 구독을 하고 다른 쪽에서 publish 채널명 메시지 명령어로 메시지를 보내면 구독을 한 곳에서 메시지를 받는 것을 확인할 수 있다.
  • redisson은 자신이 점유하던 lock을 해제할 시 채널에 메시지를 보내줌으로써 락을 획득해야하는 스레드들에게 락을 획득하라고 전달해주게 된다.
  • 그러면 lock을 획득해야하는 스레드들은 메시지를 받을 때 락 획득을 시도한다.
  • Lettuce는 lock획득을 계속 시도하는 반면 Redisson은 락 해제가 되었을 때나 그외 몇번만 시도하기 때문에 Redis에 부하를 줄여주게 된다.
implementation 'org.redisson:redisson-spring-boot-starter:3.17.5'
@Component
public class RedissonLockStockFacade {

    private static final Logger log = LoggerFactory.getLogger(RedissonLockStockFacade.class);

    private final RedissonClient redissonClient;
    private final StockService stockService;

    public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
        this.redissonClient = redissonClient;
        this.stockService = stockService;
    }

    public void decrease(Long key, Long quantity) {
        RLock lock = redissonClient.getLock(key.toString());

        try {
            boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);

            if (!available) {
                log.info("락 획득 실패");
                return;
            }

            stockService.decrease(key, quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
  • Redisson은 별도의 Repository가 필요없고 RedissonClient를 사용하면 된다.
  • Lua Script를 사용해서 자체 TTL를 적용하는 것을 확인할 수 있다.
    • hincrby는 해당 필드가 없으면 increment값을 설정한다.
    • perpire는 지정된 시간 후 key를 자동 삭제하게 된다.
      • 여기서는 lock을 삭제할 때 사용하는 것 같다.
  • tryLock(long waitTime**,** long leaseTime**,** TimeUnit unit)
    • 락을 사용할 수 있을 때 까지 waitTime 시간까지 대기
    • leaseTime 시간 동안 락을 점유하는 시도
    • leaseTime 시간이 지나면 자동으로 락이 해제
    • 즉, 선행 락 점유 스레드가 존재한다면 waitTime동안 락 점유를 기다리며 leaseTime 시간 이후로는 자동으로 락이 해제되기 때문에 다른 스레드도 일정 시간이 지난 후 락을 점유할 수 있다.

단, 장점

  • pub-sub 기반으로 redis의 부하를 줄여준다는 장점이 있지만 Lettuce에 비해서 별도의 라이브러리 사용과 구현이 복잡하다는 점이 있다.

정리

  • Lettuce
    • 구현이 간단하며 spring-data-redis를 이용하면 Lettuce가 기본이기 때문에 별도의 라이브러리를 추가하지 않아도 된다.
    • 하지만 spin lock 방식이기 때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis에 부하가 갈 수 있다.
  • Redisson
    • 락 획득 재시도를 기본으로 제공한다.
    • pub-sub 방식으로 구현이 되어있기 때문에 Lettuce와 비교했을 때 Redis에 부하가 덜 간다.
    • 하지만 별도의 라이브러리 추가가 필요하며 lock을 라이브러리 차원에서 제공하는 것이기 때문에 사용법을 공부해야 한다.
  • 실무에선?
    • 재시도가 필요하지 않는 lock은 Lettuce
    • 재시도가 필요한 경우에는 redisson

MySQL과 Redis 간단 비교

  • MySQL
    • 이미 데이터베이스로 MySQL를 사용하고 있다면 별도의 비용없이 사용이 가능하다.
    • 어느정도의 트래픽까지는 문제없이 활용이 가능하다.
    • Redis보다는 성능이 좋지 않다.
  • Redis
    • 활용중인 Redis가 없다면 별도로 구축해야하기 때문에 인프라 관리비용이 발생한다.
    • MySQL보다 성능이 좋다.

마무리

  • 동시성을 처리하는 방법을 총 3가지 배웠다.
    • Application 단
    • Database 단
    • Redis 단
  • 각각 마다 장,단점이 달라 상황에 맞는 방황을 사용하면 될 것 같다.
  • 이전부터 동시성은 어떻게 제어할 지 궁금하고 속으로 끙끙앍고 있었는 데 이런 강의를 만나게 되어서 너무 기쁘다.
  • 하지만 대략적인 개념들만 파악했기에 깊이 있게 공부해봐야겠다.
    • 프로젝트에서도 Redis를 사용했기 때문에 Redis도 정리해봐야 겠다.

Reference

댓글