티스토리 뷰

개요

  • Redis는 바이너리 데이터 저장에 최적화된 인메모리 Key-Value 스토어로 RDBMS를 제외한 데이터 저장소 중 가장 유명하고 널리 쓰이고 있다. 캐시 저장소로 Redis를 적절히 활용하면 API 응답 시간을 10ms 수준으로 낮출 수 있다.

목표

  • Spring Boot 기반으로 Redis 저장소에 대한 CRUD를 수행할 수 있다.
  • Java 오브젝트를 바이너리 변환된 JSONSmile 형식으로 Redis 저장소에 저장할 수 있다.

docker-compose.yml

  • 아래는 로컬 환경에서의 Redis 테스트를 위해 프로젝트 루트에 docker-compose.yml을 작성한 예이다.
version: '3'
services:
  redis:
    image: "public.ecr.aws/ubuntu/redis:latest"
    environment:
      - ALLOW_EMPTY_PASSWORD=yes
      - TZ=UTC
    ports:
      - "6379:6379"
    restart: always
  • 작성이 완료되면 아래와 같이 Redis 인스턴스를 기동할 수 있다.
$ docker-compose up -d
  • 운영체제 또는 프로젝트 환경 변수에 아래 내용을 추가한다.
SPRING_REDIS_HOST=localhost
SPRING_REDIS_PORT=6379
SPRING_REDIS_MODE=STANDALONE

라이브러리 종속성 추가

  • 프로젝트 루트의 build.gradle.kts에 아래 내용을 추가한다.
dependencies {
    implementation("org.springframework.data:spring-data-redis:3.0.0")
    implementation("io.lettuce:lettuce-core:6.2.2.RELEASE")
    implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.13.4")
}
  • jackson-dataformat-smile 아티팩트는 Smile 형식을 지원하기 위한 Jackson의 확장 라이브러리이다. SmileJSON 문자열의 내용은 그대로 유지하면서 바이너리 형식에 최적화한 것으로 Redis와 같은 바이너리 저장소에 특화된 형식이다. 저장소의 메모리 공간을 절약할 수 있다.

@Configuration 클래스 작성

import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.MapperFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.dataformat.smile.databind.SmileMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import io.lettuce.core.ClientOptions
import io.lettuce.core.SocketOptions
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisClusterConfiguration
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
import java.time.Duration

@Configuration
class RedisConfig(

    @Value("\${spring.redis.host}")
    private val REDIS_HOST: String,

    @Value("\${spring.redis.port}")
    private val REDIS_PORT: Int,

    @Value("\${spring.redis.mode}")
    private val REDIS_MODE: String
) {
    @Bean("lettuceConnectionFactory")
    fun lettuceConnectionFactory(): LettuceConnectionFactory {

        if (REDIS_MODE == "STANDALONE") {
            return LettuceConnectionFactory(RedisStandaloneConfiguration(REDIS_HOST, REDIS_PORT))
        }

        val clusterConfiguration = RedisClusterConfiguration().apply {
            clusterNode(REDIS_HOST, REDIS_PORT)
        }

        val clientConfiguration = LettuceClientConfiguration.builder()
            .clientOptions(
                ClientOptions.builder()
                    .socketOptions(
                        SocketOptions.builder()
                            .connectTimeout(Duration.ofSeconds(10)).build()
                    )
                    .build()
            )
            .commandTimeout(Duration.ofSeconds(10)).build()

        return LettuceConnectionFactory(clusterConfiguration, clientConfiguration)
    }

    @Bean("stringRedisTemplate")
    fun stringRedisTemplate(
        @Qualifier("lettuceConnectionFactory") lettuceConnectionFactory: LettuceConnectionFactory
    ): StringRedisTemplate {

        return StringRedisTemplate(lettuceConnectionFactory)
    }

    @Bean("objectRedisTemplate")
    fun objectRedisTemplate(
        @Qualifier("lettuceConnectionFactory") lettuceConnectionFactory: LettuceConnectionFactory
    ): RedisTemplate<String, ByteArray> {

        val template = RedisTemplate<String, ByteArray>().apply {
            keySerializer = StringRedisSerializer()
            isEnableDefaultSerializer = false
        }
        template.setConnectionFactory(lettuceConnectionFactory)

        return template
    }

    @Bean("redisObjectMapper")
    fun redisObjectMapper(): ObjectMapper {

        return SmileMapper().registerModules(JavaTimeModule())
            .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
            .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            .setSerializationInclusion(JsonInclude.Include.ALWAYS)
            .disable(MapperFeature.USE_ANNOTATIONS)
    }
}
  • Redis는 기본적으로 Key, Value를 바이트의 배열로 저장한다. StringRedisTemplate를 사용하면 Key, Value 모두 문자열로 저장할 수 있다.
  • RedisTemplateRedis 저장소에 오브젝트를 저장할 때 기본값으로 정의된 JdkSerializationRedisSerializer을 이용한다. 따라서 해당 오브젝트는 반드시 java.io.Serializable 인터페이스를 구현해야 한다. 이 방식의 문제점은 다른 언어 환경에서 Redis 저장소에 접근할 경우 값을 인식하지 못한다는 것이다. 또한 오브젝트의 클래스 메타 정보를 저장하다보니 크기 또한 커진다. 특정 언어에 종속시키지 않으려면 저장되는 값으로 JSON 문자열 또는 Smile 형식을 고려해야 한다. 이를 위해 redisObjectMapperobjectRedisTemplate 빈을 작성했다.

