Database/Redis

레디스를 활용한 분산 락(Distrubuted Lock) feat lettuce, redisson

ysk(0soo) 2023. 8. 27. 21:01

레디스를 활용한 분산 락(Distrubuted Lock)

분산 락이란(Distributed Lock)

분산 락은 분산 환경에서 여러대의 서버와 여러 DB간의 동시성을 관리하는 데 사용됩니다.

일반적으로 분산환경이 아닌 DB 등과 같은 곳에서는 비관적 락 등을 이용하여 동시성을 제어할 수 있지만,

여러 대의 DB가 존재하는 분산 DB 환경에서는 동시성 문제를 해결할 수 없습니다.

분산 DB에서 비관적 락으로 해결할 수 없는 이유

  • 성능 저하: 분산 환경에서의 비관적 락은 네트워크 지연, 노드 간의 통신 오버헤드 등으로 인해 더 큰 성능 저하를 초래할 수 있다
  • 데드락 문제
  • 네트워크 파티션 문제
    • 두 노드 사이의 네트워크 연결이 끊긴다.
    • 한 노드에서 데이터의 락을 설정했지만, 연결이 끊어진 노드에서는 이 락의 정보를 알 수 없다.
    • 결과적으로 두 노드에서 동시에 동일한 데이터를 변경할 수 있으며, 이는 데이터 불일치를 초래할 수 있다.
  • 데이터 복제본과 일관성의 문제:
    • 한 노드에서 데이터를 업데이트하고 락을 해제한 후, 변경 사항을 다른 노드에 복제한다.
    • 복제하는 동안, 다른 사용자가 이전 버전의 데이터를 다른 노드에서 읽을 수 있다.
    • A 노드에서 데이터 X에 대한 락을 설정했다고 하면, B 노드에서는 그 정보를 모르기 때문에 B 노드의 사용자가 동일한 데이터 X에 접근 가능하기 때문이다.

분산 데이터베이스에서는 여러 노드가 데이터의 복제본을 가지고 있기 때문에, 한 노드에서의 락이 다른 노드의 데이터 접근에 어떤 영향을 미칠지 예측하기 어려운 문제가 있습니다.

 

분산 락은 여러 방법으로 구현될 수 있습니다.

  1. MySQL의 네임드락: MySQL에서 락이름을 명시하여 관리할 수 있는 네임드락으로 구현할 수 있습니다.
  2. ZooKeeper: Apache ZooKeeper는 분산 시스템을 위한 일관된 서비스를 제공하는 오픈 소스 프로젝트로, 분산 락 구현에 자주 사용됩니다.
  3. etcd 또는 Consul: 이러한 도구는 분산 설정 관리와 서비스 발견에 사용되며, 분산 락을 구현하기 위한 원자적 연산을 제공합니다.
  4. 레디스 (Redis): 레디스는 SETNX (set if not exists) 명령어와 같은 원자적 연산을 사용하여 분산 락을 구현하는 데 사용될 수 있습니다.

분산 락은, 반드시 분산환경에서만 사용할 수 있는 것은 아닙니다.

락의 종류

낙관적 락, 비관적 락에 대한 설명은 생략하고 락의 개념, 스핀락, 네임드락에 대해서만 정리합니다.

락 (Lock)

락은 공유 자원에 대한 동시 접근을 제어하는 메커니즘입니다. 락을 사용하면 한 번에 하나의 스레드만 해당 자원에 접근하거나 변경할 수 있습니다.

프로그램에서 동시에 실행되는 여러 작업을 조율하고, 데이터의 일관성 위해 동시성을 제어해야 합니다.

락을 획득한다는 것은 자원을 사용해도 된다는 의미이며, 다른 프로세스는 현재 락을 획득한 프로세스가 잠금을 건 자원에 대해 수정 등에 대해 접근할 수 없음을 의미합니다.

  • 비관적 락은 읽기조차 불가능 합니다

스핀락 (Spin Lock)

멀티스레딩 환경에서 공유 자원에 대한 동시 접근을 방지하기 위한 락(Lock) 중 하나.

다른 락과는 다르게, 락을 획득할때까지 계속해서 락 획득을 시도하고 조건을 확인하면서 대기하는 기법입니다.

