본문 바로가기
개발 지식/Spring Framework

[Spring] Coroutine Suspend Cache Hit 가 되지 않다.

by 에르주 2023. 2. 11.
반응형

https://erjuer.tistory.com/131

[Spring] Caffeine Cache Config 설정하기

Caffeine Cache Config값은 두가지의 방법으로 설정할 수 있다. 1) application.yml -> yml에 등록만 해주면 spring이 알아서 cache 등록을 해준다. 2) Config Custom Bean 생성 -> 캐시가 여러개 일 경우 각각의 Config Poli

erjuer.tistory.com

에서 Caffeine 캐시를 등록하고 이제 캐시를 활용해보고자 하였다.

실무 개발을 진행하며서 Spring Boot에서 제공하는 어노테이션 @Cacheable("")를 사용해여 DB 조회를 매번 하는 것이 아니라 캐시를 통해서 조회 쿼리를 최소화 하려고 하였으나 hit 되지 않고 자꾸 DB를 조회하는 로직이 수행되었다.


Cache가 적용되는 순서를 살펴보면

  • @Cacheable이 설정된 함수 호출시 Cache 내 Key값이 있는지 확인한다
    • Key값이 있으면 그 Key값에 대한 데이터를 반환한다. ->
    • Kery값이 없으면 Key를 새로 생성한다.
      • Simplekeygenerator를 통해 Key를 생성한다,.
      • 생성된 Key값으로 데이터를 Cache 영역에 저장한다. ->



Cache가 hit 되지 않는 이유는 간단하다.

Key값이 매핑이 되지 않는다는 것

 

비동기 함수 즉 suspend 함수를 사용하면서 이런 증상이 발생했는데 그 이유는

suspend 함수를 complie 하면 기존 파라미터외 Continuation<T>가 추가 된다.


CPS style로 바뀐다. (Continuation-passing style) →
suspend 함수는 컴파일 하게 되면 switch case형태의 바이트코드가 생성되며 이는 코루틴의 중단 재개 시점을 알 수 있도록 하는 파라미터가 추가 된다. (단순히 중단과 재개시점을 파악하려는 flag값으로 이해하면 쉽다.)


코드로 확인해보자!

캐시값을 저장하면서 Key 생성하는 부분은 다음과 같다.

key generate 하는 로직

param이 두개 표시 된다.


Debug를 찍어보면 params가 두개 찍히는 것을 확인 할 수 있다.
첫번째는 그 함수의 파라미터로 설정한 프로퍼티였고 또 다른 하나는 Continuation at... 을 확인 할 수 있으며 이는 컴파일이 추가된 Continuation 프로퍼티라는 것을 확인 할 수 있다.

즉 suspend 함수에 @Cacheable 어노테이션을 걸어 캐시를 저장한다고 해도 Continuation이 매번 새롭게 생성 되면서 key값 또한 새롭게 생성 된 것이다.

모자이크는 내부 파라미터 때문에,..

최종 키값을 확인해보면 Continuation이 추가된 Key값을 확인 할 수 있다.



그렇다면 해결방법은?

자동으로 Key값을 생성하는 것이 아닌 내가 직접 Key값을 생성하는 것이다. 즉 Continuation을 제외한 내가 설정한 파라미터 값으로만 Key값을 생성할 수 있도록 설정한다.

KeyGernator를 상속받아서 customKeyGenerator를 생성하였다.
params를 넘겨 받을 때 마지막에 위치하는 Continuation 객체를 제외한 나머지를 list형태로 만들어 hashcode를 생성하고 이를 Key값으로 생성하였다.

(물론 method가 suspend 여부에 따라 마지막 파라미터값을 삭제 또한 설정하면 더 좋다. 일단 예시로 간단히 표현하였다.)

마지막 파람즈의 삭제
bean 등록


그리고 "suspendKeyGenerator" Bean등록을 하고 @Cacheable 파라미터 내 KeyGerator를 지정하면 된다.

// BaseService
@Cacheable(value = ["baseCache"], keyGenerator = "suspendKeyGenerator")
suspend fun getbaseList(form: baseForm): MutableList<Base> =
    baseRepository.getBaseList(form).toList()