CacheType Enum 클래스 작성

  • Redis에 저장할 대상 오브젝트의 정보를 아래와 같이 작성한다.
import com.fasterxml.jackson.annotation.JsonFormat
import java.time.Duration
import kotlin.reflect.KClass

@JsonFormat(shape = JsonFormat.Shape.STRING)
enum class CacheType(
    val targetClass: KClass<*>,
    val key: String,
    val duration: Duration
) {
    FOO(FooDTO::class, "/FOOS/{id}", Duration.ofHours(1)),
    BAR(BarDTO::class, "/BARS/{id}", Duration.ofHours(1));

    fun toKey(id: String): String {

        return this.key.replace("{id}", id)
    }
}

@Repository 클래스 작성

  • 아래는 임의의 Foo 오브젝트를 Smile 형식으로 Redis 저장소에 저장하고 불러오는 예제이다.
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Repository

@Repository
class CacheRepository(
    private val objectRedisTemplate: RedisTemplate<String, ByteArray>,
    private val redisObjectMapper: ObjectMapper
) {
    fun save(id: String?, target: Any?, type: CacheType) {

        id ?: return
        target ?: return

        objectRedisTemplate
            .opsForValue()
            .set(
                type.toKey(id),
                redisObjectMapper.writeValueAsBytes(target),
                type.duration
            )
    }

    fun fetchById(id: String, type: CacheType): Any? {

        val value = objectRedisTemplate
            .opsForValue()
            .get(type.toKey(id)) ?: return null

        return redisObjectMapper.readValue(value, type.targetClass.java)
    }

    fun deleteById(id: String?, type: CacheType) {

        id ?: return

        objectRedisTemplate
            .delete(type.toKey(id))
    }

    fun deleteByIds(ids: List<String>, type: CacheType) {

        if (ids.isEmpty()) return

        objectRedisTemplate
            .delete(ids.map { type.toKey(it) })}
    }
}
  • save()을 통해 앞서 정의된 redisObjectMapper 빈을 이용하여 POJO 오브젝트를 플랫폼 중립적인 순수한 Smile 형식으로 저장한다. JSON 문자열 대비 2/3 절약된 크기로 오브젝트를 저장할 수 있으며 어떤 언어에서도 해당 값에 접근하고 해석할 수 있다.
  • 마찬가지로 fetchById()을 통해 저장된 Smile 형식의 데이터를 POJO 오브젝트로 변환하여 불러온다.

Redis 캐시 사용 예

  • 앞서 작성된 CacheRepository 빈을 통해 아래와 같이 특정 캐시에 대한 CRUD를 실행할 수 있다.
// FOO 캐시 저장
cacheRepository.save("{fooId}", foo, CacheType.FOO)

// FOO 캐시 조회
cacheRepository.fetchById("{fooId}", CacheType.FOO)?.let { return it as FooDTO }

