Redis - 자료구조 및 캐시

June·2023년 4월 16일
0

Redis

목록 보기
2/4

자료구조 및 명령어

1. Strings

기본적으로 hash table을 사용해서 데이터를 저장하고 조회한다.

  • SET key value : 키(key)에 값(value)을 저장합니다.
  • GET key : 키(key)에 저장된 값을 반환합니다.
  • GETRANGE key start end : 키(key)에 저장된 값을 start와 end로 지정한 범위만큼 자릅니다.
  • INCR key : 키(key)에 저장된 값을 1 증가시킵니다.
  • DECR key : 키(key)에 저장된 값을 1 감소시킵니다.
  • APPEND key value : 기존 값 끝에 값을 추가합니다.
  • STRLEN key : 키(key)에 저장된 값의 길이를 반환합니다.
  • SETEX key seconds value : 키(key)에 값을 저장하고, seconds 후에 만료됩니다.
  • MSET key1 value1 key2 value2 ... : 여러 키(key)에 값을 동시에 저장합니다.
  • MGET ket1 ket2 ... : 여러 키 값들을 동시에 조회합니다.

set이나 get을 사용할때는 데이터의 개수를 적절히 조절하는 것이 좋다. 일반적으로 1000개 이하가 적절하고 많아도 몇십k까지 제한하는 것이 일반적이다.

MSET/MGET도 키를 50개 정도까지 제한하는게 좋다. 싱글스레드로 동작하는데 너무 많은걸 한번에 처리하면 다른 것들을 처리할 수 없다.

레디스 클러스터에서 MGET이나 MSET처럼 키를 여러 개 지정하는 명령어를 사용하려면 주의 사항이 있다. 레디스 클러스터에서는 여러 개의 마스터 노드가 있고, 각 마스터 노드는 일부 슬롯들을 담당하기 떄문에 MGET 명령어를 실행할 떄는 조회하려는 키들이 어느 마스터 노드에 저장되어 있는지 알아내야 한다. CLUSTER KEYSLOT 명령어를 사용하면 각 키가 어느 슬롯에 있는지 알 수 있다.

사실 cli에서 이걸 실행하기보다 서버 개발자면 스프링 코드로 보는게 훨씬 실용적으로 이해하는 방식 같다.

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

MyService

import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
import org.springframework.stereotype.Service

@Service
class MyService(
    private val redisTemplate: RedisTemplate<String, String>
) {
    init {
        redisTemplate.keySerializer = StringRedisSerializer()
        redisTemplate.valueSerializer = StringRedisSerializer()
        redisTemplate.afterPropertiesSet()
    }
    
    fun setString(key: String, value: String) {
        redisTemplate.opsForValue().set(key, value)
    }
    
    fun getString(key: String): String? {
        return redisTemplate.opsForValue().get(key)
    }
}

RedisTemplate을 이용해서 Strings를 저장/조회하고 있다. 참고로 여기서 ops는 operatinos를 뜻한다.

2. List

자료구조대로, 리스트는 중간에 추가/삭제가 느리다. Head와 Tail에 데이터를 추가 삭제할 때 유용하고, 데이터를 탐색할 때는 O(N)으로 비용이 비싸다.

그래서 100개의 아이템중 뭐보다 큰것만 찾아라 이런건 되게 느리다. 이런거 없이 첫번째것만 줘 이런 연산은 빠르다.

  • LPUSH key value [value ...] : 키(key)에 값을 왼쪽에서부터 추가합니다.
  • RPUSH key value [value ...] : 키(key)에 값을 오른쪽에서부터 추가합니다.
  • LPOP key : 키(key)에서 가장 왼쪽의 값을 가져옵니다.
  • RPOP key : 키(key)에서 가장 오른쪽의 값을 가져옵니다.
  • LLEN key : 키(key)에 저장된 리스트의 길이를 반환합니다.
  • LRANGE key start stop : 키(key)에서 start부터 stop까지의 값을 가져옵니다.
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
import org.springframework.stereotype.Service

