Programing/Framework

[Spring] @Cacheable - null 값도 캐싱이 되고 있었다.

나모찾기 2024. 10. 21. 19:38

발단

백오피스에는 사번이 아닌 이메일을 통해 로그인을 하는 정책을 가지고 있었다.
IP 대신 Domain 주소를 쓰는 것처럼 본인의 사번보다는 이메일이 외우기 쉽기 때문이다.
시스템 내부적으로는 대리키인 사번으로 저장한다.
따라서 이메일과 사번을 매핑이 필요했다.

또한 입사나 퇴사 같은 이벤트가 발생할 경우에만 바뀌므로 자주 업데이트되지 않는 데이터 유형에 속했다.
따라서 latency를 줄이고 불필요한 부하를 줄이기 위하여 DB에서 매번 조회하는 것보다 캐시에서 반환하도록 하고 있었다.
 
신규 개발자 분이 입사하셔서 DB상 매핑 데이터를 추가했는데 계속 매핑이 안되는 현상이 발생했다.
왜 그럴까 고민을 했는데 속으로 혹시 NULL 값에 대한 캐싱 때문일까 생각을 했다.
 

디버깅으로 오픈소스 스터디

오픈소스가 어떻게 동작하는지 분석도 할겸 로컬 환경에서 디버깅 모드로 애플리케이션을 돌려보았다.
@Cacheable 를 통해 선언적인 방식으로 캐싱을 사용하고 있었다.
 
캐싱이 동작하는 구조에는 CacheAspectSupport 라는 인터셉터가 존재하고 있었다.
: spring-context-6.1.13.jar

package org.springframework.cache.interceptor;

public abstract class CacheAspectSupport extends AbstractCacheInvoker
		implements BeanFactoryAware, InitializingBean, SmartInitializingSingleton {
  // ..

  @Nullable
  private Object evaluate(@Nullable Object cacheHit, CacheOperationInvoker invoker, Method method,
      CacheOperationContexts contexts) {

    // Re-invocation in reactive pipeline after late cache hit determination?
    if (contexts.processed) {
      return cacheHit;
    }

    Object cacheValue;
    Object returnValue;

    if (cacheHit != null && !hasCachePut(contexts)) {
      // If there are no put requests, just use the cache hit
      cacheValue = unwrapCacheValue(cacheHit);
      returnValue = wrapCacheValue(method, cacheValue);
    }
    else {
      // Invoke the method if we don't have a cache hit
      returnValue = invokeOperation(invoker);
      cacheValue = unwrapReturnValue(returnValue);
    }

    // Collect puts from any @Cacheable miss, if no cached value is found
    List<CachePutRequest> cachePutRequests = new ArrayList<>(1);
    if (cacheHit == null) {
      collectPutRequests(contexts.get(CacheableOperation.class), cacheValue, cachePutRequests);
    }

 
evaluate 라는 메서드에서 전형적인 캐싱에 대한 알고리즘을 볼 수 있었다.
캐시 적중(hit)가 발생하면 그 값을 반환하고,
벗어나면(miss) 원천 데이터를 가져오는 부분을 통해 가져와서 가져온 값을 가져와서 캐시 저장소에 저장하고 값을 반환하는 구조이다.
 
위의 코드에보면 wrapCacheValue과 unwrapCacheValue 이라는 도우미 메서드를 볼 수 있다.

@Nullable
private Object unwrapCacheValue(@Nullable Object cacheValue) {
  return (cacheValue instanceof Cache.ValueWrapper wrapper ? wrapper.get() : cacheValue);
}

@Nullable
private Object wrapCacheValue(Method method, @Nullable Object cacheValue) {
  if (method.getReturnType() == Optional.class &&
      (cacheValue == null || cacheValue.getClass() != Optional.class)) {
    return Optional.ofNullable(cacheValue);
  }
  return cacheValue;
}

 
캐싱하는 값을 한 번더 감싸는 구조로 볼 수 있었다.
가령 null 값에 대해서는 Optional 타입으로 반환을 하고 있었다.
참고로 optional을 사용하는 방법을 negative caching이라는 용어로 부르고 있음을 알 수 있었다.

컴퓨터 프로그래밍에서, negative cache란 negative response를 저장한 cache를 말한다.
이는 원인이 해결된 후에도 프로그램이 실패를 나타내는 결과를 기억한다는 것을 의미한다.

디버깅 결과

디버깅을 해보니 null 값이 그대로 저장이 되고, 두 번째 부터는 DB까지 가지 않고 바로 null이 반환되는 것을 확인했다.
 

만약 Null 값에 대하여 캐싱을 하지 않으려면?

사용하고 있는 CaffeineCache의 경우 캐시 설정에서 isAllowNullValues 값을 설정할 수 있게 되어 있다.

Return whether this cache manager accepts and converts null values for all of its caches.

하지만 이 설정을 한다고 캐싱이 안되는 것은 아니였다.

오히려 원천 데이터에서 null을 반환하는 경우 아래와 같은 IllegalArgumentException 예외가 발생했다.
에외 메시지)

Cache '캐시 이름' is configured to not allow null values but null was provided

 
이 부분은 스프링의 캐시 지원의  AbstractValueAdaptingCache 클래스에 구현되어 있었다.

package org.springframework.cache.support;

public abstract class AbstractValueAdaptingCache implements Cache {

  private final boolean allowNullValues;
  
  // ..

  protected Object toStoreValue(@Nullable Object userValue) {
    if (userValue == null) {
      if (this.allowNullValues) {
        return NullValue.INSTANCE;
      }
      throw new IllegalArgumentException(
          "Cache '" + getName() + "' is configured to not allow null values but null was provided");
    }
    return userValue;
  }

allowNullValues 설정이 true 인 경우 NullValue.INSTANCE (NullValue) 라는 싱글톤 객체가 저장이 된다.
하지만 그렇지 않은 경우 예외를 던지게 되는 것이다.
 
RedisCache의 경우 설정 빌드에서 disableCachingNullValues() 를 통해 Null 값들을 캐싱하지 않겠다고 설정하면 아래와 같은 메시지를 보여준다.

Avoid storing null via '@Cacheable(unless="#result == null")' or configure RedisCache
 to allow 'null' via RedisCacheConfiguration.
package org.springframework.data.redis.cache;

public class RedisCache extends AbstractValueAdaptingCache {
	// ..
	private Object processAndCheckValue(@Nullable Object value) {

		Object cacheValue = preProcessCacheValue(value);

		if (nullCacheValueIsNotAllowed(cacheValue)) {
			throw new IllegalArgumentException(("Cache '%s' does not allow 'null' values; Avoid storing null"
					+ " via '@Cacheable(unless=\"#result == null\")' or configure RedisCache to allow 'null'"
					+ " via RedisCacheConfiguration").formatted(getName()));
		}

		return cacheValue;
	}

 
다시 말하면 @Cacheable 애너테이션에 unless로 null값에 대해서는 캐싱을 하지 않겠다고 명시적으로 적으라는 의미이다.
애플리케이션 코드에 해당 설정을 적용해보니 실제로 null 로 반환되는 경우 캐싱이 되지 않았고 매번 DB를 조회를 하는 것으로 확인했다.