끝!


인줄 알았으나 해당 값을 적용하고 테스트 진행 시 최초 요청은 DB를 조회하지만
재조회 했을 경우에는 무한 루프 즉 결과 값이 나오지 않고 기약을 알 수 없는 응답 대기하게 된다.
-> 엇.. Cache 영역 안에 있는 데이터를 부를 때 뭔가 오류가 발생한게 아닐까?

그 이유를 이제 key값 생성이 아니라 key값 생성 후 데이터를 저장 그리고 찾을 때의 로직을 살펴보자.

캐시값 최초 저장

위는 키값을 생성하고 최초로 데이터를 캐시영역안에 저장할 때 실행되는 로직이다.
여기서 name ="COROUTINE_SUSPEND" 라는 것을 확인 할 수 있는데 이는 DB값을 조회한 결과값이 아닌 "COROUTINE_SUSPEND" 값이 저장되는 확인 할 수 있다.


다시 조회 했을 경우 cache hit는 했지만 codeValue값이 COROUTINE_SUSPEND값이라는 것을 확인 할 수 있다.

cache hit 했을 경우


구체적인 원인은 CacheInterceptor에서 캐시 저장 및 불러올 때 @cacheable이 달린 함수 상태가 비동기 및 suspend 인지 모르고 있기 때문이다.
그 때문에 비동기 리턴 타입을 인식하지 못하고 어플리케이션이 일시 중지 상태가 되는 것이다.
(https://github.com/micronaut-projects/micronaut-cache/issues/183)


이에 따라
나는 Deferred 객체를 활용하여 아예 그 suspend의 함수의 비동기 상태를 객체로 저장하였다.

// baseComponent.kt

@Cacheable(value = ["baseCache"], keyGenerator = "suspendKeyGenerator")
suspend fun getBaseList(form: baseForm): Deferred<Base> = CoroutineScope(Dispatchers.Default).async {
    baseRepository.getBaseList(form)
}


// baseService.kt
fun retriveBaseList() ; String {

 	val caffeineCache = cacheManager.getCache("Contents") as CaffeineCache
	val key = keyGenerator.generate(#{클래스명}, #{메소드명}, form)
	baseComponent.getBaseList(form).await() // 캐시 데이터 저장 및 불러오기
   
    return "성공!"
}


이라고 하면 끝!


이지만 또 끝이 아니다.

왜냐하면 위의 코드에서는 문제점이 하나 있다.
비동기로 동작하다가 실제적인 데이터는 await 함수가 실행 시값을 가져올 수 있는데 혹시 await 했더니 NPE 또는 에러가 발생한다면..
?

: Deferred 객체를 캐시 데이터로 저장했기 때문에 저장 당시에 해당 데이터가 에러인지는 판단할 수 없으며 (왜냐하면 await해야 그 데이터를 확인할 수 있으니까) 혹시나 실제 데이터 조회 시 에러가 발생한다면 그 에러 자체를 데이터로 저장한다.

예륻 들면 'Key" : #{key}, "value" : Null point Exception 와 같이 저장되어 Exception이 터져버린다.

그러므로 정상적인 데이터 일 때 데이터를 저장 할 수 있도록 로직을 일부 수정해야 한다.

try {
    val caffeineCache = cacheManager.getCache("Contents") as CaffeineCache
    val key = keyGenerator.generate(#{클래스명}, #{메소드명}, form)
    caffeineCache.nativeCache.getIfPresent(key)?.let {
        return it as BaseDto
    } ?: run {
        val base = movieComponent.getBaseList(form).await()
        val baseDto = mapper.toBase(base.toList()).toList()
        caffeineCache.put(key, baseDto)
        return baseDto
    }
} catch (e: Exception) {
    throw IllegalStateException()
}



진짜 끝.

P.S 아니면 다음과 같은 오프소스를 활용해도 좋을 듯 하다.
https://github.com/konrad-kaminski/spring-kotlin-coroutine/blob/master/spring-kotlin-coroutine/src/main/kotlin/org/springframework/kotlin/coroutine/cache/CoroutineCacheConfiguration.kt#L81

반응형

댓글