[Spring Boot] Cache - Redis (Remote Dictionary Server)

by 민갤

Back End /

Open Source (BSD licensed)

Dictionary(key-value)구조로 된 비정형 데이터를 메모리에 저장하고 관리하는 In-Memory 기반 비관계형 데이터베이스 관리 시스템.

인 메모리(In-Memory) 솔루션 또는 NoSQL(Not Only SQL)로 분류되기도 한다.

쿼리 연산을 지원하지 않는다.

특징

메모리 기반

  • 디스크 기반보다 읽기와 쓰기가 매우 빠르다.
  • 메모리 파편화가 발생할 수 있다.
  • 메모리가 낭비로 인해 프로세스가 죽을 수 있다.
  • 쓰기 연산이 copy on wirte 방식으로 동작하기 때문에 최대 메모리를 2배까지 사용하기도 한다.
  • 메모리에 여유가 있어야 한다.
  • 메모리 기반이지만 Disk에 데이터를 저장할 수 있어 영구적인 데이터 보존이 가능하다.

인 메모리 데이터 구조

  • Lists, Sets, Hashes, Sorted Sets과 같은 다양한 자료 구조를 지원한다. (링크)
  • 서버 장애 시 데이터 유실이 발생할 수 있다.
  • 영속성 보장 기능으로 복구할 수 있다.

단일 스레드(Single Threaded)

  • 한 번에 한 작업만 처리하기 때문에 처리 시간이 긴 작업은 피해야한다.
  • Master-Slave 구조로 된 복제(Replication) 기능을 사용하여 방지할 수 있다.
  • 동시성은 있으나(Atomic 보장) 병렬성은 없다.
  • 쓰기 작업이 많아지면 샤딩을, 읽기 작업이 많아지면 복제 기능을 사용해야 한다.

데이터를 여러 서버에서 공유할 수 있다.

원자성(Atomic)을 보장하는 기능을 제공한다. (Redis Transactions)

  • SessionCallback
  • Redisson의 @Transactional

영속성(Persistent)을 보장하는 기능을 제공한다.

  • RDB (snapshotting) 방식: 특정 시점마다 데이터를 디스크에 저장
  • 메모리 데이터를 스냅샷을 떠서 .rdb 확장자 파일로 저장한다.
  • 바이너리 파일이기 때문에 파일을 직접 수정하거나 읽을 수 없다.
  • 스냅샷을 읽어서 특정 시점으로 복구할 수 있다. (AOF 대비 속도 빠름)
  • 스냅샷을 추출하는 데 시간이 오래 걸리고 도중에 서버가 꺼지면 데이터가 모두 사라진다.
  • AOF (Append only file) 방식: 데이터가 변경될 때마다 디스크에 저장
  • 조회를 제외한 모든 연산을 log 파일에 저장한다.
  • log 파일 용량이 크고, 복구 시 모든 연산을 재실행하기 때문에 속도가 느리다.
  • rewirte 기능으로 특정 시점을 최종 데이터로 다시 써서 용량을 줄일 수 있다.

Redis Mode

Stand Alone: Redis를 단일 인스턴스로 운영

Sentinel

  • non-clustered 고가용성
  • Master Node와 Slave Node를 모니터링하고 관리하는 서버
  • 모니터링(Monitoring): Redis 인스턴스들이 정상 동작하는 지 지속적으로 감시한다.
  • 자동 장애 조치(Automatic Failover): 장애가 발생하면 Slave를 Master로 승격시킨다.
  • Sentinal 프로세스를 홀수로 구성
  • 동작
  • 지속적으로 서버 상태를 확인한다.
  • Sentinal 프로세스 하나가 Master 인스턴스가 응답이 없다고 알린다. (Subjective Down)
  • 나머지 Sentinal 프로세스 중 과반수 이상이 응답에 실패하면 장애라고 판단한다 (Objective Down)
  • Failover를 진행한다.
  • 다른 Slave들이 새로운 Master를 사용하도록 재구성한다.
  • 알림(Notification): failover 가 발생하면 관리자에게 알림할 수 있다.