// FOO 캐시 삭제
cacheRepository.deleteById("{fooId}", CacheType.FOO)

일치하는 패턴의 Key 이름 조회

  • Redis는 초고속의 Key-Value 저장소로서 조회 및 검색 관점에서 RDBMS와 비교하면 제공하는 기능이 매우 단순하고 제한적이다.
  • 특히 특정 패턴과 일치하는 Key 이름을 조회하는 것은 성능 문제로 운영 환경에서 가장 지양해야 하지만 불가피하게 필요할 때가 있다.
  • 가장 먼저 아래는 StringRedisTemplate을 이용하여 KEYS 명령을 수행하는 예제이다. USER_ID:로 시작하는 Key 이름을 한 번에 조회한다. KEYS 명령이 실행되면 응답이 완료되기 까지 다른 요청은 모두 블록되기 때문에 운영 환경에서 절대 사용을 자제해야할 안티 패턴으로 취급된다.
// KEYS "USER_ID:*"
Set<String> keys = stringRedisTemplate.keys("USER_ID:*");
Iterator<String> it = redisKeys.iterator();
while (it.hasNext()) {
    System.out.println(it.next()); // 조회된 Key의 이름을 출력
}
  • 아래는 SCAN 명령을 수행하여 위와 동일한 결과를 출력하는 예제이다. SCAN 명령은 커서를 기반으로 부분적으로 데이터를 조회하기 때문에 KEYS보다는 운영 환경에 미치는 부담이 상대적으로 적다.
// SCAN 0 MATCH "USER_ID:*" COUNT 10
RedisConnection redisConnection = null;
try {
    redisConnection = stringRedisTemplate.getConnectionFactory().getConnection();
    ScanOptions options = ScanOptions.scanOptions().match("USER_ID:*").count(10).build();
    Cursor<byte[]> cursor = redisConnection.scan(options);
    while (cursor.hasNext()) {
        System.out.println(new String(cursor.next())); // 조회된 Key의 이름을 출력
    }
} finally {
    redisConnection.close();
}

트러블슈팅: Could not get a resource from the pool

[Message] Cannot get Jedis connection; nested exception is redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
[Exception] org.springframework.data.redis.RedisConnectionFailureException
[Root Exception] java.net.ConnectException
  • 2가지 경우에 발생한다. 첫째, Redis 인스턴스가 어떠한 문제로 중단되어 연결이 불가능한 경우이다. 둘째, 설정된 최대 동시 접속 허용 클라이언트 수(maxclients)를 초과할 경우 발생한다. 해결책은 후자의 경우 서버와 클라이언트 모두 적절한 클라이언트 수 제한이 필요하다.
  • 서버 측인 Redis 인스턴스에서는 아래와 같이 설정할 수 있다.
$ nano /etc/redis.conf
maxmemory 1gb // 메모리 한계치, 기본값은 0으로 무제한
maxmemory-policy volatile-lru // 한계치 도달시 데이터 삭제 정책 설정, 기본값은 volatile-lru로 만기 시점을 가지는 키에 한하여 읽기 동작이 가장 오래 전에 발생한 순서로 제거
  • 클라이언트에서는 아래와 같이 설정할 수 있다.
JedisPoolConfig poolConfig = new JedisPoolConfig();

// 커넥션 풀의 최대 생성 연결 값을 설정한다. (기본값은 8)
poolConfig.setMaxTotal(1000);

// 커넥션 풀이 가득 찼을 경우 준비된 연결이 도착하기를 기다린다.
poolConfig.setBlockWhenExhausted(true);

// 커넥션 풀이 가득 찼을 경우 새로운 연결을 기다리지 않고 NoSuchElementException 예외를 발생시킨다.
poolConfig.setBlockWhenExhausted(false);

JedisConnectionFactory factory = new JedisConnectionFactory();
...
factory.setUsePool(true);
factory.setPoolConfig(poolConfig);

트러블슈팅: OOM command not allowed when used memory > ‘maxmemory’.

