반응형
개요
- 이전글에서 공연 조회 API에 대해 병목지점을 파악하고, 성능 테스트를 해보았습니다. 이번글에서는 공연 정보 조회 API에 캐시를 적용하여 조회 성능을 더욱 올려보도록 하겠습니다.
캐시를 적용한 이유
- 현재 공연 조회 API는 별도의 검색 기능을 제공하지 않고, 오로지 페이지 네이션 기능만을 제공합니다. 이렇게 사용자에게 보여지는 데이터가 일정하고 변경이 많지 않기 때문에 캐시를 적용하는 것이 적절하다고 판단하였습니다.
구조 설계
- 캐시는 Local Cache, Global Cache(Redis)를 모두 사용할 것인데요, 이렇게 두가지의 캐시를 아래의 전략으로 사용할 예정입니다.
- Local Cache는 TTL을 짧게 주어 데이터가 자주 변경되게 함.
- Global Cache는 TTL을 길게 주어 데이터가 오래 유지되도록 함.
- 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하면 모두 저장 등 묶어서 제공하면 됩니다.
반응형
'Spring > Ticketing 프로젝트' 카테고리의 다른 글
공연 조회 API에 캐싱을 적용하고 성능 테스트하기 (0) | 2024.11.16 |
---|---|
공연 조회 API 성능 측정 및 개선 사안 찾아보기 (0) | 2024.11.16 |
공연 예매 시 Proxy를 사용해 DB 부하 줄이기 (2) | 2024.11.16 |
공연 정보 조회 API 쿼리 분석하고 개선하기 (0) | 2024.11.15 |
Spring + Grafana, Loki, Prometheus로 모니터링 시스템 구축하기 (0) | 2024.11.15 |