본문 바로가기

Spring boot

Spring Redis 분산 락 사용법

반응형

우선 Redis의 고급 기능 들을 지원해주는 Redisson 클라이언트 종속성을 build.gradle에 넣어준다 

당연한 얘기지만 로컬환경에 redis를 미리 다운 받아 놔야한다.

macOS에서는 brew 로 쉽게 설치가 가능하다.

 

build.gradle

dependecies{
 //...
  implementation 'org.springframework.boot:spring-boot-starter-data-redis'
  implementation 'org.redisson:redisson-spring-boot-starter:3.17.0'

}

 

 

나는 선착순 티켓팅에서 동시성 문제를 해결하기 위해 분산 락을 걸어줄 것이다!!

 

아래와 같이 작성한다.

 

package softeer.demo.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisCacheConfig  {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        return new LettuceConnectionFactory(host,port);
    }

    @Bean
    public RedisTemplate<String, Integer> redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<String,Integer> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));
        return template;
    }

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + host + ":" + port);
        return Redisson.create(config);
    }
}

${spring.redis.host}는 application.properties에 잘 넣어주자

 

난 localhost, 6379를 넣어줬ㄷ다

 

그 후 서비스 코드를 작성해준다.

 

package softeer.demo.service;


import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
@Transactional
public class RedisTicketService {

    private final RedisTemplate<String , Integer> redisTemplate;
    private final RedissonClient redissonClient;

    public void saveValue(String key,Integer value){
        redisTemplate.opsForValue().set(key, value);
    }

    public int findByKey(String key){
        Integer value =  redisTemplate.opsForValue().get(key);
        if(value == null){
            return 0;
        }
        return value;
    }

    public void decreaseValue(String key){
        Integer value = redisTemplate.opsForValue().get(key);
        System.out.println("잔여 티켓 : " + --value);
        redisTemplate.opsForValue().set(key, value);
    }

    public void decreaseValueWithLock(String key){

        // RLock 인스턴스를 생성합니다. key 값에 "_lock"을 붙여서 고유한 락 이름을 생성합니다.
        RLock lock = redissonClient.getLock(key + "_lock");

        try{
            // tryLock 메서드를 사용하여 락을 시도합니다. 10초 동안 락을 기다리고, 락을 획득한 후에는 5초 동안 락을 유지합니다.
            if(lock.tryLock(10,5, TimeUnit.SECONDS)){
                try{
                    Integer value = redisTemplate.opsForValue().get(key);
                    if(value != null & value > 0){
                        System.out.println("잔여 티켓 : " + --value);
                        redisTemplate.opsForValue().set(key,value);
                    }
                } finally{
                    // try 블록 내에서 작업이 끝나면 항상 락을 해제합니다.
                    lock.unlock();
                }
            } else{
                // 락을 획득하지 못했을 경우 메시지를 출력합니다.
                System.out.println("Could not acquire lock for key : " + key);
            }
        } catch (InterruptedException e){
            // 인터럽트 예외가 발생한 경우 현재 스레드의 인터럽트 상태를 설정합니다.
            // tryLock 메서드 호출 중 인터럽트가 발생할 수 있으므로 이를 처리하기 위한 catch 블록
            Thread.currentThread().interrupt();
        }
    }

    public void deleteAllKeysCurrentDatabase(){
        redisTemplate.execute((RedisCallback<Integer>) connection -> {
            connection.serverCommands().flushDb();
            return null;
        });
    }
}

 

나는 락을 걸지 않은 메소드와 락을 걸어놓은 메소드 두개 다 구현해두었다.

 

package softeer.demo.service;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.StopWatch;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static org.assertj.core.api.Assertions.assertThat;


@SpringBootTest
@Slf4j
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) //실제 데이터 베이스를 쓴다
class RedisTicketServiceTest {

    @Autowired
    private RedisTicketService redisTicketService;

    private String key = "ticket";


    @AfterEach
    void clear(){
        redisTicketService.deleteAllKeysCurrentDatabase();
    }

    @Test
    @DisplayName(value = "Redis에서 락 없이 티켓을 획득하는 테스트입니다")
    void getTicket(){

        //given
        int participant = 100;
        redisTicketService.saveValue(key, 100);

        //when
        ExecutorService executorService = Executors.newFixedThreadPool(2);//100명
        CountDownLatch latch = new CountDownLatch(participant);//100만큼 latch를 설정

        //when
        for(int i = 0 ; i < participant ; i++){
            executorService.submit(()->{
                try{
                    redisTicketService.decreaseValue(key);
                } catch (RuntimeException ex){
                    log.debug(ex.getMessage());
                } finally{
                    latch.countDown();
                }
            });
        }

        try{
            latch.await();//설정된 latch가 0이 될때 까지 다음코드로 안넘어간다
        } catch (InterruptedException ex){
            log.debug(ex.getMessage());
        }
        //then
        assertThat(redisTicketService.findByKey(key)).isEqualTo(0);
        executorService.shutdown();

    }

    @Test
    @DisplayName(value = "분산 락을 걸고 동시성 테스트입니다")
    void getTicketWithLock(){


        //given
        int participant = 100;
        redisTicketService.saveValue(key, 100);

        //when
        ExecutorService executorService = Executors.newFixedThreadPool(2);//100명
        CountDownLatch latch = new CountDownLatch(participant);//100만큼 latch를 설정

        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        //when
        for(int i = 0 ; i < participant ; i++){
            executorService.submit(()->{
                try{
                    redisTicketService.decreaseValueWithLock(key);
                } catch (RuntimeException ex){
                    log.debug(ex.getMessage());
                } finally{
                    latch.countDown();
                }
            });
        }

        try{
            latch.await();//설정된 latch가 0이 될때 까지 다음코드로 안넘어간다
        } catch (InterruptedException ex){
            log.debug(ex.getMessage());
        }
        
        stopWatch.stop();
        //then
        assertThat(redisTicketService.findByKey(key)).isEqualTo(0);
        System.out.println(stopWatch.prettyPrint());
        executorService.shutdown();

    }

}

 

위와 같이 테스트 코드를 작성해 두었다!!

 

결과는 아래와 같다!!

 

락 없이

 

 

분산 락과 함께

 

 

 

참고로 Redis가 아닌 MySQL에 비관적 락을 걸고 동시성을 해결 할 수 도 있는데 이때 Redis vs MySQL 뭐가 더 빠를지 속도 측정을 하기 위해 stopWatch를 사용해서 측정해 보았다.

 

아래는 MySQL에 락을 걸고 실행 했을 때의 결과이다

 

 

생각보다 크게 차이가 안났다. 현재는 티켓을 100개만 발급하는걸로 했는데 과연 10000개 일때는 어떨까?  참고로 둘 다 멀티스레드는 2개만 열어놨다.

 

 

 

 

Redis 분산 락 활용 티켓 10000개, 스레드 2개

 

 

 

 

 

 

 

MySQL 비관적 락 활용 티켓 10000개, 스레드 2개

 

 

 

 

 

 

Redis 분산 락 활용 티켓 10만개, 스레드 2개

 

 

 

 

 

 

MySQL 비관적 락 활용 티켓 10만개, 스레드 2개

 

 

생각보다 별로 차이 안나는데 테스트를 잘 못 돌린건가...?

반응형