Cluster

  • 데이터셋을 여러 Node에 자동으로 분산 저장한다. (확장성)
  • 일부 Node가 종료되어도 정상 동작한다. (고가용성)
  • 데이터 샤딩: Master Node에 Slave Node를 최소 1대 이상 연동하여 Mesh topology 를 구성한다.
  • Redis 클라이언트는 하나의 endpoint에 접속하며 Redis 내부에서 로드를 분산
    Shard 1
      |
    Master - (Replaca) Slave 1 - (Replaca) Slave 2

주 사용처

인증 토큰 등 저장 (String, Hash)

랭킹 보드 (Sorted Set)

유저 API Limit

잡 큐(List)

의존성 추가

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

환경 정보 설정

spring:
  data:
    redis:
      repositories:
        enabled: false
  cache:
    type: redis
  redis:
    host: dev-redis / [AWS ElastiCache End-Point Host url]
    port: 6379
    password: redis_password

spring.data.redis.repositories.enabled=false

  • Redis Repositories를 사용하지 않고 RedisTemplate만 활용하는 경우 false 설정

방법 1. Redis Template

Redis와 상호작용하기 위한 고수준 추상화를 제공한다.

Config

@Configuration
class RedisConfig(
    @Value("\${spring.redis.host}") private val redisHost: String,
    @Value("\${spring.redis.port}") private val redisPort: Int,
    @Value("\${spring.redis.password}") private val password: String,
    @Value("\${spring.redis.ssl}") private val isSsl: Boolean
) {

    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory {
        val standaloneConfiguration = RedisStandaloneConfiguration()
        standaloneConfiguration.hostName = redisHost
        standaloneConfiguration.port = redisPort
        standaloneConfiguration.setPassword(password)
        return LettuceConnectionFactory(standaloneConfiguration)
    }

    @Bean
    fun redisTemplate(
        redisConnectionFactory: RedisConnectionFactory,
        objectMapper: ObjectMapper
    ): RedisTemplate<String, Any> {
        val template = RedisTemplate<String, Any>()
        template.setConnectionFactory(redisConnectionFactory)
        template.setDefaultSerializer(StringRedisSerializer())
        return template
    }

}

Redis가 클러스터, 복제를 어떻게 사용하느냐에 따라 Connection Factory를 변경해야 한다.

  • 비클러스터: RedisStandaloneConfiguration
  • 클러스터: RedisClusterConfiguration
  • 복제: RedisStaticMasterReplicaConfiguration

Provider

@Component
class RedisCacheProvider(
    private val redisTemplate: RedisTemplate<String, String>
) {

    fun <T> get(key: String, clazz: Class<T>): T? {
        val data = redisHash().get(key, key.hashCode().toString())
        return try {
            if (data != null) mapper().readValue(data, clazz) else null
        } catch (e: Exception) {
            null
        }
    }

    fun set(key: String, any: Any, timeout: Long = 1L, expireTime: TimeUnit = TimeUnit.DAYS) {
        redisHash().put(key, key.hashCode().toString(), mapper().writeValueAsString(any))
        redisTemplate.expire(key, timeout, expireTime)
    }

    fun del(key: String) {
        redisTemplate.delete(key)
    }

    fun clear() {
        redisTemplate.connectionFactory?.connection?.flushAll()
    }

    private fun redisHash(): HashOperations<String, String, String> = redisTemplate.opsForHash()

    private fun mapper() = ObjectMapper()
        .registerModules(KotlinModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .registerModules(JavaTimeModule())

}

Entity, Interface 는 Redis에 저장할 수 없다.

  • Page → PageImpl 사용하기
  • Entity → DTO 사용하기
  • 저장 시도 시 오류 문구: JsonMappingException: failed to lazily initialize a collection of role could not initialize proxy

데이터베이스 조회 시 LAZY 걸려있는 컬럼은 Transactional 해줘야 오류나지 않는다.

Controller

@RestController
@RequestMapping("/api")
class UserController(
    private val redisCacheProvider: RedisCacheProvider,
    private val userService: UserService
) {

    @GetMapping("/v1/user/{id}")
    fun getUserDetail(@PathVariable id: Long): ResponseEntity<UserResponse> {
        val key = "user::$id"
        val cache = redisCacheProvider.get(key, UserResponse::class.java)

        val dto = cache ?: userService.getUser(id)
        if (cache == null) redisCacheProvider.set(key, dto)

        return ResponseEntity.ok(dto)
    }

    @DeleteMapping("/v1/user/{id}")
    fun delUser(@PathVariable id: Long) 
        redisCacheProvider.del("user::$id")
    }

}

