본문 바로가기

Spring/Ticketing 프로젝트

공연 조회 API에 캐싱 적용하기

반응형

개요

  • 이전글에서 공연 조회 API에 대해 병목지점을 파악하고, 성능 테스트를 해보았습니다. 이번글에서는 공연 정보 조회 API에 캐시를 적용하여 조회 성능을 더욱 올려보도록 하겠습니다.

 

캐시를 적용한 이유

  • 현재 공연 조회 API는 별도의 검색 기능을 제공하지 않고, 오로지 페이지 네이션 기능만을 제공합니다. 이렇게 사용자에게 보여지는 데이터가 일정하고 변경이 많지 않기 때문에 캐시를 적용하는 것이 적절하다고 판단하였습니다.

 

 

구조 설계

  • 캐시는 Local Cache, Global Cache(Redis)를 모두 사용할 것인데요, 이렇게 두가지의 캐시를 아래의 전략으로 사용할 예정입니다.
    1. Local Cache는 TTL을 짧게 주어 데이터가 자주 변경되게 함.
    2. Global Cache는 TTL을 길게 주어 데이터가 오래 유지되도록 함.
    3. Local Cache와 Global Cache를 Composite 패턴으로 묶어서 제공(L1, L2 캐시 구조와 유사하게)
  • 그 이유는 Local Cache는 조회 성능이 빠르지만 Global Cache보다 저장할 수 있는 용량이 제한적이고, Global Cache는 조회 성능이 Local Cache보다는 느리지만 확장성이 좋기 때문에 용량을 무제한 확장 가능합니다.
  • 다만 Global Cache는 백엔드 인스턴스가 여러개여야 의미가 있기 때문에, 인스턴스가 하나일 경우에는 Local Cache만 사용하는 것이 더 효율적일 것이라고 생각합니다.

LocalCacheManager, GlobalCacheManager 빈 등록

  • 먼저 두 캐시 매니저를 모두 사용하기 위해선 캐시 매니저를 등록해야 합니다.
@Bean(LOCAL_CACHE_MANAGER_NAME)
fun localCacheManager(): CacheManager {
    val caches = CacheType.entries.map {
        CaffeineCache(
            it.cacheName,
            Caffeine.newBuilder()
                .recordStats()
                .expireAfterWrite(it.localTtl)
                .maximumSize(20).build()
        )
    }

    val cacheManager = SimpleCacheManager()
    cacheManager.setCaches(caches)

    return cacheManager
}
enum class CacheType(val cacheName: String, val localTtl: Duration, val globalTtl: Duration) {
    PRODUCT("product", Duration.ofMinutes(10), Duration.ofHours(1));

