Spring

Spring Boot 캐시 만료시간 설정을 위한 Redis Cache AOP 작성

제리 . 2022. 3. 4. 23:34

Spring 프레임 워크에서 제공하는 캐시는 추상화가 잘되어있고 여러 어노테이션(@Cacheable, @CacheEvict..)을 사용해서 간단히 사용하기 편하다.

다만, 내가 redis를 구현체로 사용하면서 느꼈던 불편함은 캐시의 만료시간을 설정하기 까다롭다는 점이다.

 

우선, spring에서 제공하는 @Cacheable, @CachePut 등의 어노테이션으로는 만료시간을 설정하는 옵션이 없다. 

기본적으로는 만료 시간이 없게 캐시가 저장되기때문에 필요하다면 캐시를 명시적으로 제거해주거나 업데이트해줘야 한다.

만료 시간만 설정되면 되는데 캐시를 컨트롤해줘야 하는 로직이 불필요하게 코드 안에 섞이는 건 부담스러웠다.

 

그렇다고 만료기간을 설정할 방법이 없는 건 아니다.

 

첫 번째 방법으로, cachemanger를 등록할 때 configuration에서 특정 캐시에 대한 ttl을 설정할 수 있다. 

    @Bean
    fun RedisCacheManager cacheManager(connectionFactory: RedisConnectionFactory) {
        val configuration = RedisCacheConfiguration.defaultCacheConfig()
                .disableCachingNullValues()
                .entryTtl(Duration.ofSeconds(600)) // global한 ttl 설정
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));

        val cacheConfigurations = new HashMap<String, RedisCacheConfiguration>();
        
        cacheConfigurations.put("cacheName", RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(3600))); // 특정 캐시에 대한 ttl설정

        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory).cacheDefaults(configuration)
                .withInitialCacheConfigurations(cacheConfigurations).build();
    }

하지만 이 방법은 단점이 있다.

- 캐시 수가 늘어날수록 configuration에 계속 하드 코딩해줘야 한다.

- 캐시 이름이 같다면 ttl이 모두 동일하게 설정된다.

- 캐시 하려는 메서드와 ttl설정의 코드가 분리되어있어 관리가 어렵다.

 

두 번째 방법으로, redisTemplate을 직접 사용해서 캐시를 다루면 된다.

@Service
class OrderService(
    val redisTemplate: RedisTemplate<String, Any?>,
    val orderRepository: OrderRepository
) {

    fun findTopPriceOrderByCountry(country: String): Order {
        val orderList = redisTemplate.opsForValue()["country"]
        if (orderList != null) {
            return orderList
        }
        val result = orderRepository.findTopPriceOrderByCountry(country)
        redisTemplate.opsForValue().set("findTopPriceOrderByCountry($country)", result, 600, TimeUnit.SECONDS)
        return result
    }
}

redisTemplate을 사용하면 ttl을 직접 설정할 수 있다.

그런데 이 방법 역시 한계점이 분명하다.

- 캐시를 위한 로직이 비즈니스 로직과 섞이게 된다.

- 캐시를 사용하는 클래스마다 redisTemplate을 주입받아 사용해야 한다.

 

이런 이유로 redisTemplate을 사용하는 관심사를 분리하고 스프링의 캐시와 비슷하면서 ttl을 설정할 수 있는 aop를 만들어보게 됐다.

 

세 번째 방법으로, @RedisCacheable, @RedisCacheEvict, @RedisCachePut AOP 만들기

먼저 사용할 어노테이션을 만들어준다.

/**
 * 메서드 반환 값을 캐시 저장한다.
 * 이미 저장된 캐시가 있다면 캐시 값을 리턴한다.
 * 메서드 실행중 예외 발생시 캐시가 저장되지 않는다.
 */
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RedisCacheable(
    /**
     * redis에 사용할 캐시 이름
     */
    val cacheName: String,
    /**
     * 캐시 만료 시간 (초 단위) default : 만료시간 없음
     */
    val expireSecond: Long = -1,
    /**
     *  캐시 key 생성에 class, method 이름을 사용할 것 인지 default : false
     * classAndMethodNamePrefix = true -> key::ClassName.MethodName(args...)
     * classAndMethodNamePrefix = false -> key::(args...)
     */
    val hasClassAndMethodNamePrefix: Boolean = false
)

캐시를 저장하거나 캐시가 있다면 반환하는 어노테이션이다.

hasClassAndMethodNamePrefix는 캐시 key에 클래스와 메서드의 이름을 붙일지 정하는 옵션이다. 이 옵션은 캐시 키의 이름이 중복될 가능성이 있어 설정이 필요할 때가 있다. 자세한 이유는 https://jgrammer.tistory.com/entry/무신사-watcher-Cacheable-중복되는-key값-어떻게-처리할까 이 글을 참고하면 되겠다.

 

 

/**
 * 메서드 실행 후 캐시를 제거한다.
 * 메서드 실행중 예외 발생시 캐시가 제거되지 않는다.
 */
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RedisCacheEvict(
    /**
     * redis에 사용할 캐시 이름
     */
    val cacheName: String,

    /**
     *  clearAll = true : 메서드 parameter에 관계 없이 해당 key로 전부 제거
     *  clearAll = false : 메서드 parameter와 일치하는 캐시만 제거
     */
    val clearAll: Boolean = false,
    /**
     *  캐시 key 생성에 class, method 이름을 사용할 것 인지 default : false
     * classAndMethodNamePrefix = true -> key::ClassName.MethodName(args...)
     * classAndMethodNamePrefix = false -> key::(args...)
     */
    val hasClassAndMethodNamePrefix: Boolean = false
)