방법 2. Redis Template와 Spring Cache

RedisConfig

@Configuration
class RedisConfig {

    @Value("\${spring.redis.host}")
    var redisHost = "localhost"

    @Value("\${spring.redis.port}")
    var redisPort = 6379

    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory {
        val standaloneConfiguration = RedisStandaloneConfiguration()
        standaloneConfiguration.hostName = redisHost
        standaloneConfiguration.port = redisPort
        return LettuceConnectionFactory(standaloneConfiguration)
    }

    @Bean
    fun objectMapper(): ObjectMapper {
        val mapper = ObjectMapper()
        // LocalDateTime
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        mapper.registerModules(JavaTimeModule())
        // Robustness Principle
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        // Null Property, Empty Constructor
        mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
        // Empty Constructor
        mapper.registerModules(KotlinModule())
        // DTO
        val ptv: PolymorphicTypeValidator = BasicPolymorphicTypeValidator
            .builder()
            .allowIfBaseType(Any::class.java)
            .build()
        mapper.activateDefaultTyping(ptv, DefaultTyping.EVERYTHING)
        return mapper
    }

    @Bean
    fun redisTemplate(
        redisConnectionFactory: RedisConnectionFactory,
        objectMapper: ObjectMapper
    ): RedisTemplate<String, Any> {
        val template = RedisTemplate<String, Any>()
        template.setConnectionFactory(redisConnectionFactory)
        return template
    }

}

Spring Boot는 ConnectionFactory로 Redis Client인 Lettuce와 Jedis를 제공하며, RedisTemplate과 StringRedisTemplate을 자동으로 Bean을 생성한다.

  • ConnectionFactory: 외부 Redis와 연결

ObjectMapper

  • Redis에 값을 저장할 때 Object를 Json으로 직렬화하고 값을 불러올 때 Json을 Object로 역직렬화한다.
  • 직렬화할 때 기본 설정에서 날짜 타입을 Timestamp 형식으로 저장하고 있어서 오류가 발생한다.
  • 해당 기능을 비활성화하고 LocalDateTime을 저장하도록 모듈을 등록한다.
  • 오류 문구: Could not write JSON: Java 8 date/time type `java.time.LocalDate` not supported by default: add Module
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
mapper.registerModules(JavaTimeModule())
  • Object로 저장한 값을 역직렬화 할때 LinkedHashMap 으로 인식하여 오류가 발생한다. Object로 인식할 수 있게 설정해야 한다.
  • 오류 문구: java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class ...DTO...
val ptv: PolymorphicTypeValidator = BasicPolymorphicTypeValidator
    .builder()
    .allowIfSubType(Any::class.java)
    .build()
mapper.activateDefaultTyping(ptv, DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
  • 역직렬화할 때 클래스에 기본 생성자가 없거나 값에 Null이 있으면 오류가 발생한다.
  • 오류 문구: annotations: [null] has no property name annotation; must have name when multiple-parameter constructor annotated as Creator
mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
mapper.registerModules(KotlinModule())
  • 유연한 처리 설정
  • deserialize시 알지 못하는 property가 오더라도 실패하지 않도록 처리하여 견고함의 원칙(Robustness Principle)을 지킨다.
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)

Template

  • RedisTemplate
  • 키와 값을 java Object로 저장한다.
  • defaultSerializer: JdkSerializationRedisSerializer
  • key와 hashKey를 defaultSerializer 그대로 사용하면 cli에서 호출하기 불편하기 때문에 단순 String 형태로 설정하여 사용
  • StringRedisTemplate
  • 키와 값을 String으로 저장한다.
  • defaultSerializer: StringRedisSerializer

Spring Cache Config

@Configuration
@EnableCaching
class CacheConfig {

