[Redis] 분산락 적용으로 동시성 제어 처리 + 테스트(docker, springboot 1.5.8, JMeter)

Doyeon·2023년 11월 8일
1
post-thumbnail

개요

배경

기관별로 사용자에게 ID를 부여해서 등록하는 서비스가 있다. 이 때, 하나의 기관에서 동일한 ID를 여러 번 부여해서는 안된다.

위 사항을 충족하고자, 사용자를 등록하는 함수에서 1) 동일 기관 중복 ID 여부 체크, 2) 새로운 ID일 경우 등록(DB Insert) 하도록 로직을 만들었다.

문제

네트워크 상황이 불안정해서 요청이 두 번 이상 동시에 보내지거나, 여러 사용자가 동시에 같은 ID로 등록을 시도할 때, 동일 기관에 같은 ID로 DB에 사용자가 중복되어 등록되는 경우가 발생했다.

원인

동시성 제어가 제대로 되지 않아 발생한 문제다. 동일 기관 중복 ID 여부 체크 가 수행 완료되기 전에 사용자 등록 요청이 들어왔을 때, 아직 같은 ID로 DB에 등록되지 않았으니 새 ID가 등록이 되고, 이전 요청 또한 동일 기관 중복 ID 여부 체크 에서 걸리는 것이 없으니 DB Insert 가 진행되어 같은 ID가 두 번 등록되는 상황이 발생할 수 있다.

동시에 여러 요청이 와도 예상대로 로직이 잘 수행될 수 있도록 Redis 분산락을 적용해보자!

0. 개발환경

  • SpringBoot 1.5.8
  • docker redis
  • docker tomcat:9-jdk11

1. 도커 레디스 컨테이너 설치

docker에서 redis 이미지를 받고, redis용 network 생성 후, redis container를 생성하고 실행시킨다.

# redis 이미지 받기
docker pull redis

# network 생성
docker network create redis-network

# redis container 생성 및 실행
docker run --name redis_test \ # 컨테이너 이름
             -p 6379:6379 \ # port forwarding
             --network redis-network \ # network 연결
             -d redis redis-server \ # redis image 실행 & redis 서버 실행
						 --appendonly yes \ # AOF(Append-Only File) 활성화
						 --requirepass 123456789@ \ # password 설정

# 접속
docker exec -it redis_test redis-cli

2. SpringBoot 프로젝트 - Redis 의존성 추가 및 연결 설정

Redis 의존성을 추가하고 프로젝트를 실행시키는 단계에서 시간이 굉장히 오래 걸렸다. 예전에 진행했던 프로젝트와 동일하게 설정해주고 의존성을 추가해주었는데 프로젝트 실행이 자꾸 실패했다.

Lettuce 라이브러리로 Redis와 연결하고, Redisson 라이브러리의 RedissonClient를 사용해서 분산락을 적용하려고 했지만, 에러가 발생했다.

결론부터 말하자면, 현재 프로젝트의 스프링부트 버전이 1.5.8로 상당히 낮아서, 다른 프로젝트에서 설정한 의존성과 라이브러리를 지원하지 않기 때문에 버전에 맞는 의존성을 찾아 주입해야 한다.

스프링부트 2 이상 버전에는 Lettuce 라이브러리를 지원하고, Lettuce 라이브러리를 사용할 때, Redisson도 다른 설정 없이 바로 사용할 수 있다. 하지만, 스프링부트 1.5.8에서는 Lettuce 라이브러리를 지원하지 않으므로, Redisson에 대해 따로 설정(RedissonSpringDataConfig.java)해줘야 한다.

[참고] redisson-spring-data
redisson-spring-data github

Redis 의존성 추가

  • spring-boot-starter-data-redis
  • redisson-spring-data-18
<!-- Redis -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
	<version>1.5.9.RELEASE</version>
</dependency>

<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson-spring-data-18</artifactId>
	<version>3.15.0</version>
</dependency>

