동시성 제어 [3] Redis

최준호·2024년 5월 8일
1

동시성

목록 보기
3/3
post-thumbnail

🔴 분산 Lock

동시성 제어 [1] java 처리
동시성 제어 [2] DB Lock

에 이어 Redis를 사용한 동시성 제어 방법을 학습해보자.

분산된 서비스에서 사용할 수 있는 락 방법으로 DB를 사용하는 방법에 대해 알아봤었다. 하지만 DB의 경우 이미 insert와 select 등 다양한 처리를 진행하고 있기에 lock을 걸었을때 성능의 이슈와 DB 자체가 빠른 시스템이 아니라 처리 속도면에서도 좋은 결과를 보여주진 않는다.

그래서 우리가 선택할 수 있는 동시성 제어 방법으로 in-memory의 캐시를 사용하는 Redis를 통해 Lock을 처리할 수 있다.

🟠 Redis(lettuce) 설정

Redis를 사용하기 위해서는 Redis 서버를 생성해야되기 때문에 설정부터 시작해보자.

🟢 docker compose 작성

version: '3.7'
services:
  redis:
    container_name: redis
    image: redis
    ports:
      - "6379:6379"

docker compose up -d 로 서버를 실행하거나 자신이 redis를 로컬에 직접 설치하여 진행하겠다면 해당 방법으로 진행하면 된다. redis를 실행하기만 하면 된다.

🟢 redis 설정

🔵 gradle

implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")

🔵 yml

spring:
  data:
    redis:
      host: localhost
      port: 6379

🔵 config

@Configuration
@EnableRedisRepositories
@RequiredArgsConstructor
public class LettuceConfig {
    private final Environment env;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        String host = env.getProperty("spring.data.redis.host");
        String portAsString = env.getProperty("spring.data.redis.port");
        int port = Integer.parseInt(portAsString);
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());
        redisTemplate.setEnableDefaultSerializer(false);
        redisTemplate.setEnableTransactionSupport(true);
        return redisTemplate;
    }
}

🟢 Service

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LettuceService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final MemberRepository memberRepository;
    @Transactional
    public void post(RequestDto requestDto) throws InterruptedException {
        // redis lock
        String key = "lock:user:event";
        try {
            while (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(key, "lock", Duration.ofMillis(3_000)))) {
                Thread.sleep(10);
            }
            List<MemberEntity> all = memberRepository.findAll();
            // 선착순 30명
            if (all.size() >= 30) {
                return ;
            }
            memberRepository.save(requestDto.toModel());
        } finally {
            // redis unlock
            redisTemplate.delete(key);
        }
    }
}

setIfAbsent 메서드를 사용하면 해당 key가 있다면 false를 반환하며 해당 키값의 밸류를 수정하지 않는다. 하지만 만약 해당 키가 존재하지 않는다면 키값을 세팅하고 true를 반환한다.

redis에 해당 키가 있다면 쓰레드를 10ms 후에 다시 redis에 요청하는 방식이다. 이는 spin-lock 방식으로 키값을 획득할때까지 무한히 반복할수도 있어 주의해야 한다.

🟢 Test

@SpringBootTest
class LettuceServiceTest {
    @Autowired
    private LettuceService lettuceService;
    @Test
    public void testConcurrentReservation() throws InterruptedException {
        int totalThreads = 100;
        int perThread = 1;
        CountDownLatch latch = new CountDownLatch(totalThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);

        for (int i = 0; i < totalThreads; i++) {
            final int finalI = i;
            executorService.submit(() -> {
                try {
                    for (int j = 0; j < perThread; j++) {
                        RequestDto request = RequestDto.builder()
                                .number(finalI)
                                .build();
                        lettuceService.post(request);
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();
    }
}

🟢 결과

🟠 Redis(Redisson) 설정

lettuce의 장점은 Spring Boot에서 기본으로 제공해주는 redis connect libaray로 바로 사용할 수 있고 대부분 많이 사용하고 있을 것이다. 그렇기 때문에 실무에서는 가볍게 재획득이 필요 없는 결제와 같은 처리는 lettuce로 처리해도 되지만 선착순이 중요한 쿠폰이나 선착순 이벤트의 경우 재획득이 필요하므로 redisson을 사용하여 구현하는 경우가 많다.

🟢 redis 설정

🔵 gradle

implementation("org.redisson:redisson-spring-boot-starter:3.21.1")

🔵 yml

spring:
  data:
    redis:
      host: localhost
      port: 6379

🔵 config

@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "spring.data.redis")
public class RedissonConfig {
    private String host;
    private int port;
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress(String.format("redis://%s:%d", host, port));
        return Redisson.create(config);
    }
    
//    @Bean
    public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redissonClient) {
        return new RedissonConnectionFactory(redissonClient);
    }
}

여기서 주의할 점으로는 위에서 lettuce를 이미 설정해서 RedisConnectionFactory를 lettuce를 통해 생성해서 공유하여 사용하고 있어 따로 설정이 필요하지 않았다. 만약 필요하다면 @bean을 주석을 제거해서 사용해주면 된다.

🟢 Service

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RedissonService {
    private final RedissonClient redissonClient;
    private final MemberRepository memberRepository;

    @Transactional
    public void post(RequestDto requestDto) throws InterruptedException {
        // redis lock
        String key = "lock:user:event";
        RLock lock = redissonClient.getLock(key);

        try {
            boolean isLock = lock.tryLock(3, 3, TimeUnit.SECONDS);
            if (! isLock) {
                throw new RuntimeException("lock fail");
            }

            List<MemberEntity> all = memberRepository.findAll();
            // 선착순 30명
            if (all.size() >= 30) {
                return ;
            }
            memberRepository.save(requestDto.toModel());
        } finally {
            lock.unlock();
        }
        // redis unlock
    }
}

redisson을 통해 lock을 얻고 lock을 얻지 못하면 3초동안 대기한다.

lettuce와 다른 점으로는 spin-lock 방식이 아닌 pub-sub 방식으로 우리가 일반적으로 알고 있는 kafka와 비슷하게 메세지 브로커 역할을 수행하여 redis를 통한 채널을 통해 pub-sub 구조로 통신하기 때문에 굉장히 빠르다는 장점이 있다.

🟢 Test

@SpringBootTest
class RedissonServiceTest {
    @Autowired
    private RedissonService redissonService;
    @Test
    public void testConcurrentReservation() throws InterruptedException {
        int totalThreads = 100;
        int perThread = 1;
        CountDownLatch latch = new CountDownLatch(totalThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);

        for (int i = 0; i < totalThreads; i++) {
            final int finalI = i;
            executorService.submit(() -> {
                try {
                    for (int j = 0; j < perThread; j++) {
                        RequestDto request = RequestDto.builder()
                                .number(finalI)
                                .build();
                        redissonService.post(request);
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();
    }
}

🟢 결과

👏 마치며

이상 Spring에서 동시성 이슈를 처리하는 방법에 대해 알아보았다. 다양한 방법이 있고 환경에 따라 유리한 방법이 있으니 개발자로써 잘 판단하여 자신의 서버에서 동시성 이슈를 해결하기 바란다.

깃허브 소스

profile
해당 주소로 이전하였습니다. 감사합니다. https://ililil9482.tistory.com

0개의 댓글