이런 방식때문에 스핀이라는 이름이 붙었습니다.

스핀락의 동작 방식

1. A스레드가 락을 획득하려고 시도
2. 락이 이미 다른 스레드가 획득했다면 A스레드가 반복적으로 요청하면서 락 획득 시도
3. 락이 해제되면 다음으로 먼저 요청한 스레드중 하나가 랜덤으로 락을 획득  

때문에 락을 얻을때가지 계속 요청을 보내며 대기하므로 서버에 많은 부하를 줍니다.

스핀락의 장점:

  • 컨텍스트 스위치(Context Switch) 발생하지 않음: 스핀락을 대기하는 동안 스레드는 활성 상태로 유지되기 때문에 컨텍스트 스위치가 발생하지 않습니다. 따라서 짧은 시간 동안 락을 획득하려는 경우 스핀락이 효율적일 수 있습니다.

스핀락의 단점:

  • CPU 시간 낭비: 락이 해제되길 기다리며 CPU 시간을 계속 소비합니다. 따라서 락이 오랜 시간 동안 보유될 것으로 예상되는 경우에는 스핀락이 비효율적일 수 있습니다.
  • 우선순위 역전(Priority Inversion) 문제 발생 가능: 높은 우선순위의 스레드가 낮은 우선순위의 스레드로 인해 블록되는 현상을 발생시킬 수 있습니다.

스핀락은 해당 락을 대기하는 동안 스레드를 유휴 상태로 만들지 않고 계속 실행 상태로 둡니다.

따라서 짧은 시간 락 대기에는 효율적일 수 있지만, 긴 시간 대기에는 다른 락 메커니즘이 더 적합할 수 있습니다.

MySQL의 네임드락

MySQL의 Named Lock은 주어진 이름의 락을 사용하여 여러 세션 사이에서 동기화를 수행할 수 있는 기능입니다.

  • GET_LOCK(), RELEASE_LOCK(), IS_USED_LOCK(), IS_FREE_LOCK()와 같은 함수를 통해 락을 관리할 수 있습니다.

특정 이름으로 락의 이름을 지정할 수 있어 애플리케이션에서 명시적으로 동시성 제어 관리가 가능합니다,

여러 애플리케이션에서 동시성을 관리하기에 더 편할 수 있죠.

로우나 테이블에 락을 걸지 않고 메모리를 이용하여 락을 관리하므로 비관적 락보다 시스템 전반의 처리량이 증가할 수 있습니다.

또한 동시성을 위해 Zookeeper, Redis 등의 추가 인프라 관리가 필요하지 않아 비용과 관리포인트를 아낄 수 있다는 장점이 있습니다

네임드락 단점

불필요한 부하

  • 락에 대한 정보가 DB에 저장되고, 락을 획득하고 제거하는 쿼리가 매번 발생하여 DB에 불필요한 부하를 줄 수 있습니다.
  • 그러나 대부분의 상황에서는 미미한 편입니다.

제한적이다.

  1. MySQL에서만 사용가능하다는 단점
  2. JPA에서는 nativeQuery를 사용해야 함.

데드락 위험

  • Named Locks와 테이블 레벨 락, 행 레벨 락을 혼용하여 사용할 경우 데드락 상황이 발생할 수 있습니다.

커넥션이 종료되면 잠금이 해제되는 문제 - 우아한형제들 기술블로그, MySQL 공식 문서에 적힌 내용

  • Named Locks는 세션 범위를 가지며, 다른 세션에서는 해당 락의 상태를 변경할 수 없기 때문

커넥션풀이 부족

  • (참고) 주의할 점은, Named Lock을 활용할 때 데이터소스를 분리하지않고 하나로 사용하게되면 커넥션풀이 부족해질 수 있습니다.

Named Lock은 한 MySQL 서버 인스턴스에서만 유효합니다. 따라서, 여러 서버 인스턴스가 있는 분산 환경에서는 한 서버에서 설정된 Named Lock이 다른 서버에는 적용되지 않으므로 분산 시스템에는 적합하지 않을 수도 있습니다.

이러한 문제점들로 인해, 분산 시스템에서는 주로 분산 락 전용 솔루션(예: Apache ZooKeeper, etcd, Redis의 RedLock 등)을 사용하여 관리하는 것이 좋을 수 있습니다.

