[Spring Boot] Cache - Redis (Remote Dictionary Server)
Language : KO
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
[REDIS] ? 레디스 소개 & 사용처 (캐시 / 세션) - 한눈에 쏙 정리
Redis의 동시성(Concurrency)개념과 고립성(Isolation)을 위한 Transaction 처리
Redis란 무엇입니까? - Amazon Web Services(AWS)
[우아한테크세미나] 191121 우아한레디스 by 강대명님
해피쿠 블로그 - [Redis] Redis.conf에 대해 파헤쳐보자! (Snapshot 설정, AOF 설정)
[Springboot] '최근 읽은 글' Dto로 Redis에 저장하고 조회하기 AtoZ
Spring Data Redis Repository 미숙하게 사용해 발생한 장애 극복기