    @Bean
    fun cacheManager(
        redisConnectionFactory: RedisConnectionFactory,
        objectMapper: ObjectMapper
    ): CacheManager {
        val expireat = Duration.ofDays(1)
        val redisCacheConfiguration = RedisCacheConfiguration
            .defaultCacheConfig()
            .disableCachingNullValues() // null 값은 캐시 안함
            .serializeKeysWith(
                RedisSerializationContext
                    .SerializationPair
                    .fromSerializer(StringRedisSerializer())
            )
            .serializeValuesWith(
                RedisSerializationContext
                    .SerializationPair
                    .fromSerializer(GenericJackson2JsonRedisSerializer(objectMapper))
            )
            .entryTtl(expireat) // 기본 유효시간 설정

        // 캐시별 유효시간 설정
        val cacheConfigurations = mutableMapOf<String, RedisCacheConfiguration>()
        cacheConfigurations["cacheName"] = redisCacheConfiguration.entryTtl(Duration.ofHours(3))
        
        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory)
            .cacheDefaults(redisCacheConfiguration)
            .withInitialCacheConfigurations(cacheConfigurations)
            .build()
    }

}

Serialization

  • StringRedisSerializer: String
  • Jackson2JsonRedisSerializer(classType.class): classType 값을 json 형태로 저장. 특정 클래스에만 종속되어 DTO를 사용할 경우 각각 Template를 설정해야 한다.
  • GenericJackson2JsonRedisSerializer: 모든 classType을 json 형태로 저장할 수 있는 범용적인 Jackson2JsonRedisSerializer이다. 캐싱에 클래스 타입도 저장된다는 단점이 있지만 RedisTemplate을 이용해 다양한 타입 객체를 캐싱할 때 사용하기에 좋다.

Controller

@RestController
@RequestMapping("/api")
class UserController {

    @GetMapping("/v1/user/{id}")
    @Cacheable(cacheNames = ["user"], key = "#id")
    fun geUsertDetail(@PathVariable id: Long): ResponseEntity<UserResponse> {
        ...
    }

    @DeleteMapping("/v1/user/{id}")
    @CacheEvict(cacheNames = ["user"], key = "#id")
    fun delUser(@PathVariable id: Long) {
        ...
    }
}

Response 형태

"com.dev.user.dto.response.UserResponse",
{
    "id": 1,
    "name": "홍길동",
    "age": 20
}

방법 3. Redis Repository

Redis용 Domain과 Repository를 구현하여 데이터 저장한다.

@Transactional 사용 불가

환경 정보 설정

spring:
  cache:
    type: redis
  redis:
    host: dev-redis
    port: 6379
  main:
    allow-bean-definition-overriding: true

Config

@Configuration
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
class RedisConfig {

    @Value("\${spring.redis.host}")
    var redisHost = "localhost"

    @Value("\${spring.redis.port}")
    var redisPort = 6379

    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory {
        val standaloneConfiguration = RedisStandaloneConfiguration()
        standaloneConfiguration.hostName = redisHost
        standaloneConfiguration.port = redisPort
        return LettuceConnectionFactory(standaloneConfiguration)
    }
}

Domain

@Entity
@Table(name = "user")
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L,
    var name: String,
    val age: Int
)

@Entity
@RedisHash("devdev")
@Table(name = "user")
data class UserRedis(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L,
    var name: String,
    val age: Int
) : Serializable

Repository

interface UserRepository : JpaRepository<User, Long>
interface UserRedisRepository : JpaRepository<UserRedis, Long>

Service

@Service
class UserService(
    private val userRepository: UserRepository,
    private val userRedisRepository: UserRedisRepository
) {

    fun createRedis() {
        userRepository.findAll().forEach {
            userRedisRepository.save(
                UserRedis(
                    id = it.id,
                    name = it.name,
                    age = it.age
                )
            )
        }
    }

    fun getUsers(): List<DevRedis> {
        return userRedisRepository.findAll()
    }
}

Monitor - Log

