[Spring] 로컬 캐시와 레디스를 함께 사용하는 2-Level Cache(2계층 캐시) 구현하기
1. 로컬 캐시와 레디스를 함께 사용하는 2-Level Cache(2계층 캐시) 구현하기
아래의 내용을 제대로 이해하기 위해서는 스프링이 제공하는 Cache와 CacheManager에 대한 이해가 필요하다. 따라서 이전 포스팅을 먼저 참고하도록 하자.
[ 요구사항 정리 ]
이번에 구현하고자 하는 기능은 스프링의 @Cacheble을 활용하여 로컬 캐시와 분산 캐시를 함께 사용하는 2-Level Cache(2계층 캐시)를 구현하는 것이다. 2계층 캐시란 동일한 캐시 요청(EmailToNameCache)에 대해 먼저 로컬 캐시(LocalCacheManager)를 조회하여 있으면 반환하고, 없으면 분산 캐시(RedisCacheManager)를 조회하여 그 결과를 반환하는 것이다. 이를 통해 분산 캐시에 대한 트래픽을 보호할 수 있고, 분산 캐시가 장애난 경우에도 로컬 캐시를 통해 서비스에는 문제가 없도록 만들 수 있다.
기존의 스프링이 제공하는 구현으로는 @Cacheable을 통해 2-Level Cache를 적용할 수 없으며, Local 또는 Redis만 적용되는 캐시를 적용할 때도 CacheManager를 지정해주어야 한다.
@Cacheable(cacheNames = [CacheKey.HOLIDAYS], key = "#year", cacheManager = "localCacheManager")
override fun getHolidays(year: Int): List<LocalDate> {
return fetchDataApiPort.getHolidays(year)
}
하지만 @Cacheable을 통해 필요한 경우에만 2-Level Cache를 적용하며, 일부 캐시는 Redis만 또는 Local 만을 적용할 수 있도록 하고, 추가적으로 CacheManager를 지정할 필요가 없도록 개선하고자 한다. 스프링은 Cache와 CacheManage 인터페이스를 제공하므로, 우리의 필요에 맞게 해당 기능을 확장해보도록 하자.
@Cacheable(cacheNames = [CacheKey.HOLIDAYS], key = "#year")
override fun getHolidays(year: Int): List<LocalDate> {
return fetchDataApiPort.getHolidays(year)
}
[ 2-Level Cache (2계층 캐시) 구현하기 ]
CacheType 구현
먼저 적용할 캐시가 로컬 캐시 혹은 분산 캐시인지, 아니면 2계층 복합 캐시(Composite) 인지 타입을 구분한다. 그리고 CacheType에 따라 Cache를 CacheManager에 등록해주는데, COMPOSITE인 경우, LocalCache와 RedisCache 모두 등록해줄 것이다. 따라서 캐시의 타입을 나타내는 enum 클래스를 먼저 생성해주도록 하자.
enum class CacheType(
private val desc: String,
) {
LOCAL("로컬 캐시만 적용함"),
GLOBAL("분산 캐시만 적용함"),
COMPOSITE("로컬 캐시와 분산 캐시를 모두 적용함"),
}
그리고 CacheManager에 등록할 CacheName, TTL, CacheType을 갖는 캐시 그룹을 관리하는 클래스를 생성해주도록 하자. 해당 Enum을 바탕으로 Cache 객체를 생성하여 CacheManager에 등록한다.
import java.time.Duration
enum class CacheGroup(
val cacheName: String,
val expiredAfterWrite: Duration,
val cacheType: CacheType,
) {
LOCAL_ONLY(
CacheName.LOCAL_ONLY,
Duration.ofMinutes(10),
CacheType.LOCAL,
),
GLOBAL_ONLY(
CacheName.GLOBAL_ONLY,
Duration.ofMinutes(10),
CacheType.GLOBAL,
),
COMPOSITE_ALL(
CacheName.COMPOSITE,
Duration.ofMinutes(10),
CacheType.COMPOSITE,
),
;
companion object {
private val CACHE_MAP = entries.associateBy { it.cacheName }
fun isCompositeType(cacheName: String): Boolean {
return get(cacheName).cacheType == CacheType.COMPOSITE
}
private fun get(cacheName: String): CacheGroup {
return CACHE_MAP[cacheName]
?: throw NoSuchElementException("$cacheName Cache Name Not Found")
}
}
}
object CacheName {
const val LOCAL_ONLY = "local"
const val GLOBAL_ONLY = "global"
const val COMPOSITE = "composite"
}
LocalCache와 GlobalCache를 갖는 CompositeCache 구현
그 다음으로는 여러 개의 캐시를 관리할 수 있는 새로운 Cache 구현체가 필요하다. 해당 캐시는 동일한 CacheName에 대해 로컬 캐시 객체와 레디스 캐치 객체를 리스트로 갖는다.
이를 위해서 내부적으로 컴포지트 패턴(Composite Pattern)을 활용하며, 구현한 코드는 다음과 같다.
조회 시에는 리스트에서 먼저 찾은 값을 반환하고, 값이 존재하는 경우에 LocalCacheManager에 값을 갱신한다. 값의 갱신 및 삭제 요청은 로컬 캐시와 레디스 캐시에 모두 적용한다.
data class CompositeCache(
private val caches: List<Cache>,
private val updatableCacheManager: UpdatableCacheManager,
) : Cache {
override fun getName(): String {
return caches.first().name
}
override fun getNativeCache(): Any {
return caches.map { it.nativeCache }
}
override fun get(key: Any): Cache.ValueWrapper? {
for (cache in caches) {
val valueWrapper = cache.get(key)
val value = valueWrapper?.get()
if (valueWrapper != null && value != null) {
updatableCacheManager.putIfAbsent(cache, key, value)
return valueWrapper
}
}
return null
}
override fun <T : Any?> get(key: Any, type: Class<T>?): T? {
for (cache in caches) {
val value = cache.get(key, type)
if (value != null) {
updatableCacheManager.putIfAbsent(cache, key, value)
return value
}
}
return null
}
override fun <T : Any?> get(key: Any, valueLoader: Callable<T>): T? {
for (cache in caches) {
val value = cache.get(key, valueLoader)
if (value != null) {
updatableCacheManager.putIfAbsent(cache, key, value)
return value
}
}
return null
}
override fun put(key: Any, value: Any?) {
for (cache in caches) {
cache.put(key, value)
}
}
override fun evict(key: Any) {
for (cache in caches) {
cache.evict(key)
}
}
override fun clear() {
for (cache in caches) {
cache.clear()
}
}
}
CompositeCache 관리를 위한 CompositeCacheCacheManager 구현
그 다음에는 해당 캐시를 관리하기 위한 CacheManager가 필요하다. 우리는 로컬 캐시를 위한 CacheManager와 레디스 캐시를 위한 CacheManager 두 종류를 기본적으로 사용해야 하는데, 이들에 컴포지트 패턴을 적용하여 하나의 CacheManager로 관리할 수 있는 CompositeCacheManager가 이미 존재한다.
하지만 우리는 스프링이 제공해주는 기존의 CompositeCacheManager를 사용할 수 없는데, 그 이유는 위에서 직접 구현한 CompositeCache를 지원해야 하기 때문이다. 우리는 CacheManager들을 순회하여 찾은 캐시들을 바탕으로 CompositeCache를 생성하여 반환하는 새로운 CacheManager가 필요하다. 따라서 스프링이 제공해주는 CompositeCacheManager를 상속받고, 캐시 조회 시에 COMPOSITE 타입이라면, 복수의 캐시 매니저를 순회하여 Cache 객체 목록을 조회하고, 이를 CompositeCache로 감싸서 반환도록 캐시 조회 기능을 오버라이딩해주도록 하자. 만약 COMPOSITE 타입이 아니라면, 복수의 캐시 매니저를 순회하여 먼저 찾은 경우 이를 반환하면 된다.
class CompositeCacheCacheManager(
private val cacheManagers: List<CacheManager>,
private val updatableCacheManager: UpdatableCacheManager,
) : CacheManager {
private val cacheNames: List<String>
init {
cacheNames = mutableListOf()
for (cacheManager in cacheManagers) {
cacheNames.addAll(cacheManager.cacheNames)
}
}
override fun getCache(name: String): Cache? {
if (CacheName.isCompositeType(name)) {
return CompositeCache(
cacheManagers.mapNotNull { it.getCache(name) },
updatableCacheManager,
)
}
return cacheManagers
.map { it.getCache(name) }
.firstOrNull { it != null }
}
override fun getCacheNames(): MutableCollection<String> {
return cacheNames.toMutableList()
}
}
그 다음으로 로컬 캐시와 레디스 캐시를 관리하는 CacheManager들을 빈으로 등록하고, 이들을 우리가 구현한 CompositeCacheCacheManager로 묶어서 빈으로 등록해주어야 한다.
따라서 먼저 로컬 캐시를 관리하는 CacheManager를 빈으로 등록해주어야 하는데, 기본적으로 스프링이 제공해주는 SimpleCacheManager가 존재한다. 하지만 역시 SimpleCacheManager를 사용할 수 없는데, 왜냐하면 앞선 포스팅에서 살펴보았듯이, 스프링이 제공하는 SimpleCacheManager는 CacheName으로 캐시를 조회하는 경우에 없으면 새로운 캐시를 등록하기 때문이다.
예를 들어, SimpleCacheManager와 RedisCacheManager를 갖는 CompositeCacheCacheManager가 존재한다고 하자. 그러면 CacheType이 GLOBAL인 경우, CompositeCacheCacheManager 구현을 따라 순차적으로 CacheManager를 탐색하여 CacheName을 바탕으로 캐시를 찾을 것이다. 하지만 스프링이 제공하는 SimpleCacheManager의 경우, CacheName에 해당하는 캐시 객체가 없다면 이를 등록하여 반환하므로 캐시가 없을 수 없다. 따라서 CacheType이 GLOBAL이라고 하더라도, 항상 SimpleCacheManager에서 캐시가 반환되어 RedisCacheManager에서 Cache 객체를 찾는 상황은 발생하지 않을 것이다.
따라서 CacheName에 해당하는 캐시 객체가 없다면 새롭게 캐시를 등록하지 않고 null을 반환하여 다음의 RedisCacheManager에서 캐시 객체를 찾는 LocalCacheManager가 필요하다. 따라서 이러한 요구사항을 만족하는 새로운 CacheManager를 구현해주도록 하자. 추가적으로 해당 CacheManager는 LocalCache가 없어서 RedisCache로 찾은 경우, update 해주는 메서드도 필요하다. 그래야 다음 요청이 왔을 때에는 LocalCache에서 값을 찾아 반환할 것이다.
class LocalCacheManager(
private val caches: List<Cache> = emptyList(),
) : CacheManager, UpdatableCacheManager {
private var cacheMap: Map<String, Cache> = emptyMap()
@Volatile
private var cacheNames: Set<String> = emptySet()
@PostConstruct
fun initializeCaches() {
val cacheNames = LinkedHashSet<String>(caches.size)
val cacheMap = ConcurrentHashMap<String, Cache>(16)
for (cache in caches) {
val name = cache.name
cacheMap[name] = cache
cacheNames.add(name)
}
this.cacheNames = cacheNames
this.cacheMap = cacheMap
}
@Nullable
override fun getCache(name: String): Cache? {
return cacheMap[name]
}
override fun getCacheNames(): Collection<String> {
return cacheNames
}
override fun putIfAbsent(cache: Cache, key: Any, value: Any) {
val localCache = getCache(cache.name)
localCache?.putIfAbsent(key, value)
}
}
interface UpdatableCacheManager {
fun putIfAbsent(cache: Cache, key: Any, value: Any)
}
Redis의 경우 RedisCacheManager가 존재하므로 이를 그대로 활용해주도록 하자.
그러면 이제 위에서 구현한 클래스들을 바탕으로 캐시에 대한 설정을 진행해주어야 한다. 캐시와 관련된 설정은 다음과 같이 진행해줄 수 있다. 참고로 여기서 RedisSerializer로 자체 구현한 Gzip 기반의 Serializer를 적용하고 있는데, 그 이유는 스프링이 기본 제공하는 JdkSerializationRedisSerializer 클래스의 경우, 패키지와 같은 클래스 정보를 같이 넣어서 관리가 어려운 부분이 있다. 또한 압축을 지원하지 않아서 이러한 부분을 개선한 Json 기반으로 Gzip을 적용한 Serializer를 구현하여 적용하였다. 그리고 마지막으로 우리가 개발한 CompositeCacheCacheManager를 등록하였다.
@Configuration
@EnableCaching
class CacheConfiguration(
private val redisConnectionFactory: RedisConnectionFactory,
) {
@Bean
fun localCacheManager(): LocalCacheManager {
return LocalCacheManager(
caches = CacheGroup.entries
.filter { it.cacheType == CacheType.LOCAL || it.cacheType == CacheType.COMPOSITE }
.map { toCaffeineCache(it) },
)
}
private fun toCaffeineCache(it: CacheGroup): CaffeineCache {
return CaffeineCache(
it.cacheName,
Caffeine.newBuilder()
.expireAfterWrite(it.expiredAfterWrite.seconds, TimeUnit.SECONDS)
.build()
)
}
@Bean
fun redisSerializer(): RedisSerializer<Any> {
val objectMapper = JsonMapper.builder()
.addModules(
listOf(
JavaTimeModule(),
KotlinModule.Builder()
.withReflectionCacheSize(512)
.configure(KotlinFeature.NullToEmptyCollection, true)
.configure(KotlinFeature.NullToEmptyMap, true)
.configure(KotlinFeature.NullIsSameAsDefault, true)
.configure(KotlinFeature.SingletonSupport, true)
.configure(KotlinFeature.StrictNullChecks, true)
.build()
)
)
.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder().allowIfBaseType(Any::class.java).build(),
ObjectMapper.DefaultTyping.EVERYTHING,
)
.configure(MapperFeature.USE_GETTERS_AS_SETTERS, false)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.build()
return GzipRedisSerializer(
clazz = Any::class.java,
objectMapper = objectMapper
)
}
@Bean
fun redisCacheManager(redisSerializer: RedisSerializer<Any>): CacheManager {
val redisCacheConfigurationMap = CacheGroup.entries
.filter { it.cacheType == CacheType.GLOBAL || it.cacheType == CacheType.COMPOSITE }
.associate {
it.cacheName to RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
.entryTtl(it.expiredAfterWrite)
}
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheWriter(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory))
.withInitialCacheConfigurations(redisCacheConfigurationMap)
.enableStatistics()
.build()
}
@Bean
@Primary
fun cacheManager(redisSerializer: RedisSerializer<Any>): CacheManager {
val localCacheManager = localCacheManager()
return CompositeCacheCacheManager(
cacheManagers = listOf(localCacheManager, redisCacheManager(redisSerializer)),
updatableCacheManager = localCacheManager,
)
}
}
[ 결론과 트레이드 오프 ]
결론
이제 @Cacheable을 통해 cacheManager를 지정해주지 않아도 된다. 또한 CacheType에 따라 적합하게 Cache 로직을 처리하며, 필요에 따라 2-Level 캐시 적용이 가능해졌다.
@Cacheable(cacheNames = [CacheKey.HOLIDAYS], key = "#year")
override fun getHolidays(year: Int): List<LocalDate> {
return fetchDataApiPort.getHolidays(year)
}
정합성의 문제
하지만 위의 기능은 정합성을 완전히 보장하지 못한다. 예를 들어 다음과 같이 타임라인이 잡힌다면, 정합성이 깨지는 부분이 존재할 것이다.
- 레디스에 “MangKyu”라는 캐시 데이터가 존재함
- 1번 쓰레드가 “MangKyu” 캐시 조회를 시도함
- 로컬 캐시에는 존재하지 않아서 레디스에서 캐시를 조회함
- 2번 쓰레드가 “MinKyu”로 캐시 갱신을 시도함
- 2번 스레드가 로컬 캐시와 레디스 캐시를 “MinKyu”로 갱신함
- 1번 쓰레드가 레디스에서 조회한 캐시(”MangKyu”)를 로컬 캐시에 갱신함
- 최신의 캐시 데이터는 “MinKyu”인데 “MangKyu”로 저장됨
온전한 정합성을 위해 로컬 캐시를 갱신하는 부분에 보다 정밀한 로직을 넣으면 이러한 문제를 해결할 수 있을 것이다. 하지만 로컬 캐시를 도입한다는 것부터 이미 정합성에 대한 부분은 어느 정도 내려놓은 것이라고 판단하였고, 위의 구현에서 더는 개선하지 않았다. 따라서 이러한 부분에 대해서 인지한 상태로 사용하도록 하자.
현재 위와 같은 방식으로 레디스에 대한 부하를 줄이고, 구현의 편의를 얻어 운영하고 있습니다.
전체 구현은 GitHub에 올려두었으니 참고 부탁드립니다!