Redis와 Spring AOP를 이용한 캐시 관리: RedisCacheable로 TTL 설정하기
애플리케이션 성능을 최적화하기 위해서는 캐시를 활용하는 것이 매우 중요합니다. 특히, Redis는 고속의 인메모리 데이터 저장소로써 Spring과 결합해 캐시를 관리하는 데 아주 유용합니다. 이번 포스팅에서는 @RedisCacheable이라는 커스텀 어노테이션을 사용하여 TTL(Time to Live)로 Redis 캐시의 생명주기를 관리하는 방법을 소개하려 합니다.
1. @RedisCacheable 어노테이션 정의
@RedisCacheable 어노테이션을 정의합니다. 이 어노테이션은 메서드에 적용되어, 메서드의 반환값을 Redis에 캐싱하도록 해줍니다. 이를 통해 반복되는 데이터 조회 작업의 부담을 줄일 수 있습니다.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RedisCacheable(
val name: String,
val key: String = "",
val ttl: Long = -1,
val hasClassAndMethodNamePrefix: Boolean = false
)
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RedisCacheEvict(
val name: Array<String>,
val key: String = "",
val clearAll: Boolean = false,
val hasClassAndMethodNamePrefix: Boolean = false
)
package com.samsungfire.chacpet.common.annotation
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RedisCacheEvict(
val name: Array<String>,
val key: String = "",
val clearAll: Boolean = false,
val hasClassAndMethodNamePrefix: Boolean = false
)
- name: Redis에서 사용할 캐시 이름을 지정합니다.
- key: 캐시에 사용할 특정 키를 지정합니다. 메서드의 파라미터 값들을 기반으로 키가 생성됩니다.
- ttl: 캐시의 유효 기간을 초 단위로 설정합니다. 기본값은 -1로, 이 경우 캐시가 만료되지 않습니다.
- hasClassAndMethodNamePrefix: 캐시 키에 클래스와 메서드 이름을 포함할지 여부를 설정합니다.
- clearAll: 주어진 명명 이하로 * 를 추가해서 삭제시 사용하며, key와 혼합해서 사용 가능합니다.
- RedisCacheable 어노테이션 처리하기
AOP를 사용해 RedisCacheable 어노테이션을 처리하는 방법을 설명하겠습니다. RedisCacheAspect
클래스를 통해 어노테이션이 적용된 메서드를 가로채어 캐시를 관리하는 로직을 추가합니다.
@Aspect
@Component
class RedisCacheAspect(
private val redisTemplate: RedisTemplate<String, Any?>
) {
@Around("@annotation(redisCacheable)")
fun cacheableProcess(joinPoint: ProceedingJoinPoint, redisCacheable: RedisCacheable): Any? {
val cacheKey = generateKey(
redisCacheable.name,
joinPoint,
redisCacheable.hasClassAndMethodNamePrefix,
redisCacheable.key
)
val cacheTTL = redisCacheable.ttl
redisTemplate.opsForValue().get(cacheKey)?.let {
return it
}
val methodReturnValue = joinPoint.proceed()
if (cacheTTL < 0) {
redisTemplate.opsForValue().set(cacheKey, methodReturnValue)
} else {
redisTemplate.opsForValue().set(cacheKey, methodReturnValue, cacheTTL, TimeUnit.SECONDS)
}
return methodReturnValue
}
@Around("@annotation(redisCacheEvict)")
fun cacheEvictProcess(joinPoint: ProceedingJoinPoint, redisCacheEvict: RedisCacheEvict): Any? {
val methodReturnValue = joinPoint.proceed()
val cacheKey =
generateKey(cacheName, joinPoint, redisCacheEvict.hasClassAndMethodNamePrefix, redisCacheEvict.key)
redisCacheEvict.name.forEach { cacheName ->
if (redisCacheEvict.clearAll) {
val keys = redisTemplate.keys("$cacheKey*")
if (keys.isNotEmpty()) {
redisTemplate.delete(keys)
}
} else {
redisTemplate.delete(cacheKey)
}
}
return methodReturnValue
}
@Around("@annotation(redisCachePut)")
fun cachePutProcess(joinPoint: ProceedingJoinPoint, redisCachePut: RedisCachePut): Any? {
val cacheKey = generateKey(
redisCachePut.name,
joinPoint,
redisCachePut.hasClassAndMethodNamePrefix,
redisCachePut.key
)
val cacheTTL = redisCachePut.ttl
val methodReturnValue = joinPoint.proceed()
if (cacheTTL < 0) {
redisTemplate.opsForValue().set(cacheKey, methodReturnValue)
} else {
redisTemplate.opsForValue().set(cacheKey, methodReturnValue, cacheTTL, TimeUnit.SECONDS)
}
return methodReturnValue
}
private fun generateKey(
cacheName: String,
joinPoint: ProceedingJoinPoint,
hasClassAndMethodNamePrefix: Boolean,
key: String
): String {
val generatedKey = if (key.isEmpty()) {
StringUtils.arrayToCommaDelimitedString(joinPoint.args)
} else {
getParameterValueByKey(joinPoint, key)
?: throw IllegalArgumentException("No matching parameter for key: $key")
}
return if (hasClassAndMethodNamePrefix) {
val target = joinPoint.target::class.simpleName
val method = (joinPoint.signature as MethodSignature).method.name
"$cacheName::$target.$method::$generatedKey"
} else {
"$cacheName::$generatedKey"
}
}
private val parser = SpelExpressionParser()
private fun getParameterValueByKey(joinPoint: ProceedingJoinPoint, key: String): String? {
val signature = joinPoint.signature as MethodSignature
val parameterNames = signature.parameterNames
val context = StandardEvaluationContext()
parameterNames.forEachIndexed { index, indexedValue
-> context.setVariable(indexedValue, joinPoint.args[index]) }
return parser.parseExpression(key).getValue(context, String::class.java)
}
}
3. 캐시 키 생성과 TTL 적용
generateKey 메서드를 이용해 캐시 키를 생성합니다. 이 키는 Redis에 저장될 데이터의 고유 식별자가 됩니다. TTL 값은 캐시의 생명주기를 관리하는 데 사용되며, 지정된 TTL이 지나면 캐시는 자동으로 만료됩니다. TTL 값이 지정되지 않았다면 캐시는 영구적으로 유지됩니다.
4. 실제 사용 예시
이제 @RedisCacheable 어노테이션을 실제 코드에 적용해 보겠습니다:
@RedisCacheable(name = "petCache", key = "#petType", ttl = 3600)
fun getPetDetails(petType: String): PetDetails {
// 데이터베이스 또는 외부 API를 통해 데이터를 조회하는 로직
return petService.getPetDetails(petType)
}
위 메서드는 petType을 키로 하여 petCache에 캐싱됩니다. 캐시는 3600초(1시간) 동안 유효하며, 이후에는 자동으로 만료됩니다.
5. 적재된 cache가 redis 에서 조회가 안될 경우 처리 방법
@Bean
fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<String, Any>? {
val template = RedisTemplate<String, Any>().apply {
setConnectionFactory(connectionFactory)
setKeySerializer(StringRedisSerializer())
}
return template
}
setKeySerializer(StringRedisSerializer())
라인은 RedisTemplate이 Redis에 데이터를 저장하거나 조회할 때 사용하는 키의 직렬화 방식을 설정하는 부분입니다.
RedisTemplate은 Redis와의 상호작용을 추상화하여 간단한 API를 제공합니다. 이 때 Redis에 저장되는 데이터의 키와 값은 각각 직렬화가 필요합니다. KeySerializer는 Redis에 저장되는 키를 어떻게 직렬화할지를 결정합니다.
기본적으로 RedisTemplate의 키 직렬화는 JdkSerializationRedisSerializer
을 사용합니다. 이 방식은 Java의 기본 직렬화 방식을 사용하여 객체를 바이트 배열로 변환합니다. 하지만, 일반적으로 Redis 키는 문자열로 저장되는 것이 좋습니다. 그 이유는
- 문자열은 Redis CLI나 기타 관리 도구로 쉽게 조회할 수 있습니다.
- 다른 언어나 시스템과의 호환성이 높습니다.
마치며
이번 포스팅에서는 @RedisCacheable 어노테이션과 AOP를 활용해 Redis 캐싱을 어떻게 구현하고 TTL로 캐시의 생명주기를 관리하는지를 살펴보았습니다. 이를 통해 데이터 조회 성능을 크게 향상시킬 수 있으며, 특히 빈번한 데이터 변경이 없는 경우 유용하게 사용할 수 있습니다. Redis와 Spring의 조합을 통해 성능 최적화에 한 발 더 나아가 보세요!