동시성 제어하기 3 : Redis

Woody·2024년 9월 10일

TIL

목록 보기
13/19

Redis로 동시성 제어하기

Redis로 동시성을 제어하는 방식은 앞서 학습한 네임드 락 방식과 똑같이 특정 Key 값이 등록되어 있는지 확인하고, 잠금을 획득하는 방식이다. 이를 위해서는 원자성을 보장해야 한다.

원자성이란 더 이상 쪼개질 수 없는 성질이란 뜻으로 Redis의 명령어는 기본적으로 원자성을 보장한다. 하지만 여러 명령어를 실행해야 할 때는 원자성을 보장할 수 없는데, Redis는 이를 Lua Script와 Transaction을 통해서 원자성을 보장한다.

스프링에서 Redis를 사용하기 위해 아래와 같이 의존성을 추가하면 Lettuce 라이브러리 의존성이 추가된다.

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

Lettuce로 동시성 제어하기

Redis의 set nx 명령어를 통해서 잠금을 획득하는 연산의 원자성을 보장할 수 있다.

set nx 명령어는 key가 존재하는지 확인, 존재하지 않으면 값을 설정한다. ****

Lettuce에서 setIfAbsent 명령어를 통해서 set nx 명령어를 사용할 수 있다.

// SET key value NX
setIfAbsent(key, "lock")

// SET key value NX EX timeOut
// key 값이 없다면, time out 동안 key 값을 등록한다.
// time out이후에는 자동으로 key 값이 사라진다.
setIfAbsent(key, "lock", Duration.ofSeconds(timeOut))

구현

@Component
class LettuceLock(
    private val stringRedisTemplate: StringRedisTemplate
) {

    fun lock(key: String, timeOut: Long): Boolean {

        while (true) {
            val result = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, "lock", Duration.ofSeconds(timeOut)) ?: return false
            if (result) return result
        }
    }

    fun unLock(key: String): Boolean {
        return stringRedisTemplate.delete(key)
    }
}

잠금을 획득하기 위해서는 지속적으로 잠금을 요청해야 하기 때문에 스핀락 방식을 통해 구현한다.

스핀락이란 ‘임계 영역에 진입이 불가능할 때, 진입이 가능할 때까지 루프를 돌면서 재시도하는 방식’이다.

하지만 이는 Redis에 지속적으로 요청을 보내기 때문에 Redis에 부하를 준다.

이를 해결하기 위해서 Redis의 라이브러리로 Redisson을 사용할 수 있다.

Redisson 라이브러리로 대체하기

Redisson은 Redis 라이브러리로 Redis의 자료구조인 String, Hash, List, Sorted set 등을 모두 지원하고, 동시에 Lock 과 같은 다양한 구현체를 쉽게 사용할 수 있어 다른 Redis 라이브러리 보다 러닝 커브가 낮다.

Redisson은 스핀락 방식이 아닌 Pub/Sub 방식을 통해서 잠금을 획득하기 때문에 Redis에 부하를 적게 준다. 잠금이 사용 중일 때 해당 잠금을 구독하고, 잠금을 반납할 때 구독하고 있는 스레드들에게 잠금을 사용해도 된다는 것을 알린다.

또한, 아래와 같이 루아 스크립트를 통해 원자성을 보장하면서 잠금을 획득하는 것을 볼 수 있다.

// RedissonLock.java
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    return this.evalWriteSyncedAsync(
		    this.getRawName(), 
		    LongCodec.INSTANCE, 
		    command, 
        "if (redis.call('exists', KEYS[1]) == 0) then " +
            "redis.call('hset', KEYS[1], ARGV[2], 1); " +
            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
            "return nil; " +
        "end; " +
        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
            "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
            "return nil; " +
        "end; " +
        "return redis.call('pttl', KEYS[1]);",
        Collections.singletonList(this.getRawName()), 
        new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)}
		);
}

구현

@Component
class RedissonLock(
    private val redissonClient: RedissonClient
) {
    fun lock(key: String, timeOut: Long): Boolean {
        val lock = redissonClient.getLock(key)
        val result = lock.tryLock(timeOut, 5, TimeUnit.SECONDS)
        return result
    }

    fun unLock(key: String): Boolean {
        val lock = redissonClient.getLock(key)
        lock.unlock()
        return true
    }
}
  • tryLock을 통해서 잠금을 획득할 수 있다. 매개변수로 다음 3가지를 넣는다.
    1. 몇 초 동안 락을 대기할 것인지
    2. 잠금을 획득하고, 몇 초 뒤에 자동으로 잠금을 반납할 것인지
    3. 시간 단위는 무엇인지

마무리

지금까지 Redis를 이용한 분산 락 구현 방식에 대해 학습했다. 이전에 학습한 네임드 락 방식보다 확실히 쉽고, 커넥션 풀을 따로 관리 안해도 된다는 점에서 편했다. 하지만 커넥션 풀 관리 대신 이젠 Redis를 관리해야 하는 인프라 비용이 추가된다는 단점이 있다.

이제 동시성 문제를 해결할 때, 앞서 학습한 내용들의 장단점을 잘 파악해서 현재 상황에 맞는 트레이드 오프를 할 수 있도록 노력할 것이다.

참고

Redis - SET

대규모 처리 시 Redis 연산의 Atomic을 보장하기

스핀락 - 위키 백과

8. Distributed locks and synchronizers

레디스와 분산 락(1/2) - 레디스를 활용한 분산 락과 안전하고 빠른 락의 구현

0개의 댓글