@Service
class MyService(
    private val redisTemplate: RedisTemplate<String, String>
) {
    init {
        redisTemplate.keySerializer = StringRedisSerializer()
        redisTemplate.valueSerializer = StringRedisSerializer()
        redisTemplate.afterPropertiesSet()
    }
    
    fun pushToList(key: String, value: String) {
        redisTemplate.opsForList().rightPush(key, value)
    }
    
    fun popFromList(key: String): String? {
        return redisTemplate.opsForList().leftPop(key)
    }
    
    fun getListSize(key: String): Long {
        return redisTemplate.opsForList().size(key)
    }
    
    fun getListRange(key: String, start: Long, end: Long): List<String> {
        return redisTemplate.opsForList().range(key, start, end)
    }
}

코드를 보면 Strings와 거의 비슷한 것을 알 수 있다.

3. Set

Set은 당연히 유니크한 데이터들을 저장하는 자료구조다. 매일매일 도전과제 이벤트를 할때, 한 유저는 한번만 참여를 할 수 있어야했다. 이때 각 유저들이 참여 가능한지 validate를 하는데 매번 db에 exists 쿼리를 날리는 것은 부담이 될 것 같아서, 각 유저의 유니크한 id들을 set으로 저장하고 조회를 했다.

  • SADD key member [member ...] : 키(key)에 여러 개의 값을 추가합니다.
  • SREM key member [member ...] : 키(key)에서 여러 개의 값을 삭제합니다.
  • SMEMBERS key : 키(key)에 저장된 모든 값을 가져옵니다.
  • SISMEMBER key member : 키(key)에 member가 포함되어 있는지 여부를 확인합니다.

smember는 O(N) 명령어이므로 사용하면 안된다.

4. Sorted Set

레디스의 sorted set은 set과 유사하지만, 각 요소에 대해 score 값을 가지고 있는거다. 이 score는 실수다. 정수값을 사용할 수 없다는 것에 유의하자.

  • ZADD key score member [score member ...] : 키(key)에 여러 개의 값을 추가합니다. 각 요소에는 Score 값을 함께 지정합니다.
  • ZREM key member [member ...] : 키(key)에서 여러 개의 값을 삭제합니다.
  • ZRANGE key start stop [WITHSCORES] : 키(key)에서 start부터 stop까지의 값을 가져옵니다. WITHSCORES 옵션을 추가하면 Score 값도 함께 가져옵니다.
  • ZREVRANGE key start stop [WITHSCORES] : 키(key)에서 start부터 stop까지의 값을 역순으로 가져옵니다. WITHSCORES 옵션을 추가하면 Score 값도 함께 가져옵니다.
  • ZSCORE key member : 키(key)에서 member의 Score 값을 가져옵니다.

레디스의 sorted set은 스킵 리스트를 이용해서 구현되었고 탐색할 때 시간복잡도는 O(logn)이다.

MyService

import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
import org.springframework.stereotype.Service

@Service
class MyService(
    private val redisTemplate: RedisTemplate<String, String>
) {
    init {
        redisTemplate.keySerializer = StringRedisSerializer()
        redisTemplate.valueSerializer = StringRedisSerializer()
        redisTemplate.afterPropertiesSet()
    }
    
    fun addToSortedSet(key: String, value: String, score: Double) {
        redisTemplate.opsForZSet().add(key, value, score)
    }
    
    fun removeFromSortedSet(key: String, value: String) {
        redisTemplate.opsForZSet().remove(key, value)
    }
    
    fun getSortedSetRange(key: String, start: Long, end: Long): Set<String> {
        return redisTemplate.opsForZSet().range(key, start, end)
    }
    
    fun getSortedSetRangeByScore(key: String, minScore: Double, maxScore: Double): Set<String> {
        return redisTemplate.opsForZSet().rangeByScore(key, minScore, maxScore)
    }
    
    fun getSortedSetScore(key: String, value: String): Double? {
        return redisTemplate.opsForZSet().score(key, value)
    }
}

add 할 때 보면 score를 넣어주는 것을 볼 수 있다.

5. Hash

레디스 자체가 key-value 구조인데 value가 또 키가 되어 특정군의 데이터를 묶는 자료구조다.

  • HSET key field value: 지정된 key에 대해 field와 value를 추가합니다. key가 존재하지 않으면 새로 생성합니다.

  • HGET key field: 지정된 key에 대해 field에 대응하는 값을 반환합니다.

  • HMGET key field [field ...]: 지정된 key에 대해 지정된 field들에 대응하는 값을 반환합니다.

  • HMSET key field value [field value ...]: 지정된 key에 대해 여러 개의 field와 그에 대응하는 값을 한 번에 추가합니다.

  • HGETALL key: 지정된 key에 대해 모든 field와 그에 대응하는 값을 반환합니다.