Redis를 이용한 분산락

Redis를 이용한 분산락 구현 방법

Spring에서 사용할 수 있는 Redis Client로는 Jedis, Lettuce, Redisson 등이 있습니다.

테스트 시나리오

시나리오

분산 환경에서 유저가 gather에 가입을 한다.
이때 gather에는 인원 제한이 있다.
100명의 유저가 5명의 인원 제한이 있는 gather에 가입 요청을 하였을 때, 5명만 가입되어야 한다

유저 엔티티

// user
@Entity
@Table(name = "users")
class User(

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long?,

    val name: String,

    @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
    @JoinColumn(name = "group_id")
    var gather: Gather?

) {

    fun join(gather: Gather) {
        this.gather = gather
    }

}

게더 엔티티

@Entity
@Table(name = "gathers")
class Gather(

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long?,

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "gather", cascade = [CascadeType.ALL])
    val members: MutableList<User>,

    var currentMemberCount: Int,

    val limitsCount: Int,

    ) {

    constructor(limitsCount: Int, user: User) : this(null, mutableListOf(user), 1, limitsCount)

    fun join(user: User) {
        require(currentMemberCount < limitsCount) { "가입 불가" }

        if (!this.members.contains(user)) {
            this.members.add(user)
            user.join(this)

            this.currentMemberCount += 1
            println("currentMemberCount : $currentMemberCount")
        }
    }

}

가입 비지니스 로직

@Service
class GatherService(
    private val gatherRepository: GatherRepository,
    private val userRepository: UserRepository,

) {

    @Transactional
    fun join(groupId: Long, userId: Long) {
        val gather = gatherRepository.findById(groupId).orElseThrow()
        val user = userRepository.findById(userId).orElseThrow()

        gather.join(user)
    }

}

Lettuce를 이용한 분산 락 구현

Lettuce는 공식적으로 분산락을 제공하지 않기 때문에 직접 구현해서 사용해야 합니다.

환경 : 코틀린, spring boot 3.1.3

의존성 추가

plugins {
    id("org.springframework.boot") version "3.1.3"
    id("io.spring.dependency-management") version "1.1.3"
    kotlin("jvm") version "1.8.22"
    kotlin("plugin.spring") version "1.8.22"
    kotlin("plugin.jpa") version "1.8.22"
    kotlin("plugin.allopen") version "1.8.22"
}

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

Lettuce 클라이언트 설정

@ConfigurationProperties(prefix = "spring.data.redis")
class RedisProperties @ConstructorBinding constructor(
    val host: String,
    val port: Int,
    val password: String,
) {
}
@Configuration
class LettuceConfig(
    private val redisProperties: RedisProperties
) {
    // TCP 통신
    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory {
        val redisStandaloneConfiguration = RedisStandaloneConfiguration(redisProperties.host, redisProperties.port)

        redisStandaloneConfiguration.password = RedisPassword.of(redisProperties.password)

        return LettuceConnectionFactory(redisStandaloneConfiguration)
    }

    // 커넥션 위에서 조작 가능한 메소드 제공
    // 공식 문서에서는 <String, String>으로 되어 있다
    @Bean
    fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<Any, Any> {
        val redisTemplate = RedisTemplate<Any, Any>()

        redisTemplate.apply {
            keySerializer = StringRedisSerializer()
            valueSerializer = GenericJackson2JsonRedisSerializer() // JSON 포맷으로 저장
        }

        redisTemplate.connectionFactory = connectionFactory

        return redisTemplate
    }

    // 문자열에 특화한 메소드 제공. 위에서 선언한 redisTemplate만으로도 사용가능하다
    @Bean
    fun stringRedisTemplate(redisConnectionFactory: RedisConnectionFactory): StringRedisTemplate {
        val redisTemplate = StringRedisTemplate()

        redisTemplate.apply {
            connectionFactory = redisConnectionFactory
            keySerializer = StringRedisSerializer()
            valueSerializer = StringRedisSerializer()
        }

        return redisTemplate
    }

}
  • application. yml
spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: 1234

redisTemplate 를 활용해서 락을 관리하는 LockManager를 구현

  • 예제에서는 Repository라는 이름를 사용했습니다.
