Springboot Redis 설정 및 사용 (다중 데이터 소스, Redis Cluster 적용)
- 이번 포스팅에서는 Springboot 에서 Redis 와 연동하는 설정과 사용 방식에 대한 설명글을 작성하겠습니다.
이번에도 먼저 build.gradle 에 라이브러리를 입력하세요.
// (Redis)
// : 메모리 키 값 데이터 구조 스토어
implementation("org.springframework.boot:spring-boot-starter-data-redis:3.3.0")
그리고 application.yml 안에,
# Redis DataSource 설정
datasource-redis:
# Redis 추가
# 작명법은, 앞에 redis{index}-{제목} 형식으로 하기(다른 datasource 설정과의 통일성을 위해)
# (주 사용 Redis)
redis1-main:
node-list: 127.0.0.1:7001, 127.0.0.1:7002, 127.0.0.1:7003, 127.0.0.1:7101, 127.0.0.1:7102, 127.0.0.1:7103
위와 같이 설정을 합니다.
저는 redis 를 여러 데이터 소스에서 가져오는 것을 가정하였기에 위와 같이 여러 주소를 추가 할 수 있도록 하였습니다.
게다가 node-list 는, redis cluster 를 구성할 때, 구성된 클러스터 노드 각각의 주소를 리스트로 입력하였습니다.
다음으로 Redis Config 를 작성할 것인데,
redis_configs 라는 폴더 안에,
위와 같이 Redis1MainConfig 라는, 앞서 application.yml 에서 설정한 Redis 설정에 대한 Config 클래스 파일을 생성해줍니다.
import io.lettuce.core.SocketOptions
import io.lettuce.core.cluster.ClusterClientOptions
import io.lettuce.core.cluster.ClusterTopologyRefreshOptions
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.EnableCaching
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.lettuce.LettuceClientConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
import java.time.Duration
// [Redis 설정]
@Configuration
@EnableCaching
class Redis1MainConfig {
// <멤버 변수 공간>
companion object {
// !!!application.yml 의 datasource-redis 안에 작성된 이름 할당하기!!!
const val REDIS_CONFIG_NAME = "redis1-main"
// Redis Template Bean 이름
const val REDIS_TEMPLATE_NAME = "${REDIS_CONFIG_NAME}_template"
}
// ---------------------------------------------------------------------------------------------
@Value("\${datasource-redis.${REDIS_CONFIG_NAME}.node-list:}#{T(java.util.Collections).emptyList()}")
private lateinit var nodeList: List<String>
@Bean(REDIS_CONFIG_NAME + "_ConnectionFactory")
fun redisConnectionFactory(): LettuceConnectionFactory {
// Socket Option
/*
Lettuce 라이브러리를 사용한다면 Keep Alive 기능을 활성화하고 Connection timeout을 설정하는 것을 추천합니다.
keepAlive 옵션을 활성화(keepAlive(true))하면, 애플리케이션 런타임 중에 실패한 연결을 처리해야 할 상황이 줄어듭니다.
이 속성은 TCP Keep Alive 기능을 설정합니다. TCP Keep Alive는 다음과 같은 특성을 가집니다.
TCP Keep Alive를 켜면 오랫동안 데이터를 전송하지 않아도, TCP Connection이 활성된 상태로 유지됩니다.
TCP Keep Alive는 주기적으로 프로브(Probe)나 메시지를 전송하고 Acknowledgment를 수신합니다.
만약 Acknowledgment가 주어진 시간에 오지 않는다면, TCP Connection은 끊어진 걸로 간주되어 종료됩니다.
Java 애플리케이션에서 TCP Keep Alive를 활성화하기 위해서는 몇 가지 조건이 필요합니다. 다음을 참고하시길 바랍니다.
Java 11 또는 그 이상의 epoll을 사용하는 NIO Socket을 사용하는 경우 가능
Java 10이나 이전 버전의 epoll을 사용하는 NIO Socket을 사용하는 경우 불가능
kqueue는 불가능
ConnectionTimeout에 설정된 시간 값(connectTimeout(Duration.ofMillis(100L)))은 애플리케이션과 Redis 사이에 LettuceConnection을 생성하는 시간 초과 값입니다.
일반적으로 Redis와 애플리케이션은 내부 네트워크를 사용하고 있으므로 커넥션을 생성하는 시간을 짧게 두어도 무방합니다.
예제에서는 100ms로 설정했습니다.
connectionTimeout은 command timeout과 같이 반드시 설정해야 하는 값입니다.
네트워크 또는 Redis에 문제가 발생하여 Redis 명령어를 빠르게 실행할 수 없다면 애플리케이션 처리량까지 느려질 수 있습니다.
두 설정 값을 너무 크게 잡지 않는다면(예: 1초 이상) Redis나 네트워크에 문제가 발생했을 때 빠르게 예외(Exception)를 발생시킬 수 있습니다.
그래서 애플리케이션이 연쇄적인 장애에 빠지지 않게, 시스템을 격리/보호하는 전략도 고려해 볼 수 있습니다.
비즈니스 로직에 따라서 빠른 실패가 시스템 전체를 보호할 수 있습니다.
*/
val socketOptions: SocketOptions = SocketOptions.builder()
.connectTimeout(Duration.ofMillis(100L))
.keepAlive(true)
.build()
// Cluster topology refresh 옵션
/*
Redis 클러스터는 3개 이상의 Redis 노드들로 구성되어 있습니다.
Redis 클러스터에 노드를 추가/삭제 또는 Master 승격 같은 이벤트가 발생하면 토폴로지가 변경됩니다.
Redis 클러스터를 연결된 클라이언트 애플리케이션은 최신의 Redis 클러스터 정보를 동기화합니다.
그래서 클라이언트 애플리케이션이 어떤 노드에 데이터를 조회/생성/삭제할지 미리 알고 있습니다.
ClusterTopologyRefreshOptions는 Redis 클러스터 토폴로지에 변경이 발생했을 때 클라이언트 애플리케이션이 가진 토폴로지 갱신과 관련된 설정 기능을 제공합니다.
enablePeriodicRefresh()의 시간 인자는 클라이언트 애플리케이션이 Redis 토폴로지를 갱신하는 주기를 설정합니다.
하지만 dynamicRefreshSources(), enableAllAdaptiveRefreshTriggers()는 Redis 클러스터에서 발생하는 이벤트를
클라이언트 애플리케이션이 수신하여 토폴로지를 갱신하는 차이가 있습니다.
만약 클라이언트 애플리케이션의 토폴로지 정보가 업데이트되지 않아 잘못된 노드에 명령어를 실행해도 문제없습니다.
Redis 노드들 또한 토폴로지 정보를 업데이트하고 있으며, MOVED 응답으로 해당 데이터가 저장된 정확한 노드를 응답합니다.
enablePeriodicRefresh()의 기본값은 60초입니다.
이 옵션이 비활성화되면 클라이언트 애플리케이션은 클러스터에 명령을 실행하고 오류가 발생할 때만 클러스터 토폴로지를 업데이트합니다.
대규모의 Redis 클러스터를 사용하고 있다면 리프레시 주기를 길게 가져가는 것이 좋습니다.
갱신 시간 값이 짧고 Redis 클러스터의 노드 수가 많은 클라이언트 애플리케이션이 자주 토폴로지를 갱신한다면,
Redis 클러스터 전체에 부하가 될 수 있습니다.
enableAllAdaptiveRefreshTriggers()는 Redis 클러스터에서 발행하는 모든 트리거에 대해서 토폴로지를 갱신합니다.
트리거는 MOVED_REDIRECT, ASK_REDIRECT, PERSISTENT_RECONNECTS, UNCOVERED_SLOT, UNKNOWN_NODE 등이 될 수 있습니다.
dynamicRefreshSources()의 기본 값은 true입니다.
소규모 클러스터에는 DynamicRefreshResources를 활성화하고 대규모 클러스터에는 비활성화하는 것이 좋습니다.
이 설정이 false이면 Redis 클라이언트는 seed 노드에만 질의하여 새로운 노드를 찾는 데 사용합니다.
이 경우 문제가 있는 노드가 클라이언트 애플리케이션의 토폴로지 정보에서 제외되는 데 시간이 소요됩니다.
이 설정이 true이면 Redis 클라이언트는 모든 Redis 클러스터 노드에게 질의하여 결과를 비교합니다.
그래서 새로운 정보로 토폴로지를 업데이트합니다.
그러므로 대규모 Redis 클러스터에는 DynamicRefreshResources 기능을 끄는 것을 추천합니다.
*/
val clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions
.builder()
.dynamicRefreshSources(true)
.enableAllAdaptiveRefreshTriggers()
.enablePeriodicRefresh(Duration.ofSeconds(30))
.build()
// Cluster client 옵션
/*
maxRedirects() 옵션은 Redis 클러스터가 MOVED_REDIRECT를 응답할 때 클라이언트 애플리케이션에서 Redirect하는 최대 횟수를 설정합니다.
Redis 클라이언트는 Redis 토폴로지 정보를 동기화하고 있습니다.
각 Redis 노드의 마스터/슬레이브 정보와 IP, 그리고 데이터를 분배하는 정보인 슬롯 범위를 동기화합니다.
만약 Redis 클라이언트가 토폴로지 업데이트에 실패하거나 동기화하지 못한 경우, 잘못된 노드에 Redis 명령을 실행할 수 있습니다.
이 경우 Redis는 실패(MOVED_REDIRECT)를 응답하고 클라이언트는 적절한 노드로 리다이렉션할 수 있습니다.
만약 Redis 클러스터가 3대의 노드로 구성되어 있다면 maxRedirects 값을 3으로 설정했다고 생각해 봅시다.
이 경우 클라이언트 애플리케이션이 실행한 명령어가 실패할 확률은 매우 줄어듭니다.
*/
val clusterClientOptions = ClusterClientOptions
.builder()
.pingBeforeActivateConnection(true)
.autoReconnect(true)
.socketOptions(socketOptions)
.topologyRefreshOptions(clusterTopologyRefreshOptions)
.maxRedirects(3).build()
// Lettuce Client 옵션
/*
Lettuce 라이브러리는 지연 연결을 사용하고 있으므로, Command Timeout 값이 Connection Timeout 값보다 커야 합니다.
예제에서는 Command Timeout을 150ms로 설정했으며,
앞서 설정한 SocketOptions의 Connection Timeout 값을 100ms로 설정했습니다.
*/
val clientConfig = LettuceClientConfiguration
.builder()
.commandTimeout(Duration.ofMillis(150L))
.clientOptions(clusterClientOptions)
.build()
val clusterConfig = RedisClusterConfiguration(nodeList)
clusterConfig.maxRedirects = 3
clusterConfig.setPassword("todoPw")
val factory = LettuceConnectionFactory(clusterConfig, clientConfig)
// LettuceConnectionFactory 옵션
factory.validateConnection = false
return factory
}
@Bean(REDIS_TEMPLATE_NAME)
fun redisRedisTemplate(): RedisTemplate<String, String> {
val redisTemplate = RedisTemplate<String, String>()
redisTemplate.connectionFactory = redisConnectionFactory()
redisTemplate.keySerializer = StringRedisSerializer()
redisTemplate.valueSerializer = StringRedisSerializer()
return redisTemplate
}
}
내부 코드는 위와 같습니다.
자세한 설명은 이전글과 중복되므로 생략하겠습니다.
- 이제 위와 같이 등록한 REDIS_TEMPLATE_NAME 이름의 RedisTemplate Bean 을 설정하여 사용하면 되는데,
저의 경우는 별도의 처리를 통해 보다 사용하기 쉽도록 패턴을 만들었습니다.
데이터베이스, 네트워크, 파일... 모두 데이터를 얻어오는 데이터 소스라고 할 수 있습니다.
그렇다면 Redis 역시 데이터 소스에 포함될것입니다.
사람들이 Redis 에 원하는 데이터는, 특정 key 를 입력하면 값을 반환하는 map 형식의 데이터일 것이기에 이에 맞춰서, Redis 데이터 형태별 key 를 할당하고 이를 반환하는 객체를 만들면 사용성이 편해지겠다는 생각 하에 사용성을 변경해보았습니다.
기본적으로 Redis 를 다루려면,
redisTemplateObj.opsForValue().set(innerKey, gson.toJson(value))
위와 같이 key, value 를 입력하거나,
val keySet: Set<String> = redisTemplateObj.keys("$mapName:*")
이처럼 특정 패턴(위에선 mapName 변수 및 :로 시작되는 모든 키)에 해당하는 모든 키를 검색하거나,
redisTemplateObj.opsForValue()[innerKey]
위와 같이 특정 키를 조회하는 방식으로 사용하면 됩니다.
그런데, key 는 어디서든 어떻게든 변경이 가능합니다.
또한 value 역시 String 타입으로 어떤 값이든 넣을 수 있고요.
저는 여기서 key 와 value 설정의 자유로움을 조금 덜어내는 방식으로 체계를 만들고자 합니다.
먼저, value 는 string 타입으로 고정하여, 특정 json 형태를 강요할 것입니다. 특정 key 에는 특정 json 형태를 강요함으로써, key 에 따른 value 의 형태를 고정하는 것입니다.
이때, key 는 내부적으로 2 파트로 나뉘게 됩니다.
"mapName:keyName"
위와 같습니다.
mapName 은 value 의 형태와 연관되어 있습니다.
동일 mapName 을 가지고 있는 key는 모두 동일한 value 형태를 가집니다.
이 상태에서 keyName 을 변경해가며 mapName 을 사용하는 구조 하에 key, value 데이터를 추가할 수 있게 되는 것입니다.
이제 위와 같은 내용을 실제로 구현해보겠습니다.
import com.google.gson.Gson
import org.springframework.data.redis.core.RedisTemplate
import java.util.concurrent.TimeUnit
// [RedisMap 의 Abstract 클래스]
// 본 추상 클래스를 상속받은 클래스를 key, value, expireTime 및 Redis 저장, 삭제, 조회 기능 메소드를 가진 클래스로 만들어줍니다.
// Redis Storage 를 Map 타입처럼 사용 가능하도록 래핑해주는 역할을 합니다.
abstract class BasicRedisMap<ValueVo>(
private val redisTemplateObj: RedisTemplate<String, String>,
private val mapName: String,
private val clazz: Class<ValueVo>
) {
private val gson = Gson()
// <공개 메소드 공간>
// (RedisMap 에 Key-Value 저장)
fun saveKeyValue(
key: String,
value: ValueVo,
expireTimeMs: Long?
) {
// 입력 키 검증
validateKey(key)
// Redis Storage 에 실제로 저장 되는 키 (map 이름과 키를 합친 String)
val innerKey = "$mapName:${key}" // 실제 저장되는 키 = 그룹명:키
// Redis Storage 에 실제로 저장 되는 Value (Json String 형식)
redisTemplateObj.opsForValue().set(innerKey, gson.toJson(value))
if (expireTimeMs != null) {
// Redis Key 에 대한 만료시간 설정
redisTemplateObj.expire(innerKey, expireTimeMs, TimeUnit.MILLISECONDS)
}
}
// (RedisMap 의 모든 Key-Value 리스트 반환)
fun findAllKeyValues(): List<RedisMapDataVo<ValueVo>> {
val resultList = ArrayList<RedisMapDataVo<ValueVo>>()
val keySet: Set<String> = redisTemplateObj.keys("$mapName:*")
for (innerKey in keySet) {
// innerKey : Redis Storage 에 실제로 저장 되는 키 (map 이름과 키를 합친 String)
// 외부적으로 사용되는 Key (innerKey 에서 map 이름을 제거한 String)
val key = innerKey.substring("$mapName:".length) // 키
// Redis Storage 에 실제로 저장 되는 Value (Json String 형식)
val innerValue = redisTemplateObj.opsForValue()[innerKey] ?: continue // 값
// 외부적으로 사용되는 Value (Json String 을 테이블 객체로 변환)
val valueObject = gson.fromJson(
innerValue, // 해석하려는 json 형식의 String
clazz // 파싱할 데이터 객체 타입
)
resultList.add(
RedisMapDataVo(
key,
valueObject,
redisTemplateObj.getExpire(innerKey, TimeUnit.MILLISECONDS) // 남은 만료시간
)
)
}
return resultList
}
// (RedisMap 의 key-Value 를 반환)
fun findKeyValue(
key: String
): RedisMapDataVo<ValueVo>? {
// 입력 키 검증
validateKey(key)
// Redis Storage 에 실제로 저장 되는 키 (map 이름과 키를 합친 String)
val innerKey = "$mapName:$key"
// Redis Storage 에 실제로 저장 되는 Value (Json String 형식)
val innerValue = redisTemplateObj.opsForValue()[innerKey] // 값
return if (innerValue == null) {
null
} else {
// 외부적으로 사용되는 Value (Json String 을 테이블 객체로 변환)
val valueObject = gson.fromJson(
innerValue, // 해석하려는 json 형식의 String
clazz // 파싱할 데이터 객체 타입
)
RedisMapDataVo(
key,
valueObject,
redisTemplateObj.getExpire(innerKey, TimeUnit.MILLISECONDS) // 남은 만료시간
)
}
}
// (RedisMap 의 모든 Key-Value 리스트 삭제)
fun deleteAllKeyValues() {
val keySet: Set<String> = redisTemplateObj.keys("$mapName:*")
redisTemplateObj.delete(keySet)
}
// (RedisMap 의 Key-Value 를 삭제)
fun deleteKeyValue(
key: String
) {
// 입력 키 검증
validateKey(key)
// Redis Storage 에 실제로 저장 되는 키 (map 이름과 키를 합친 String)
val innerKey = "$mapName:$key"
redisTemplateObj.delete(innerKey)
}
// ---------------------------------------------------------------------------------------------
// <비공개 메소드 공간>
// (입력 키 검증 함수)
private fun validateKey(key: String) {
if (key.trim().isEmpty()) {
throw RuntimeException("key 는 비어있을 수 없습니다.")
}
if (key.contains(":")) {
throw RuntimeException("key 는 :를 포함할 수 없습니다.")
}
}
// ---------------------------------------------------------------------------------------------
// <중첩 클래스 공간>
// [RedisMap 의 출력값 데이터 클래스]
data class RedisMapDataVo<ValueVo>(
val key: String, // 멤버가 입력한 키 : 실제 키는 ${groupName:key}
val value: ValueVo,
val expireTimeMs: Long // 남은 만료 시간 밀리초
)
}
위와 같은 RedisMap 추상 클래스를 만들어보았습니다.
RedisTemplate 를 사용하여 값을 입력하는 함수, 특정 mapName 으로 저장된 모든 데이터를 가져오는 함수, 특정 key 의 내용을 가져오는 함수, 그리고 특정 mapName 의 모든 데이터 및 특정 key 의 데이터를 삭제하는 함수로 이루어집니다.
즉, RedisMap 객체는 mapName 과 그와 쌍이 되는 데이터 클래스 형태를 기준으로 나뉘게 되는데, 이것을 사용하여 Redis 클래스를 하나 만들어보면,
import com.raillylinker.springboot_mvc_template.configurations.redis_configs.Redis1MainConfig
import com.raillylinker.springboot_mvc_template.custom_classes.BasicRedisMap
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Component
// [RedisMap 컴포넌트]
@Component
class Redis1_Test(
// !!!RedisConfig 종류 변경!!!
@Qualifier(Redis1MainConfig.REDIS_TEMPLATE_NAME) val redisTemplate: RedisTemplate<String, String>
) : BasicRedisMap<Redis1_Test.ValueVo>(redisTemplate, MAP_NAME, ValueVo::class.java) {
// <멤버 변수 공간>
companion object {
// !!!중복되지 않도록, 본 클래스명을 MAP_NAME 으로 설정하기!!!
const val MAP_NAME = "Redis1_Test"
}
// !!!본 RedisMAP 의 Value 클래스 설정!!!
class ValueVo(
// 기본 변수 타입 String 사용 예시
var content: String,
// Object 변수 타입 사용 예시
var innerVo: InnerVo,
// Object List 변수 타입 사용 예시
var innerVoList: List<InnerVo>
) {
// 예시용 Object 데이터 클래스
data class InnerVo(
var testString: String,
var testBoolean: Boolean
)
}
}
위와 같이 작성할 수 있습니다.
간단히 이해하자면, MAP_NAME 을 설정하고, 그에 맞는 데이터 클래스 형태를 만들면, 해당 컴포넌트 빈을 사용하여 Redis 를 조작시에는 내부적으로 자동으로 데이터를 파싱해주도록 구현되는 것입니다.
위의 예시는 Redis_Test 라는 이름의 Map 으로, ValueVo 에 설정된 형태의 JSON String 으로 Value 가 다루어지게 되는데,
@Service
class C8Service1TkV1RedisTestService(
// (프로젝트 실행시 사용 설정한 프로필명 (ex : dev8080, prod80, local8080, 설정 안하면 default 반환))
@Value("\${spring.profiles.active:default}") private var activeProfile: String,
private val redis1Test: Redis1_Test
)
위와 같이 Bean 을 가져와서,
// <공개 메소드 공간>
fun api1InsertRedisKeyValueTest(
httpServletResponse: HttpServletResponse,
inputVo: C8Service1TkV1RedisTestController.Api1InsertRedisKeyValueTestInputVo
) {
redis1Test.saveKeyValue(
inputVo.key,
Redis1_Test.ValueVo(
inputVo.content,
Redis1_Test.ValueVo.InnerVo("testObject", true),
arrayListOf(
Redis1_Test.ValueVo.InnerVo("testObject1", false),
Redis1_Test.ValueVo.InnerVo("testObject2", true)
)
),
inputVo.expirationMs
)
httpServletResponse.status = HttpStatus.OK.value()
}
////
fun api2SelectRedisValueSample(
httpServletResponse: HttpServletResponse,
key: String
): C8Service1TkV1RedisTestController.Api2SelectRedisValueSampleOutputVo? {
// 전체 조회 테스트
val keyValue = redis1Test.findKeyValue(key)
if (keyValue == null) {
httpServletResponse.status = HttpStatus.NO_CONTENT.value()
httpServletResponse.setHeader("api-result-code", "1")
return null
}
httpServletResponse.status = HttpStatus.OK.value()
return C8Service1TkV1RedisTestController.Api2SelectRedisValueSampleOutputVo(
Redis1_Test.MAP_NAME,
keyValue.key,
keyValue.value.content,
keyValue.expireTimeMs
)
}
이러한 방식으로 쉽게 Redis 를 사용 할 수 있습니다.
제가 이러한 방식을 사용하고 추천드리는 이유는,
Redis 는 프로젝트 한곳에서만 사용되는 것이 아니라 여러곳에서 사용될 수 있는 Key-Value 인메모리 데이터베이스이므로 key 관리가 중요한데, 위와 같이 MapName 을 사용하는 키 사용 규칙을 적용한다면, MapName 을 기준으로 문서를 만들어 중복되는 key 사용을 억제할 수 있을뿐더러, mapName 에 맞는 데이터 형태를 정형화 함으로써 불필요한 파싱 처리를 줄일 수 있기 때문입니다.
물론 더 좋은 방식이 존재할 수도 있으며, 이에 대한 본인의 팁이나 의견을 남겨주신다면 감사하겠습니다.
- 이상입니다.