캐시를 제거하는 어노테이션이다.

clearAll옵션은 key와 일치하는 캐시를 전부 삭제할지 여부이다. redis에 실제로 저장돼 key는 여기의 key 필드를 prefix로 설정된다. 뒤에는 파라미터 정보를 포함하고 클래스, 메서드 정보를 선택적으로 포함한다.

 

/**
 * 메서드 반환 값을 캐시 저장한다.
 * 메서드 실행중 예외 발생시 캐시가 저장되지 않는다.
 */
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RedisCachePut(
    /**
     * redis에 사용할 캐시 이름
     */
    val cacheName: String,
    /**
     * 캐시 만료 시간 (초 단위) default : 만료시간 없음
     */
    val expireSecond: Long = -1,
    /**
     *  캐시 key 생성에 class, method 이름을 사용할 것 인지 default : false
     * classAndMethodNamePrefix = true -> key::ClassName.MethodName(args...)
     * classAndMethodNamePrefix = false -> key::(args...)
     */
    val hasClassAndMethodNamePrefix: Boolean = false
)

캐시를 업데이트하는 어노테이션이다.

 

이번엔 실제 캐시 로직이 들어가는 aspect구현 부분이다. 각각 어노테이션에 대한 어드바이스를 구현해주면 된다.

@Aspect
@Component
class RedisCacheAspect(
    private val redisTemplate: RedisTemplate<String, Any?>,
) {

    @Around("@annotation(RedisCacheable)")
    fun cacheableProcess(joinPoint: ProceedingJoinPoint): Any? {
        val redisCacheable = (joinPoint.signature as MethodSignature).method.getAnnotation(RedisCacheable::class.java)
        val cacheKey = generateKey(redisCacheable.cacheName, joinPoint, redisCacheable.hasClassAndMethodNamePrefix)
        val cacheTTL = redisCacheable.expireSecond
        if (redisTemplate.hasKey(cacheKey))
            return redisTemplate.opsForValue().get(cacheKey)
        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): Any? {
        val methodReturnValue = joinPoint.proceed()
        val redisCacheEvict = (joinPoint.signature as MethodSignature).method.getAnnotation(RedisCacheEvict::class.java)
        if (redisCacheEvict.clearAll) {
            val keys = redisTemplate.keys("${redisCacheEvict.cacheName}*")
            redisTemplate.delete(keys)
        } else {
            val cacheKey = generateKey(redisCacheEvict.cacheName, joinPoint, redisCacheEvict.hasClassAndMethodNamePrefix)
            redisTemplate.delete(cacheKey)
        }
        return methodReturnValue
    }

    @Around("@annotation(RedisCachePut)")
    fun cachePutProcess(joinPoint: ProceedingJoinPoint): Any? {
        val redisCachePut = (joinPoint.signature as MethodSignature).method.getAnnotation(RedisCachePut::class.java)
        val cacheKey = generateKey(redisCachePut.cacheName, joinPoint, redisCachePut.hasClassAndMethodNamePrefix)
        val cacheTTL = redisCachePut.expireSecond
        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
    ): String {
        val generatedKey = StringUtils.arrayToCommaDelimitedString(joinPoint.args)
        if (hasClassAndMethodNamePrefix) {
            val target = joinPoint.target::class.simpleName
            val method = (joinPoint.signature as MethodSignature).method.name
            return "$cacheName::$target.$method($generatedKey)"
        }
        return "$cacheName::($generatedKey)"
    }
}

여기서 실제 redis에 저장될 키를 만드는 부분이 generateKey 함수이다.

메서드의 파라미터들에 대해서 toString()을 호출해서 key로 만든다.

 

주의사항

아래처럼 캐시를 저장하면 호출시마다 새로운 캐시가 저장된다.

class Person(val id: Long)

class OrderService(){
	
    @RedisCacheable(value = "cacheTest")
    fun findOrderList(person: Person): List<Order> {
    	...
    }

}

그 이유는 toString()이 override 되지 않았기 때문이다. override를 하지 않은 toString()을 호출하면 객체의 주소 값이 출력되기 때문에 객체의 주소로 캐시 키가 만들어진다.

따라서 Person 클래스를 다음과 같이 변경 후 사용해야 한다.

data class Person(val id: Long) : Serializable
   
class Person(val id: Long) : Serializable{
     override fun toString(): String {
        return id.toString()
    }
}

 

결론

캐시에 ttl을 쉽게 적용해 보기 위해서 redisTemplate을 aop로 구현해봤다.

필요에 따라서 ttl을 refresh 하는 로직을 추가하거나 파라미터를 포함하지 않고 특정 key값으로 강제하는 등 원하는 옵션을 구현해도 된다.

간단한 캐시 적용에는 사용하기 좋지만 아무래도 직접 구현하다 보니 기존에 있던 캐시들에 비하면 부족한 부분이 많다. 복잡한 캐싱 로직은 다른 캐시 매니저를 사용하는 것을 고려해보는 것이 좋겠다.