Redis 서버 + Redisson 사용을 위한 설정 추가

  • (properties 파일)
    • spring.redis.host, port, password 추가

    • host에는 docker가 올라간 서버의 IP를 넣는다.

      spring.redis.host=[도커 머신 IP]
      spring.redis.port=6397
      spring.redis.password=123456789@
  • (RedissonSpringDataConfig.java)
    • RedissonConnectionFactory 추가
      - SpringBoot 1.5.8 버전은 redisson-spring-boot-starter 사용 불가 → redisson-spring-data-18 사용
      - redisson-spring-data 사용 시, RedissonConnectionFactory 를 등록해주어야 한다.

      @Configuration
      public class RedissonSpringDataConfig {
      
          @Value("${spring.redis.host}")
          private String host;
      
          @Value("${spring.redis.port}")
          private int port;
      
          @Value("${spring.redis.password}")
          private String password;
      
          @Bean
          public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redisson) {
              return new RedissonConnectionFactory(redisson);
          }
      
          @Bean(destroyMethod = "shutdown")
          public RedissonClient redisson() throws IOException {
              Config config = new Config();
              config.useSingleServer()
                  .setAddress("redis://" + host + ":" + port)
                  .setPassword(password);
      
              return Redisson.create(config);
          }
      }

3. API에 분산락 적용

LockHandler.java

  • 분산락 매커니즘 적용
  • waitTime : lock 획득을 위한 대기 시간
  • leaseTime : lock을 갖고 있는 시간
@Component
public class LockHandler {

    @Autowired
    CervirayLog CLog;
    
    private final RedissonClient redissonClient;

    private static final String REDISSON_KEY_PREFIX = "RLOCK_";

    private static final long WAIT_TIME_MS = 2000L;
    private static final long LEASE_TIME_MS = 1000L;
    
    public LockHandler(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    public <T> T runOnLock(String key, Supplier<T> execute) {
        RLock lock = redissonClient.getLock(REDISSON_KEY_PREFIX + key);
        try {
            boolean available = lock.tryLock(WAIT_TIME_MS, LEASE_TIME_MS, TimeUnit.MILLISECONDS);
            if (!available) {
                CLog.d("API", "get lock fail " + key);
                return null;
            }
            CLog.d("API", "get lock success " + key);
            return execute.get();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}

TransactionHandler.java

  • 수행할 로직 부분을 트랜잭션으로 묶어 처리한다.
  • LockHandler의 ‘runOnLock’을 이용해서 수행될 로직을 execute로 넣을 때, 로직이 트랜잭션으로 묶이려면 TransactionHandler 를 이용해 프록시 호출을 가능하게 만들어 @Transactional이 동작하도록 해야 한다.
@Component
public class TransactionHandler {
    @Transactional
    public <T> T runOnWriteTransaction(Supplier<T> supplier) {
        return supplier.get();
    }
}

API 로직 수정

  • 기관_key 를 lock의 key로 설정 → 기관별 사용자 ID가 중복되면 안되기 때문
  • 기존 사용자 추가 API 로직을 transaction으로 묶은 채 lock을 획득하도록 처리한다.
public HashMap<String, Object> apiInsertUser(HashMap<String, Object> params) {
  return lockHandler.runOnLock(
      params.get("기관_key").toString(), 
      () -> transactionHandler.runOnWriteTransaction(
          () -> {
              // 기존 사용자 등록 로직
          })
      );      
}

4. 테스트

분산락이 제대로 적용되어 동시성 제어가 되는지 확인하기 위해 JMeter를 사용해 테스트를 진행했다.

JMeter의 기본 사용법은 아래 포스트에 올려두었다.

→ JMeter로 동시성제어 테스트하기

JMeter를 사용하여 동시에 여러 스레드로 요청을 보내고 결과 확인한다.

  • 동시성 제어 테스트
    • 동일 사용자가 50개의 스레드에서 사용자 등록 API 요청
      • 분산락 적용 전 : 동일 ID로 5개의 사용자 데이터 생성됨
      • 분산락 적용 후 : 동일 ID로 1개의 사용자만 생성됨
  • 성능 테스트

    • 30개의 스레드로 요청 보낸 후, JMeter Simple Report의 throughput(초당 요청 수) 확인

      • 기존(분산락 적용 전) : 29.8/sec
      • waitTime 2초, leaseTime 1초 : 28.3/sec
      • waitTime 5초, leaseTime 3초 : 11.6/sec

      → waitTime 2초, leaseTime 1초로 설정

profile
🔥

0개의 댓글