[Message] JedisDataException: OOM command not allowed when used memory > 'maxmemory'.
[Exception] org.springframework.dao.InvalidDataAccessApiUsageException
[Root Exception] redis.clients.jedis.exceptions.JedisDataException
  • Redis 인스턴스에 대한 모든 쓰기 동작 실행시 발생하는 오류이다. Redis 인스턴스에 저장된 데이터 양이 설정된 메모리 한계치(maxmemory)에 도달하여 더이상 쓰기 행위가 불가능하기 때문에 발생한다.
  • 서버 측인 Redis 인스턴스에서 아래와 같이 변경할 수 있다.
$ nano /etc/redis.conf maxmemory 1gb // 메모리 한계치, 기본값은 0으로 무제한 maxmemory-policy volatile-lru // 한계치 도달시 데이터 삭제 정책 설정, 기본값은 volatile-lru로 만기 시점을 가지는 키에 한하여 읽기 동작이 가장 오래 전에 발생한 순서로 제거 
  • 해당 오류를 예방하는 가장 효과적인 조치는 maxmemory 값을 충분히 늘리는 것이다. 2차 조치는 maxmemory-policy를 수정하는 것이다. noeviction은 한계치 도달시 어떠한 행위도 하지 않기 때문에 위 오류가 발생한다. allkeys-lru는 읽기 동작이 가장 오래 전에 발생한 키를 삭제하여 저장 공간을 확보하므로 위 오류가 발생할 확률이 줄어든다. [관련 링크]

트러블슈팅: LOADING Redis is loading the dataset in memory

[Message] JedisDataException: LOADING Redis is loading the dataset in memory
[Exception] org.springframework.dao.InvalidDataAccessApiUsageException
[Root Exception] redis.clients.jedis.exceptions.JedisDataException
  • Redis 인스턴스 재시작 시점에 모든 읽기 동작 실행시 발생하는 오류이다. Redis 인스턴스가 기동되면서 RDB/AOF 파일로부터 데이터를 로딩하는 중이라 요청 수행이 불가능할 때 발생한다. 시간이 지나면 응답이 가능하므로 엄밀히 말하면 오류는 아니다.
  • Redis 저장소는 RDB/AOF 파일을 이용하여 메모리 상의 데이터를 디스크에 보관하여 인스턴스를 재시작하더라도 영구적으로 저장된 데이터를 유지할 수 있다. 만약 완전한 비휘발성 저장소로 사용하고자 한다면 아래와 같이 설정할 수 있다.
$ nano /etc/redis.conf
appendonly no
#save 900 1
#save 300 10
#save 60 10000
save ""

Redis 저장소 상태 확인 및 초기화

  • INFO 명령은 현재 Redis 저장소의 상태를 확인할 수 있는 유용한 정보를 제공한다. 짧은 시간 주기로 INFO 명령의 응답 결과를 로그로 남기면 사전 장애 예방 및 처리에 많은 도움을 받을 수 있다. 유명한 로그 기반 모니터링 도구들은 모두 이러한 방식을 사용한다. [관련 링크]
> INFO
connected_clients:11 # 현재 접속 중인 클라이언트 개수
used_memory:2893651360 # 사용 메모리 bytes
maxmemory:4294967296 # 사용 가능한 최대 메모리 bytes
total_connections_received:87
total_commands_processed:456025 # 처리한 명령 개수
expired_keys:96 # 만료되어 삭제된 키 개수
evicted_keys:0 # maxmemory 제한으로 삭제된 키 개수
keyspace_hits:91979 # 성공한 키 조회 개수
keyspace_misses:28 # 실패한 키 조회 개수
db1:keys=4745407,expires=4745407,avg_ttl=872524771 # keys=전체 키 수
  • CONFIG RESETSTAT 명령은 INFO STATS에 노출되는 정보들을 초기화해준다. 짧은 시간 주기로 로그를 남기면서 매번 초기화해주면 특정 주기에 대한 유의미한 통계 로그를 획득할 수 있다.
> CONFIG RESETSTAT
OK 
  • 명령 실행시 ERR unknown command ‘CONFIG’ 오류가 발생한다면 해당 계정에 권한이 없는 것이다. /etc/redis.conf 파일의 아래 부분을 주석 처리 해야 한다.
# rename-command CONFIG "" 

참고 글

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함