• Home
  • About
    • lahuman photo

      lahuman

      열심히 사는 아저씨

    • Learn More
    • Facebook
    • LinkedIn
    • Github
  • Posts
    • All Posts
    • All Tags
  • Projects

Redis 기반 Spring framework Cache 고도화

29 Aug 2024

Reading time ~4 minutes

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와 혼합해서 사용 가능합니다.
  1. 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의 조합을 통해 성능 최적화에 한 발 더 나아가 보세요!

참고자료

  • Redis Cache
  • spring-framework/spring-context/src/main/java/org/springframework/cache/interceptor/SimpleKey.java
  • Spring Boot 캐시 만료시간 설정을 위한 Redis Cache AOP 작성


springcacheredis Share Tweet +1