@Component
class RedisRepository(
    private val redisTemplate: RedisTemplate<String, String>
) {

    // setIfAbsent() 를 활용해서 SETNX를 실행
    fun lock(key: String, timeoutMills: Long): Boolean {
        return redisTemplate
            .opsForValue()
            .setIfAbsent(key, "lock", Duration.ofMillis(timeoutMills)) ?: true
    }

    fun unlock(key: String): Boolean {
        return redisTemplate.delete(key)
    }

}
  • key값과 timeout을 받아 Lock을 반환합니다.

비즈니스 로직에 Lock 구현

  • 퍼사드나 유즈케이스 같은 서비스들을 이용하는 계층을 두고 호출합니다.
@Service
class GroupLettuceService(
    private val redisRepository: RedisRepository,
    private val gatherService: GatherService,
    ) {

    fun join(groupId: Long, userId: Long) {
        val key = LOCK_PREFIX + groupId.toString()

        while (!redisRepository.lock(key, 3000)) {
            Thread.sleep(100)
        }

        try {
            gatherService.join(groupId, userId)
        } finally {
            redisRepository.unlock(key)
        }
    }

    companion object {
        private const val LOCK_PREFIX = "LOCK:"
    }

}
  • 락을 획득할때까지 계속 재시도 (spin) 해야합니다.

테스트

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
internal class GatherLettuceServiceTest {
        ...생략

    @DisplayName("그룹 가입- lettuce 스핀 lock")
    @Test
    fun join() {
        // given
        val threadCount = 100
        val executorService = Executors.newFixedThreadPool(threadCount)
        val countDownLatch = CountDownLatch(threadCount)

        val limit = 5
        val user = User.create("그룹장")
        val gather = Gather(limit, user)

        gatherRepository.save(gather)
        val createUsers = createUser(100) // 100명 유저 생성 

        // when
        IntStream.range(0, threadCount)
            .forEach {
                executorService.submit {
                    try {
                        gatherLettuceService.join(gather.id!!, createUsers[it].id!!)
                    } catch (ex: InterruptedException) {
                        throw RuntimeException(ex)
                    } finally {
                        countDownLatch.countDown()
                    }
                }

            }

        countDownLatch.await()
        executorService.shutdown()

        // then
        val findGroup = gatherRepository.findById(gather.id!!).get()

        println("### findGroup.count=${findGroup.currentMemberCount}")

        Assertions.assertThat(findGroup.currentMemberCount).isEqualTo(limit)
    }

}

테스트는 통과하였습니다.

 

그러나 Lettuce를 이용한 문제점은 위에서 이야기 했던 스핀락의 문제점을 갖고 있습니다.

락을 획득하지 못한 경우 락을 획득하기 위해 redis에 계속해서 while로 요청을 보내야 합니다.

또한 스레드도 계속 일을 하는 상태가 되며 redis에 부하가 생길 수 있단 단점이 있습니다.

  • 부하를 낮추기 위해 락 획득을 재시도 시간을 길게 설정하게 되면, 락을 획득할 수 있음에도 불구하고 무조건 설정된 시간만큼 기다려야 하는 비효율적인 경우가 발생할 수 있습니다

이 문제를 Redisson에서는 다르게 해결할 수 있습니다.

Redisson을 이용한 분산 락 구현

Redisson은 Lettuce,Jedis와는 달리 RLock 이라는 Lock 전용 객체를 제공합니다.

Redisson은 Lock에 타임아웃을 명시하여 무한정 대기상태로 빠질 수 있는 위험이 없습니다.

또한 스핀락(Spin Lock)을 사용하지 않고 pub sub 기능을 사용합니다

  • 락이 해제되면 락을 subscribe(구독)하는 클라이언트들에게 채널로 락이 해제되었다는 신호를 보내게 됩니다.
  • 그렇기에 락을 subscribe하는 클라이언트들은 더 이상 락을 획득해도 되냐고 redis로 요청을 보내지 않고 해제를 공지받아 락을 시도합니다. 따라서, 별도의 retry 로직이 필요없습니다.

redis에서 채널을 사용해보고 싶으시다면 redis-cli를 열어 다음과 같이 사용하면됩니다.

(Session 1) $ docker exec -it 6c7c0a47dd34 redis-cli
(Session 2) $ docker exec -it 6c7c0a47dd34 redis-cli

