Spring Cache + Redis 등 Remote Cache 에서 곧잘 겪는 이슈

Spring Web MVC 에서 Spring-cache 모듈과 Redis 를 캐시 용도로 사용하고 있습니다. 허나 서버를 업그레이드하여 배포한 후 Remote Cache 에 저장된 데이터가 호환되지 않는 경우가 있습니다.

Redis에 저장된 캐시 데이터는 애플리케이션에서 사용하는 직렬화 방식에 따라 클래스 정보나 객체의 필드 구조를 포함할 수 있습니다. 서버를 업그레이드하면서 클래스명이나 필드 구조가 변경되면, 기존 캐시 데이터를 새로운 애플리케이션에서 역직렬화하지 못하는 문제가 발생할 수 있습니다.

이 문제를 주의해야 하는 이유는 아래와 같습니다.

  1. Compile 타임에 에러를 잡아낼 수 없고 Runtime 에만 발생하는 에러입니다.
  2. Remote Cache 에 데이터가 더럽혀지면 롤백 전략을 적용하기 힘듭니다.

이 문제는 컴파일 단계에서 검출하기 어렵습니다. 서버를 배포하기 전에는 정상적으로 동작하더라도, 기존 캐시 데이터를 읽는 순간 런타임 오류가 발생할 수 있기 때문입니다.

캐시 키에 버전을 포함하거나 배포 시 기존 캐시를 무효화하는 방법으로 문제를 예방할 수 있습니다. 그러나 이미 저장된 캐시 데이터에서 오류가 발생하는 상황에도 대비할 필요가 있습니다.

Spring Cache 에서 CacheErrorHandler 의 사용

문제를 막기 위해서는 CacheErrorHandler 를 이용하여 에러 발생시 자동으로 cache evict 를 할 수 있습니다. 이러면 cache 로 인한 에러가 크게 제한됩니다.

  @Bean
  fun cacheErrorHandler(): CacheErrorHandler {
    return object : CacheErrorHandler {
      val log = logger()
      override fun handleCacheGetError(exception: RuntimeException, cache: Cache, key: Any) {
        log.error("Cache GET error on key=$key, cache=${cache.name}", exception)
        evictOnError(cache, key)
      }

      override fun handleCachePutError(exception: RuntimeException, cache: Cache, key: Any, value: Any?) {
        log.error("Cache PUT error on key=$key, cache=${cache.name}", exception)
        evictOnError(cache, key)
      }

      override fun handleCacheEvictError(exception: RuntimeException, cache: Cache, key: Any) {
        log.error("Cache EVICT error on key=$key, cache=${cache.name}", exception)
        evictOnError(cache, key)
      }

      override fun handleCacheClearError(exception: RuntimeException, cache: Cache) {
        log.error("Cache CLEAR error on cache=${cache.name}", exception)
        try {
          cache.clear()
        } catch (e: Exception) {
          log.warn("Failed to clear cache: ${cache.name}", e)
        }
      }

      private fun evictOnError(cache: Cache?, key: Any?) {
        if (cache == null || key == null) return
        try {
          cache.evict(key)
          log.info("Evicted key=$key from cache=${cache.name} due to error")
        } catch (evictException: Exception) {
          log.warn("Failed to evict key=$key from cache=${cache.name}", evictException)
        }
      }
    }
  }

심플하게 Redisson 을 Spring Cache 로 사용하는 코드

@EnableCaching
@Configuration
class RedissonCacheConfig(
  private val redissonClient: RedissonClient
) {
  @Bean(name = ["cacheManager"])
  fun redissonCacheManager(): CacheManager {
    val config: MutableMap<String, CacheConfig> =
      HashMap()
    config["TTL을 달아야할 키"] = CacheConfig((3 * 60 * 1000).toLong(), 0)
    return RedissonSpringCacheManager(redissonClient, config)
  }
}

해당 Config의 Spring Cache 에서 Redisson 사용시 문제점

RedissonSpringCacheManager를 사용하는 과정에서 예상하지 못한 문제가 발생했습니다. Redisson 내부에서 발생한 예외가 Spring Cache의 CacheErrorHandler로 전달되지 않아, 등록해 둔 오류 처리 콜백이 실행되지 않았습니다.

제가 겪은 상황은 서버를 업그레이드하면서 캐시 대상 클래스의 필드 구조가 변경된 경우입니다. Redis에는 이전 버전의 객체가 저장되어 있었고, 새로운 서버에서 해당 데이터를 역직렬화하는 과정에서 오류가 발생했습니다.

이때 발생한 역직렬화 오류를 CacheErrorHandler에서 처리하려 했지만, 등록해 둔 콜백은 실행되지 않았습니다.

