문제
얼마전 회사에서 local cache와 redis를 같이 쓰도록 바뀌었는데, 이 때 CompositeCacheManager를 사용했다.
@Bean
fun cacheManager(): CacheManager =
CompositeCacheManager().apply{
setCacheManagers(listOf(getCaffeineCacheManager(), getRedisCacheManager))
}
fun getCaffeineCacheManager(): SimpleCacheManager =
SimpleCacheManager().apply{
setCaches(...)
}
fun getRedisCacheManager(): RedisCacheManager =
RedisCacheManager.builder(...)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(60))
.withInitialCacheConfigurations(...)
.build()
자세한 설정 코드는 생략했는데, 위와 같은 코드였다.
참고로 나는 이 배치에서 사용하는 캐시의 이름들과 ttl을 미리 설정해두고, withInitialCacheConfigurations()를 통해 캐시들을 설정한다.
API에서 캐시를 사용할 때 개발, 운영, 로컬 전부 문제가 없었다. 문제는 batch에서 터졌다.
배치에서 cpu 점유율
배치중에 캐시를 사용하는 배치가 있는데.. 도커 개발 pod에 올려서 배치를 테스트하던 도중에 문제가 생겼다. 캐시를 사용한 이유는 한 메서드의 DB접근을 줄이기 위해서 사용했다.
CPU 점유율이 엄청 높게 오르고, 원래 캐시를 사용하면 10분이면 끝나던 배치가 2시간이 걸리는 것이다. 캐시를 사용하지 않은 것과 비슷하게 걸렸다.
따라서 캐시를 분석해보기로 했다.
원인 파악
우선 해당 배치는 멀티쓰레드를 사용하게 되어있다.
CacheManager는 각각 cacheMap을 가지게 되는데.. 여기서 문제가 발생한 것으로 보인다.
CacheManager는 Bean으로 등록시 afterPropertiesSet()을 호출하여 cacheMap에 우리가 위에서 등록한 cacheName과 Cache 객체를 저장한다. Cache 객체는 Cache 정보에 대한 메타데이터를 들고 있다.
하지만 내가 사용한 2개의 CacheManager는 afterPropertySet()을 호출하지 않는다. 그 이유는, 그냥 bean으로 등록하지 않아서이다.
그러면 cacheMap엔 아무것도 없게 되는데, 이 경우 getCache()에서 cacheMap에 해당 (캐시 이름: cache 객체)를 매핑하여 추가한다.
if (missingCache != null) 부분부터 캐시 객체를 cacheMap에 추가하는 부분이다.
근데 이 때 문제가 발생하는데, 이전에 withInitialCacheConfigurations()을 통해 설정해놓은 캐시 ttl이 아니라, 내가 default로 설정해둔 ttl로 캐시가 설정된다.
나는 개별 설정으로 1시간으로 설정해놨었는데, default ttl은 60초로 설정해놨다...
따라서 캐시가 1분마다 사라졌던 것이다.
캐시가 1분마다 사라지니 그것을 다시 DB에 접근하여 가져와야하고... 거기다가 멀티쓰레드를 사용하다보니 CPU 점유율이 치솟고, 캐시를 사용하나마나가 된 것으로 추정된다.
근데 만약 배치의 일정 chunk동안 하나의 key를 통해 계속 접근했더라면...
즉, 계속 key가 1이였다면, 이런 문제는 크게 발생하지 않았을지도 모른다. 조금 느려진정도일 것이다.
근데 로직상 key가 1이 계속 들어오는 게 아니라, key가 1이 처음에 들어와서 캐싱해놨다가 한 10만 청크 뒤에 들어올 수도 있기 때문에... 그 사이에 만약 cache가 ttl 만료되면 다시 DB에 접근해서 가져와야한다.
따라서 캐시가 ttl때문에 무용지물이 되버린것이다.
결론은 afterPropertiesSet()이 제대로 호출되지 않아, 캐시의 대한 설정이 제대로 먹히지 않은 것을 원인으로 볼 수 있겠다.
해결
이를 해결하기 위해선 두가지 방법이 있다
1. afterPropertiesSet() 호출하기
@Bean
fun cacheManager(): CacheManager =
CompositeCacheManager().apply{
setCacheManagers(listOf(getCaffeineCacheManager(), getRedisCacheManager))
}
fun getCaffeineCacheManager(): SimpleCacheManager =
SimpleCacheManager().apply{
setCaches(...)
afterPropertiesSet()
}
fun getRedisCacheManager(): RedisCacheManager {
val cacheManager = RedisCacheManager.builder(...)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(60))
.withInitialCacheConfigurations(...)
.build()
cacheManager.afterPropertiesSet()
return cacheManager
}
모든 캐시매니저의 afterPropertiesSet()을 호출시켜준다. 그러면 캐시 설정이 제대로 되므로 정상 동작한다.
2. bean 등록하기
@Bean
@Primary
fun cacheManager(): CacheManager =
CompositeCacheManager().apply{
setCacheManagers(listOf(getCaffeineCacheManager(), getRedisCacheManager))
}
@Bean("localCacheManager")
fun getCaffeineCacheManager(): SimpleCacheManager =
SimpleCacheManager().apply{
setCaches(...)
}
@Bean("redisCacheManager")
fun getRedisCacheManager(): RedisCacheManager =
RedisCacheManager.builder(...)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(60))
.withInitialCacheConfigurations(...)
.build()
캐시 매니저를 전부 bean등록하여 afterPropertiesSet()을 호출하게 하는 방법이다. CompositeCacheManager()를 사용할 것이기 때문에 @Primary 어노테이션을 줬다.
나는 2번째 방법이 낫다고 생각한다.
해당 메서드는 우리가 임의로 호출할게 아니라 스프링 컨테이너가 bean 등록 과정에서 호출하라고 만들어 놓은 것이기 때문에 설계자의 의도대로 호출하는 게 맞다고 생각하기 때문이다.
'Backend > Spring' 카테고리의 다른 글
spring boot mongo DB LocalDateTime UTC -> KST 한국 시간 전환하기 (0) | 2023.12.16 |
---|---|
Redis Jackson @JsonUnwrapped Unwrapped property requires use of type information 이슈 (0) | 2023.07.07 |