(Session 1) $ subscribe ch1
// Reading messages... (press Ctrl-C to quit)
// 1) "subscribe"
// 2) "ch1"
// 3) (integer) 1

(Session 2) $ publish ch1 hello
// (integer) 1

(Session 1) $
// 1) "message"
// 2) "ch1"
// 3) "hello"

주의

  • leaseTime을 잘못 잡으면 작업 도중 Lock이 해제될 수도 있습니다. 이를 IllegalMonitorStateException 이라고 합니다

구현

의존성 추가

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

RedissonClient 빈 등록

@Configuration
class RedissonConfig(
    private val redisProperties: RedisProperties,
) {

    @Bean
    fun redissonClient(): RedissonClient {
        val config = Config()
        val codec: Codec = StringCodec() // redis-cli에서 보기 위함

        config.codec = codec

        config.useSingleServer().apply {
            //https로 접근시에는 rediss://로 접근해야 한다.
            address = "$REDISSON_HOST_PREFIX${redisProperties.host}:${redisProperties.port}"
            password = redisProperties.password
            isSslEnableEndpointIdentification = false
            timeout = 3000 // 주입받아 설정도 가능 
        }

        return Redisson.create(config)
    }

    companion object {
        const val REDISSON_LOCK_PREFIX = "LOCK:"
        const val REDISSON_HOST_PREFIX = "redis://"
    }

}

비즈니스 로직

@Service
class GatherRedissonService(
    private val gatherService: GatherService,
    private val redissonClient: RedissonClient,
) {

    fun join(groupId: Long, userId: Long) {
        val key = LOCK_PREFIX + groupId.toString()
        val lock: RLock = redissonClient.getLock(key)

        try {
            // 락 획득. (락 획득을 대기할 타임아웃, 락이 만료되는 시간)
            val isAvailable = lock.tryLock(5, 3, TimeUnit.SECONDS)

            if (!isAvailable) {
                log.info("redisson getLock fail.")
                return
            }

            gatherService.join(groupId, userId)

        } finally {
            // 락 해제
            lock.unlock()
        }
    }

    companion object {
        private val log: Logger = logger()
        private const val LOCK_PREFIX = "LOCK:"
    }

}

Redisson을 통한 락에는 RLock 인터페이스 객체가 사용됩니다.

  • 락 획득 실패 시 false를 반환, 락 획득 시 true를 반환하는데 unlock을 하지 않고 leaseTime 만큼 잠금을 획득하는 방식
  • redisson의 경우 leaseTime 설정을 통하여 만료를 지정할 수 있으므로, 락을 해제 안해주더라도 시간이 지나면 락이 해제가 됩니다. 때문에 프로세스에서 해당 락을 획득하기 위해 무한정 대기해야 하는 상황이 발생하지 않게 됩니다
public interface RedissonClient {
  ...
    /**
     * 이름으로 Lock 인스턴스를 반환합니다.
         * non-fair 락킹을 구현하므로 스레드에 의한 획득 순서를 보장하지 않습니다.
         *  장애 복구 동안의 신뢰성을 높이기 위해, 모든 연산은 모든 Redis 슬레이브로의 전파를 기다립니다.
     * @param name - Lock 객체 이름
     * @return Lock 객체
     */
    RLock getLock(String name);
}
  • 비공정(non-fair) 락킹은 스레드가 잠금(lock)을 요청하는 순서와 관계없이 잠금을 획득할 수 있다는 것을 의미합니다. 즉, 스레드가 잠금을 요청하는 순서대로 잠금을 획득하지 않는다는 것이죠.
    • 이와 대조적으로 공정(fair) 락킹은 스레드가 잠금을 요청하는 순서대로 잠금을 획득합니다. 따라서 첫 번째로 잠금을 요청한 스레드가 첫 번째로 잠금을 획득하게 됩니다.
    • non-fair 락킹은 스레드가 잠금(lock)을 요청하는 순서와 관계없이 잠금을 획득할 수 있다는 것을 의미합니다. 즉, 스레드가 잠금을 요청하는 순서대로 잠금을 획득하지 않는다는 것이죠.
    • non-fair은 일반적으로 faire 락보다 성능이 좋을 수 있습니다. 그 이유는 공정성을 유지하기 위한 추가적인 비용이 없기 때문입니다.
    • 하지만 non-fair 락의 경우, 특정 스레드가 잠금을 획득하는데 긴 시간이 걸릴 수 있거나 기아 상태(starvation)에 빠질 위험이 있습니다.