    companion object {
        const val PRODUCT_CACHE_NAME = "product"

        fun getCacheNames() = values().map { it.cacheName }.toSet()
    }
}
  • 먼저 Local Cache Manager 인데요, CacheType은 캐시 이름 정보와 TTL 정보를 저장하고 있다가 LocalCache Manager에 캐시를 등록할 때 제공합니다.

 

    @Bean(GLOBAL_CACHE_MANAGER_NAME)
    fun globalCacheManager(): CacheManager {
        val cacheConfig = RediscacheConfiguration()

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory)
            .cacheDefaults(cacheConfig)
            .withInitialCacheConfigurations(typedGlobalCacheConfiguration())
            .build()

    }

    /**
     *  Redis 캐시 설정 구성(Key : String, Value : JSON, TTL : 30m)
     */
    private fun RediscacheConfiguration(): RedisCacheConfiguration {
        val keySerializer = RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())
        val valueSerializer = getValueSerializer()

        val cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(keySerializer)
            .serializeValuesWith(valueSerializer)
            .entryTtl(Duration.ofMinutes(30L))
        return cacheConfig
    }

    private fun getValueSerializer(): RedisSerializationContext.SerializationPair<Any> {
        // objectMapper가 모든 JSON을 직렬화/역직렬화하도록 설정
        val typeValidator = BasicPolymorphicTypeValidator.builder()
            .allowIfBaseType(Any::class.java)
            .build()

        // Jackson ObjectMapper 설정(DateTime 처리, Kotlin 처리)
        val objectMapper = ObjectMapper().apply {
            registerModule(JavaTimeModule())
            registerModule(KotlinModule.Builder().build())
            activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL)
        }

        val jsonSerializer = GenericJackson2JsonRedisSerializer(objectMapper)

        return RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)
    }

    private fun typedGlobalCacheConfiguration(): Map<String, RedisCacheConfiguration> {
        return CacheType.values().associateBy(
            keySelector = { it.cacheName },
            valueTransform = { RediscacheConfiguration().entryTtl(it.globalTtl) }
        )
    }
  • Redis 설정은 조금 복잡했는데요, Value 값이 ObjectMapper를 사용하여 JSON으로 구성되는데, Date 타입의 필드가 포함되면 JSR-310이라는 라이브러리를 추가해주고, JavaTimeModule을 등록해 주어야 했습니다. 코틀린도 마찬가지로 KotlinModule을 등록해주어야 한다고 하네요.
  • 또 마찬가지로 typedGlobalCacheConfiguration 메서드를 보시면 CacheType에 이름과 TTL을 가져와서 캐시를 만드는 것을 확인할 수 있습니다.

사용하기

  • 이렇게 두개의 빈을 설정하면 캐시를 사용할 수 있습니다. 저는 Local Cache와 Global Cache를 모두 사용해 줄것이라고 했기 때문에 아래와 같이 작성을 해주었습니다.
@Caching(
    cacheable = [
        Cacheable(
            cacheManager = CacheConfig.LOCAL_CACHE_MANAGER_NAME,
            cacheNames = ["product"],
            key = "(#cursorInfoDto.cursor ?: 'first_page') + '_' + #cursorInfoDto.limit"
        ),
        Cacheable(
            cacheManager = CacheConfig.GLOBAL_CACHE_MANAGER_NAME,
            cacheNames = ["product"],
            key = "(#cursorInfoDto.cursor ?: 'first_page') + '_' + #cursorInfoDto.limit")
            )
     ]
)
fun search(cursorInfoDto : CursorInfoDto){
    // ...
}

 

 

Composite Pattern으로 개선하기

  • 근데 위 코드를 보면 코드가 굉장히 깁니다.. 만약 위처럼 두가지의 캐시매니저를 모두 사용하고 싶다면 계속 저렇게 적어 주어야 하는걸까요?
  • Spring에서는 이런 문제를 해결해 주고자 CompositeCacheManager를 제공해 주는데요, 아래와 같이 사용이 가능합니다.
    @Primary
    @Bean(COMPOSITE_CACHE_MANAGER_NAME)
    fun compositeCacheManager(
        localCacheManager: CacheManager,
        globalCacheManager: CacheManager
    ): CacheManager{
        return CompositeCacheManager(
            localCacheManager, globalCacheManager
        )
    }

 

 

CompositeCacheManager 상속해서 기능 변경하기

  • 근데 아래의 테스트 코드를 작성했는데, 테스트 코드가 실패를 했습니다.
given("search 메서드가 Cache가 적용되었을 때 - CompositeCacheManger 테스트"){
    val performances =
        PerformanceTestDataGenerator.createPerformanceGroupbyRegion(performanceCount = 5)

    every { performanceReader.findPerformanceEntityByCursor(any()) } returns performances
    every { performanceReader.findPerformanceStartAndEndDate(any()) } returns performances.map { performance ->
        PerformanceStartEndDateResult(
            performance.id,
            performance.performanceDateTime.minOf { it.showTime },
            performance.performanceDateTime.maxOf { it.showTime })
    }

    `when`("동일한 파라미터로 2번 이상 검색시") {

        val cursorDto = CursorInfoDto(limit = 5)

        performanceService.search(cursorDto)
        performanceService.search(cursorDto)

        then("두개의 CacheManager에서 모두 값을 조회한다.") {
            verify(exactly = 2) { localCacheManager.getCache(CacheType.PRODUCT_CACHE_NAME) }
            verify(exactly = 2) { globalCacheManager.getCache(CacheType.PRODUCT_CACHE_NAME) }
        }
    }

}
  • 대충 오류 내용은 localCacheManager에만 cache를 조회하고, globalCacheManager은 조회 시도 자체를 안했다고 하네요.
  • 이 문제는 CompositeCacheManager의 구현체에서 원인을 찾을 수 있었습니다.