Spring Cache는 캐시 작업 중 발생한 예외를 CacheErrorHandler로 전달할 수 있도록 설계되어 있습니다. 그러나 제가 사용한 환경에서는 Redisson의 역직렬화 오류가 해당 처리 경로까지 전달되지 않았습니다.

따라서 Redisson 내부에서 발생한 예외를 별도로 처리할 방법이 필요했습니다.

해결책

SafeRedissonCache 를 이용해서 RedissonSpringCacheManager 내부 동작시 발생한 에러를 CacheErrorHandler 에게 전달해줍니다.

@EnableCaching
@Configuration
class RedissonCacheConfig(
  private val redissonClient: RedissonClient
) {
  @Bean(name = ["cacheManager"])
  fun redissonCacheManager(cacheErrorHandler: CacheErrorHandler): CacheManager {
    val config: MutableMap<String, CacheConfig> =
      HashMap()

    return SafeRedissonCacheManager(redissonClient, config, cacheErrorHandler)
  }

  @Bean
  fun cacheErrorHandler(): CacheErrorHandler {
    return object : CacheErrorHandler {
      val log = logger()
      override fun handleCacheGetError(exception: RuntimeException, cache: Cache, key: Any) {
        log.error("Cache GET error on key=$key, cache=${cache.name}", exception)
        evictOnError(cache, key)
      }

      override fun handleCachePutError(exception: RuntimeException, cache: Cache, key: Any, value: Any?) {
        log.error("Cache PUT error on key=$key, cache=${cache.name}", exception)
        evictOnError(cache, key)
      }

      override fun handleCacheEvictError(exception: RuntimeException, cache: Cache, key: Any) {
        log.error("Cache EVICT error on key=$key, cache=${cache.name}", exception)
        evictOnError(cache, key)
      }

      override fun handleCacheClearError(exception: RuntimeException, cache: Cache) {
        log.error("Cache CLEAR error on cache=${cache.name}", exception)
        try {
          cache.clear()
        } catch (e: Exception) {
          log.warn("Failed to clear cache: ${cache.name}", e)
        }
      }

      private fun evictOnError(cache: Cache?, key: Any?) {
        if (cache == null || key == null) return
        try {
          cache.evict(key)
          log.info("Evicted key=$key from cache=${cache.name} due to error")
        } catch (evictException: Exception) {
          log.warn("Failed to evict key=$key from cache=${cache.name}", evictException)
        }
      }
    }
  }
}

class SafeRedissonCacheManager(
  redissonClient: RedissonClient,
  cacheConfigMap: MutableMap<String, CacheConfig>,
  private val errorHandler: CacheErrorHandler
) : RedissonSpringCacheManager(redissonClient, cacheConfigMap) {
  override fun getCache(name: String): Cache? {
    val cache = super.getCache(name)
    return if (cache != null) {
      SafeRedissonCache(cache, errorHandler)
    } else {
      null
    }
  }
}


class SafeRedissonCache(
  private val delegate: Cache,                // 실제 Redisson Cache 객체
  private val errorHandler: CacheErrorHandler // Spring CacheErrorHandler 인스턴스
) : Cache {

  override fun getName(): String = delegate.name

  override fun getNativeCache(): Any = delegate.nativeCache

  override fun get(key: Any): Cache.ValueWrapper? = try {
    delegate.get(key)
  } catch (ex: RuntimeException) {
    errorHandler.handleCacheGetError(ex, this, key)
    null
  }

  override fun <T : Any?> get(key: Any, valueLoader: Callable<T?>): T? = try {
    delegate.get(key, valueLoader)
  } catch (ex: RuntimeException) {
    errorHandler.handleCacheGetError(ex, this, key)
    try {
      valueLoader.call()
    } catch (loaderEx: Exception) {
      throw RuntimeException("Value loader failed for key=$key", loaderEx)
    }
  }

  override fun <T : Any?> get(key: Any, type: Class<T>?): T? = try {
    delegate.get(key, type)
  } catch (ex: RuntimeException) {
    errorHandler.handleCacheGetError(ex, this, key)
    null
  }

  override fun put(key: Any, value: Any?) = try {
    delegate.put(key, value)
  } catch (ex: RuntimeException) {
    errorHandler.handleCachePutError(ex, this, key, value)
  }

  override fun evict(key: Any) = try {
    delegate.evict(key)
  } catch (ex: RuntimeException) {
    errorHandler.handleCacheEvictError(ex, this, key)
  }

  override fun clear() = try {
    delegate.clear()
  } catch (ex: RuntimeException) {
    errorHandler.handleCacheClearError(ex, this)
  }
}

이제 Spring Cache 와 Redis 에서 발생한 문제를 RuntimeException 으로 처리할 수 있게 되었습니다.

참고

https://choieungi.github.io/posts/spring-boot-cahce-error-handling/