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

Pir·2022년 4월 26일
35
post-thumbnail

동시성 문제를 해결하는 여러 방법이 있지만, 여기선 Redis 클라이언트인 Redisson 분산락을 이용해서 예제를 통한 동시성을 제어하는 포스팅을 진행하겠습니다.

1. 분산 서버 동시성(Concurrency) 제어

왜 필요한가

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

💡 해결 방안

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

2. 동시성 제어 전 문제점

100개의 땅콩(한정된 자원)을 100명의 사람이 2개씩 구매했을 경우

  • 분산락 걸지 않고 테스트
  • 200개의 땅콩을 원하면서, 100개의 땅콩이 솔드아웃(재고 0개)가 되길 바랐지만, 94개의 땅콩이 남아있었다.

테스트 실패

  • 100개의 땅콩 재고에 200개의 구매 요청이 와서 솔드아웃(재고 0개) 되길 예상했지만, 여러 요청이 한 번에 몰리면서 땅콩 수량을 업데이트하면서 100개였던 땅콩은 6개밖에 줄지 않았다.

3. Redisson 사용 이유?

Redis 클라이언트 중에 Redisson을 사용하면 좋은 이점을 공유합니다.

🔒 Lettuce의 스핀락

  • Lettuce에서도 락을 제공하고 있습니다. 하지만 Redisson의 락과는 성격이 다릅니다.
  • Lettuce의 락은 setnx메서드를 이용해 사용자가 직접 스핀락형태로 구성하게 됩니다. 락이 점유 시도를 실패했을 경우 계속 락 점유 시도를 하게 됩니다. 이로 인해 레디스는 계속 부하를 받게 되며, 응답시간이 지연됩니다.
  • 추가적으로, 만료시간을 제공하고 있지 않아서 락을 점유한 서버가 장애가 생기면 다른 서버들도 해당 락을 점유할 수 없는 상황이 연출됩니다.

🔒 Redisson의 분산락

Distributed locks are a very useful primitive in many environments where different processes must operate with shared resources in a mutually exclusive way.

  • 레디스 공식 홈페이지를 보면 분산락은 서로 다른 프로세스가 상호 배타적인 방식으로 공유 리소스로 작동해야 하는 많은 환경에서 매우 유용한 기본 요소라고 설명하고 있습니다.

자체 TTL 적용

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

4. Redisson 분산락 적용

의존성 설정

dependencies {
    implementation 'org.redisson:redisson-spring-boot-starter:3.16.3'
}

application.yml 설정

spring:
  redis:
    host: localhost
    port: 6379

redis:
  stock:
    prefix: stocks
  • 레디스 기본 설정 host와 port 설정
  • 키설정과 락이름을 설정하기 위한 재고(stock)의 prefix 설정

StockService.java

    public void decrease(final String key, final int count){
        final String lockName = key + ":lock";
        final RLock lock = redissonClient.getLock(lockName);
        final String worker = Thread.currentThread().getName();

        try {
            if(!lock.tryLock(1, 3, TimeUnit.SECONDS))
                return;

            final int stock = currentStock(key);
            if(stock <= EMPTY){
                log.info("[{}] 현재 남은 재고가 없습니다. ({}개)", worker, stock);
                return;
            }

            log.info("현재 진행중인 사람 : {} & 현재 남은 재고 : {}개", worker, stock);
            setStock(key, stock - count);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if(lock != null && lock.isLocked()) {
                lock.unlock();
            }
        }
    }
  • 주요 기능인 RLock의 tryLock 메서드를 살펴보면,
  • 파라미터로 들어오는 leaseTime 시간 동안 락을 점유하는 시도합니다.
  • 락을 사용할 수 있을 때까지 waitTime 시간까지 기다립니다.
  • leaseTime시간이 지나면 자동으로 락이 해제됩니다.
  • 정리하자면 선행 락 점유 스레드가 존재하면 waitTime동안 락 점유를 기다리며 leaseTime 시간 이후로는 자동으로 락이 해제되기 때문에 다른 스레드도 일정 시간이 지난 후 락을 점유할 수 있습니다.

테스트 목록

상품 재고 정보와 상품키 세팅

    @BeforeEach
    void 재고_키_세팅(){
        final String name = "peanut";
        final String keyId = "001";
        final int amount = 100;
        final Stock peenut = new Stock(name, keyId, amount);

        this.stockKey = stockService.keyResolver(peenut.getName(), peenut.getKeyId());
        this.peenut = peenut;
        stockService.setStock(this.stockKey, amount);
    }
  • 테스트 하기 전에 미리 땅콩 재고를 미리 100개로 설정합니다.