Hgetall은 잘 쓰면 상관 없지만 나중에 장애의 원인이 되기도 한다. 컬렉션에 너무 많은 키를 넣지 않도록 주의해야 한다.

import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Service
import javax.annotation.Resource

@Service
class UserService {

    @Resource(name = "redisTemplate")
    lateinit var redisTemplate: RedisTemplate<String, Any>

    fun addUser(user: User) {
        redisTemplate.opsForHash<String, Any>().putAll(user.id.toString(), mapOf(
            "name" to user.name,
            "age" to user.age,
            "email" to user.email
        ))
    }

    fun getUser(id: Long): User? {
        val result = redisTemplate.opsForHash<String, Any>().entries(id.toString())
        return if (result.isNotEmpty()) {
            val name = result["name"] as String
            val age = result["age"] as Int
            val email = result["email"] as String
            User(id, name, age, email)
        } else {
            null
        }
    }

    fun updateUser(user: User) {
        redisTemplate.opsForHash<String, Any>().putAll(user.id.toString(), mapOf(
            "name" to user.name,
            "age" to user.age,
            "email" to user.email
        ))
    }

    fun deleteUser(id: Long) {
        redisTemplate.delete(id.toString())
    }

}

이외에도 다양한 명령어가 있으니 필요한 부분들은 찾아가면서 쓰자.

주의점

레디스는 싱글 스레드이기 때문에 하나의 명령이 긴 시간을 차지하면 결국 Redis 성능 저하로 이어진다.

Hgetall, hvals등 컬렉션의 데이터를 과도하게 데이터를 많이 가져오거나, 몇 MB 이상의 key나 value를 사용할 경우 문제가 발생할 수 있다.

또 Keys (서버의 모든 key 탐색 - O(n) 명령어)나 flushdb/flklushall 같이 큰 크기의 컬렉션을 지우는 문제 역시 성능을 떨어트린다. Keys는 scan으로 바꿀 수 있고, flushdb/flushall은 lazy를 줘서 할 수 있다. SCAN 명령은 key를 조금씩 나누어 탐색하며, Redis 서버의 부하를 분산 시킨다.

캐시

캐시 자체에 대한 설명은 생략한다. 실무를 하다보니 조회가 잦게 일어나는 곳이면 캐시를 왠만하면 적용해야 한다. 그렇지 않으면 DB에 부하가 가게되고, DBA 분들의 호출을 받게된다.

캐시 패턴

캐시 패턴은 캐시를 사용해서 데이터에 대한 접근 속도를 높이는 방식을 말한다. 대표적으로 두 가지 방식이 있다.

1. Look-Aside 패턴

캐시 hit 시 캐시에서 데이터를 가져오고, 캐시에 데이터가 없으면 DB에서 조회하고, 캐시에 저장 후 결과를 반환하는 패턴이다.

하지만 문제는 캐시에 데이터가 있는데 DB에 데이터가 변경된다면 캐시와 DB의 데이터 정합성이 깨지게 된다. 그래서 정합성이 중요하다면 ttl 자체를 짧게 설정하고, 나는 실무에서 데이터가 변경되는 경우 evict 를 시켜주고 있다.

예를 들어서 어떤 회원이 이벤트에 참여안했는지를 캐싱하고 있다면, 참여하는 곳에 evict 하는 코드를 추가해주는 방식이다.

캐싱하는 종류가 많아지게 되면 데이터가 변경될때 evict를 까먹지 않게 관리하는 비용이 커지기는 한다.

스프링의 @Cacheable 을 사용하면 look-aside 패턴을 이용하게 된다.

2. Write-back

캐시에 데이터를 쌓아놨다가 일정 주기마다 캐시에 있는 데이터를 DB에 반영하는 패턴이다.

DB 접근이 줄어들게 되므로 성능적으로는 확실히 유리할 수 있으나, 캐시 서버가 죽어버리면 데이터가 날라가는 위험이 존재한다.

참고

https://www.youtube.com/watch?v=mPB2CZiAkKM

0개의 댓글