public interface RLock {
  ...
  /**
  * 정의된 <code>leaseTime</code>으로 잠금을 획득하려고 시도합니다.
  * 필요한 경우 잠금이 사용 가능해질 때까지 정의된 <code>waitTime</code>까지 기다립니다.
  * 정의된 <code>leaseTime</code> 간격 후에 잠금은 자동으로 해제됩니다.
  * 
  * @param waitTime 잠금을 획득하기 위한 최대 시간
  * @param leaseTime 점유(타임아웃) 시간
  * @param unit 시간 단위
  * @return 잠금이 성공적으로 획득된 경우 true, 그렇지 않고 잠금이 이미 설정된 경우 false.
  * @throws InterruptedException 스레드가 중단된 경우
  */
  boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

  ...
}

테스트도 마찬가지로 통과합니다.

Redisson tryLock()이 락을 pub/sub 방식 으로 획득하는 과정

  • getMultiLock()이나 getSpinLock()을 사용하지 않을 경우, RedissonLock 구현체를 사용한다

  • 현재 쓰레드의 ID로 구독하고 있다.
public class RedissonLock {

  String getChannelName() {
    return prefixName("redisson_lock__channel", getRawName());  
  }
  ...

  protected CompletableFuture<RedissonLockEntry> subscribe(long threadId) {  
    return pubSub.subscribe(getEntryName(), getChannelName());
  }

}

 

구독시 내부적으로 PublishSubscribeService을 호출하여 세마포어를 가지고 옵니다.

이후 tryAcquire()를 호출해서 락 획득에 성공하면 true를 반환합니다.

  • 시간을 계산하여 지정한 waitTime이나 LeaseTime이 지난다면 false를 반환합니다.
  • 이후 락 획득에 성공해서 true를 반환하든, 실패해서 false를 반환하든 구독을 해지합니다.

정리하자면,

  1. tryLock(long waitTime, long leaseTime, TimeUnit unit) 메서드는 락을 획득하려 할 때, 최대 waitTime 시간 동안 대기하게 됩니다. leaseTime은 락의 최대 유지 시간입니다.
  2. 처음에 tryAcquire()을 이용하여 락을 획득하려 시도하고, 락이 성공적으로 획득되면 true를 반환합니다.
  3. 락 획득에 실패한 경우, ttl이 남아있고, 대기시간(waitTime)이 남아있다면 현재 스레드는 락이 사용 가능해질 때까지 알림을 받기 위해 특정 채널을 구독합니다. 이 구독 과정도 일정 시간 내에 완료되어야 합니다.
  4. 구독한 후, 현재 스레드는 다시 락 획득을 시도합니다. 이 때, 락 획득에 성공하면 true를 반환하며, 실패하면 락이 풀릴 때까지 대기합니다.
  5. 락이 아직 사용 가능하지 않다면, 현재 스레드는 락이 사용가다는 메시지가 도착할 때까지 대기합니다.
  6. 다시 ttl 내로 락을 획득하지 못하면 false를 반환합니다
  7. 성공적으로 락을 획득했든, 시간 초과로 실패했든 구독을 해지합니다.

 

이렇게 Redisson과 Lettuce의 분산락 구현 방식, 차이를 알아보았습니다.

Lock 획득이 실패하고 재시도가 반드시 필요하지 않은 경우에는 Lettuce를 사용하고,

재시도가 반드시 필요한 경우에는 Redisson을 활용하면 좋습니다.

또한, 해당 락을 사용하는 코드들을 보면

getLock(); // 락 획득

try {
  businessLogic()
} finally {
  releaseLock() // 락 해제  
}

와 같이 락 획득 - 비지니스 로직 - 락 반납이 반복되는데요

이부분은 AOP 등으로 해결할 수 있습니다.

또한, 다음 Redisson의 Wiki를 보면 더 다양한 상황에 따른 Redisson Lock을 사용할 수 있습니다.

참조