### @RedisHash data class Devdev
1671514330.951231 [0 172.18.0.6:34392] "SMEMBERS" "com.palusomni.phdkimbackend.usadmission.domain.blog.Devdev"
### @RedisHash("devdev") data class Devdev
"SMEMBERS" "devdev"
1671519478.153982 [0 172.18.0.6:49704] "DEL" "devdev:1"
1671519478.171890 [0 172.18.0.6:49704] "HMSET" "devdev:1" "_class" "com.palusomni.phdkimbackend.usadmission.domain.blog.DevRedis" "age" "11" "id" "1" "name" "\xec\x9d\xbc"
1671519478.187608 [0 172.18.0.6:49704] "SADD" "devdev" "1"
1671519478.203718 [0 172.18.0.6:49704] "DEL" "devdev:2"
1671519478.204887 [0 172.18.0.6:49704] "HMSET" "devdev:2" "_class" "com.palusomni.phdkimbackend.usadmission.domain.blog.DevRedis" "age" "12" "id" "2" "name" "\xec\x9d\xb4"
1671519478.205621 [0 172.18.0.6:49704] "SADD" "devdev" "2"
1671519478.207257 [0 172.18.0.6:49704] "DEL" "devdev:3"
1671519478.208667 [0 172.18.0.6:49704] "HMSET" "devdev:3" "_class" "com.palusomni.phdkimbackend.usadmission.domain.blog.DevRedis" "age" "13" "id" "3" "name" "\xec\x82\xbc"
1671519478.209475 [0 172.18.0.6:49704] "SADD" "devdev" "3"
1671519478.210603 [0 172.18.0.6:49704] "DEL" "devdev:4"
1671519478.211380 [0 172.18.0.6:49704] "HMSET" "devdev:4" "_class" "com.palusomni.phdkimbackend.usadmission.domain.blog.DevRedis" "age" "14" "id" "4" "name" "\xec\x82\xac"
1671519478.211865 [0 172.18.0.6:49704] "SADD" "devdev" "4"
1671519478.212863 [0 172.18.0.6:49704] "DEL" "devdev:5"
1671519478.214423 [0 172.18.0.6:49704] "HMSET" "devdev:5" "_class" "com.palusomni.phdkimbackend.usadmission.domain.blog.DevRedis" "age" "15" "id" "5" "name" "\xec\x98\xa4"
1671519478.215754 [0 172.18.0.6:49704] "SADD" "devdev" "5"
1671519478.217011 [0 172.18.0.6:49704] "DEL" "devdev:6"
1671519478.218691 [0 172.18.0.6:49704] "HMSET" "devdev:6" "_class" "com.palusomni.phdkimbackend.usadmission.domain.blog.DevRedis" "age" "16" "id" "6" "name" "\xec\x9c\xa1"
1671519478.220559 [0 172.18.0.6:49704] "SADD" "devdev" "6"

Monitor - Redis-cli

127.0.0.1:6379> keys *
1) "devdev:5"
2) "devdev:3"
3) "devdev:1"
4) "devdev:4"
5) "devdev"
6) "devdev:2"
7) "devdev:6"
127.0.0.1:6379> SMEMBERS devdev
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "6"

Reference

Introduction to Redis

[REDIS] 📚 레디스 소개 & 사용처 (캐시 / 세션) - 한눈에 쏙 정리

Redis의 동시성(Concurrency)개념과 고립성(Isolation)을 위한 Transaction 처리

Redis란 무엇입니까? - Amazon Web Services(AWS)

Redis5 설계하기 총정리

[우아한테크세미나] 191121 우아한레디스 by 강대명님

[Redis] Redis 기초 정리하기

해피쿠 블로그 - [Redis] Redis.conf에 대해 파헤쳐보자! (Snapshot 설정, AOF 설정)

Spring Boot Data Redis 사용해보기

5분 안에 구축하는 Redis(레디스)

[Springboot] '최근 읽은 글' Dto로 Redis에 저장하고 조회하기 AtoZ

Spring Data Redis Repository 미숙하게 사용해 발생한 장애 극복기

[Database] Redis 간단한 사용 방법 (Stand Alone, Replication 기능)

[Redis] Redis 요약 및 정리

redis Sentinel Introduction

High availability with Redis Sentinel

Author

민갤

민갤

Back-End Developer

꾸잉꾸잉하고 웁니다.

로그인

디코에 오신 것을 환영해요!
전문가들의 수많은 아티클 창고 🤓