@Override
@Nullable
public Cache getCache(String name) {
    for (CacheManager cacheManager : this.cacheManagers) {
        Cache cache = cacheManager.getCache(name);
        if (cache != null) {
            return cache;
        }
    }
    return null;
}
  • 확인해 보니 Cache Name으로 순차적으로 CacheManager들에게 Cache 이름을 조회하고, 조회에 성공한 첫번째 CacheManager의 캐시만을 반환한다는 것입니다.
  • 하지만 저희는 CacheType의 모든 Type의 Cache를 등록해놓고 이를 모두 등록하기 때문에 두 캐시 매니저에는 동일한 name들을 가지고 있어서, LocalCacheManager와 GlobalCacheManager 중 먼저 for문을 도는 캐시만 반환이 되는 것이였습니다.
enum class CacheType(val cacheName: String, val localTtl: Duration, val globalTtl: Duration) {
    PRODUCT("product", Duration.ofMinutes(10), Duration.ofHours(1));

    companion object {
        const val PRODUCT_CACHE_NAME = "product"

        fun getCacheNames() = values().map { it.cacheName }.toSet()
    }
}
  • 그러므로 이를 해결하기 위해선 CompositeCacheManager을 상속한 CustomCompositeCacheManager를 만들어서 조회되는 캐시를 모두 묶어서 반환하도록 기능을 변경해 보겠습니다.
class CustomCompositeCacheManager(
    private val cacheManagers : List<CacheManager>
) : CompositeCacheManager(){

    init {
        super.setCacheManagers(cacheManagers)
    }

    override fun getCache(name: String): Cache? {
        val foundCaches = cacheManagers.mapNotNull { it.getCache(name) }

        if (foundCaches.isEmpty()) {
            return null
        }

        return CompositeCache(name, foundCaches)
    }
}

CompositeCacheManager의 getCache만 상속한 것인데요, foundCache로 Null이 아닌 Cache들을 찾아서 CompositeCache라는 커스텀 캐시에 던져줍니다.

 

internal class CompositeCache(
    private val name: String,
    private val caches: List<Cache>
) : Cache {

    override fun getName(): String = name

    override fun getNativeCache(): Any = this

    override fun get(key: Any): Cache.ValueWrapper? {
        return caches.firstNotNullOfOrNull { it.get(key) }
    }

    override fun <T : Any?> get(key: Any, type: Class<T>?): T? {
        return caches.firstNotNullOfOrNull { it.get(key, type) }
    }

    override fun <T : Any?> get(key: Any, valueLoader: Callable<T>): T? {
        val value = caches.firstOrNull()?.get(key, valueLoader)
        if (value != null) {
            caches.drop(1).forEach { cache ->
                cache.put(key, value)
            }
        }
        return value
    }

    override fun put(key: Any, value: Any?) {
        // 모든 캐시에 값을 저장
        caches.forEach { it.put(key, value) }
    }

    override fun evict(key: Any) {
        // 모든 캐시에서 해당 키의 값을 제거
        caches.forEach { it.evict(key) }
    }

    override fun clear() {
        // 모든 캐시를 초기화
        caches.forEach { it.clear() }
    }
}
  • 다음으로 CompositeCache 구현인데요, 간단하게 get하면 순차적으로 찾아서 먼저 찾는 것을 반환, put하면 모두 저장 등 묶어서 제공하면 됩니다.
반응형