상품 수량 확인& 카운트만큼 재고 감소 확인

    @Test
    @Order(1)
    void 상품_수량_확인(){
        final int amount = this.peenut.getAmount();

        final int currentCount = stockService.currentStock(stockKey);

        assertEquals(amount, currentCount);
    }
    
    @Test
    @Order(2)
    void 상품_재고_카운트만큼_감소(){
        final int amount = this.peenut.getAmount();
        final int count = 2;

        stockService.decrease(this.stockKey, count);

        final int currentCount = stockService.currentStock(stockKey);
        assertEquals(amount - count, currentCount);
    }
  • 상품_수량_확인 테스트에서 땅콩이 100개가 들어가있음을 확인
  • 상품_재고_카운트만큼_감소 테스트에서 땅콩 100개에서 2개를 뺀 수량을 확인

(분산락 O) 땅콩 100개를 100명의 사람이 2개씩 구매

    @Test
    @Order(4)
    void 락O_땅콩_100개를_사람_100명이_2개씩_구매() throws InterruptedException {
        final int people = 100;
        final int count = 2;
        final int soldOut = 0;
        final CountDownLatch countDownLatch = new CountDownLatch(people);

        List<Thread> workers = Stream
                                .generate(() -> new Thread(new BuyWorker(this.stockKey, count, countDownLatch)))
                                .limit(people)
                                .collect(Collectors.toList());
        workers.forEach(Thread::start);
        countDownLatch.await();

        final int currentCount = stockService.currentStock(this.stockKey);
        assertEquals(soldOut, currentCount);
    }
    
    
    private class BuyWorker implements Runnable{
        private String stockKey;
        private int count;
        private CountDownLatch countDownLatch;

        public BuyWorker(String stockKey, int count, CountDownLatch countDownLatch) {
            this.stockKey = stockKey;
            this.count = count;
            this.countDownLatch = countDownLatch;
        }

        @Override
        public void run() {
            stockService.decrease(this.stockKey, count);
            countDownLatch.countDown();
        }
    }
  • 땅콩의 재고는 미리 100개로 세팅했고, 100명의 사람(쓰레드)을 생성하고 땅콩을 2개씩(count) 구매하도록 세팅했습니다.
  • 예상하는 결과는 땅콩 200개에 대한 구매 요청으로 인해, 100개였던 땅콩 재고는 0개가 되고 나머지 100명에겐 남아있는 재고가 없다고 메시지를 표출하는 것입니다.

✅ 테스트 통과

  • 땅콩 100개는 모두 소진돼서 남은 재고가 0개가 됐으며, 나머지 100명에겐 남은 재고가 없다는 메시지를 표출하였습니다.
  • 분산락으로 인해 컨커런시 세잎한 모습을 보이고 있으며, 땅콩 재고가 0개 이하로 내려가지 않는 모습을 볼 수 있습니다.

느낀 점

💡

요새 동시성에 대해 관심이 가던 도중, 분산 아키텍처 환경에서는 레디스로 동시성을 제어할 수 있다는 말을 듣고 '나도 한 번 구현해봐야겠다' 생각하고 레퍼런스를 찾아보고 바로 예제를 만들게 되었습니다.
하는 내내 재밌게 공부하고 다양한 레벨에서 동시성을 제어할 수 있구나 느꼈습니다.

회사에 당장 적용할 수는 없겠지만, 추후에 선착순 이벤트 등 공유 자원을 사용하는 곳에 의견을 내서 팀원들과 같이 동시성을 제어해볼 수 있을 것 같습니다.

GitHub Repository

출처

profile
흉내내는 사람이 아닌, 이해하는 사람이 되자

6개의 댓글

comment-user-thumbnail
2022년 4월 26일

디테일한 글 감사합니다. 많이 배우고 갑니다 :)

답글 달기
comment-user-thumbnail
2022년 4월 26일

좋은 글 잘 보았습니다. 나중에 이그나이트도 살짝 건들어 보시는것도 좋은 경험이 되실듯 합니다👏👍

답글 달기
comment-user-thumbnail
2022년 4월 27일

좋은 글 잘 보고 갑니다!

답글 달기
comment-user-thumbnail
2022년 7월 23일

좋은 글 감사합니다. 도움 많이 되었습니다!

답글 달기
comment-user-thumbnail
2022년 10월 23일

안녕하세요~! Redis 관련 자료가 시중에 많이 없는것 같은데 혹시 어디서 이렇게 자료를 참고하셔서 공부를 하시는지 궁금합니다 ^^

답글 달기
comment-user-thumbnail
2023년 11월 10일

동시성 주제로 이것저것 찾아보던 도중 너무 정리를 잘하셔서 댓글 남깁니다 !

답글 달기