
CacheStampede는 Cache Key 가 만료되었을때, 특정 요청이 순간적으로 몰려서 장애가 유발되는 상황을 지칭@Cacheable에는CacheStampede를 막기 위한 sync 옵션이 존재하고 동시에 오는 요청을 동기화하여 순차적으로 처리함- 해당 옵션을 실행한다고 무조건
CacheStampede를 막아주는것이 아닌 Cache 인터페이스의 구현체의 동작에 위임함- Local Cache 구현체는 멀티 인스턴스 환경에서
CacheStampede를 막을 수 없음- Redis Cache 기본 구현체(
DefaultRedisCacheWriter) 는locking 모드를 설정해줘야CacheStampede를 막을 수 있음DefaultRedisCacheWriter에locking 모드를 설정하면 lock 단위가cache key(cache entry)단위가 아닌cache name단위로 걸리기 때문에 의도하지 않은 병목이 생길 수 있음
Spring 의 Cacheable을 통해 캐시를 사용하면 아래와 같이 사용하곤 한다.
@Cacheable(cacheNames = "products", key = "#productId")
public Product getProduct(Long productId) {
return productRepository.findById(productId)
.orElseThrow();
}
이 코드의 동작을 요약하면 다음과 같다.
"캐시가 있으면 반환하고, 없으면 DB를 조회해서 로컬 캐시에 저장한다"
DB로 몰리는 트래픽을 완화하기 위해 캐시를 사용하는데, 다음과 같은 상황이 발생하기도 한다.
"같은 key에 대해 동시에 수많은 요청이 들어왔고, 마침 cache miss라면?"
그렇게 되면 수많은 요청이 Cache Miss 가 되어 모두 DB를 호출하여 캐시에 저장하게 된다.
이 지점에서 @Cacheable(sync = true) 옵션이 등장한다.
@Cacheable(cacheNames = "products", key = "#productId", sync = true)
public Product getProduct(Long productId) {
return productRepository.findById(productId)
.orElseThrow();
}
이름만 보면 뭔가 synchronized를 걸어줄 것 같다.
그런데 진짜 Spring이 메서드 전체에 lock을 걸어서 순차적으로 요청을 처리해주는걸까?
Cache Stampede는 같은 cache key가 동시에 miss 났을 때, 여러 요청이 한꺼번에 원본 저장소로 몰리는 현상이다.
예를 들어 product:1 캐시가 만료된 순간 아래처럼 요청이 들어온다고 해보자.
Thread-1 -> cache miss -> DB 조회
Thread-2 -> cache miss -> DB 조회
Thread-3 -> cache miss -> DB 조회
Thread-4 -> cache miss -> DB 조회
...
캐시를 둔 이유는 DB 부하를 줄이기 위해서인데, 정작 캐시가 비는 가장 위험한 순간에 DB로 요청이 몰린다.
해당 키가 Cache Hot key(많이 사용하는 키)라면 이 순간이 장애의 시작점이 될 수 있다.

사진 출처 : 토스 기술블로그
sync=true는 이 상황에서 "같은 key에 대한 로딩은 가능하면 한 번만 실행하자"는 의도를 가진 옵션이다.
@Cacheable은 Spring AOP 기반으로 동작한다.
캐시 대상 메서드가 호출되면 아래와 같이 호출된다.
CacheInterceptor(Spring Aop) -> CacheAspectSupport(캐시 설정 판단, Cache Hit/Miss 판별) -> Cache 인터페이스 구현체(Cache 에서 read, write)
( 관련해서 이전에 정리한 글이 있으니 링크를 첨부한다. Spring @Cacheable Deep Dive )
sync = true 옵션을 사용하면 CacheAspectSupport에서 sync 옵션을 판별해서 기존과 다르게 동작한다.
CacheInterceptor(Spring Aop)
-> CacheAspectSupport.execute (sync 옵션 판별)
-> CacheAspectSupport.executeSynchronized (sync 옵션 일때 실행 메서드)
-> AbstractCacheInvoker.doGet(Cache cache, Object key, Callable valueLoader) (cache 구현체에게 data read & sync 옵션 처리 위임 및 에러 처리)
-> cache.get(Object key, Callable valueLoader) (cache 구현체에게 cache hit/miss + synchronize 처리 위임)
핵심 흐름은 대략 이렇다.
// CacheAspectSupport.java L380-L384
private @Nullable Object execute(CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) {
if (contexts.isSynchronized()) {
// Special handling of synchronized invocation
return executeSynchronized(invoker, method, contexts);
}
}
여기서 contexts.isSynchronized()가 true이면 일반적인 findCachedValue -> evaluate -> put 흐름을 타지 않고 executeSynchronized() 를 호출한다.
executeSynchronized() 에서 캐시 관련 설정을 읽고 캐시 구현체에게 캐시 read 로직을 위임한다.
// CacheAspectSupport.java L397-L439
private @Nullable Object executeSynchronized(CacheOperationInvoker invoker, Method method,
CacheOperationContexts contexts) {
CacheOperationContext context = contexts.get(CacheableOperation.class).iterator().next();
Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT);
Cache cache = context.getCaches().iterator().next();
try {
return wrapCacheValue(method, doGet(cache, key,
() -> unwrapReturnValue(invokeOperation(invoker))));
}
catch (Cache.ValueRetrievalException ex) {
ReflectionUtils.rethrowRuntimeException(ex.getCause());
return null;
}
}
핵심은 이 줄이다.
doGet(cache, key, () -> unwrapReturnValue(invokeOperation(invoker)))
여기서 invokeOperation(invoker)가 실제 원본 메서드 호출이다.
즉, 원본 메서드는 바로 실행되는 게 아니라 Callable로 감싸져서 cache 구현체에 전달된다.
조금 더 내려가면 AbstractCacheInvoker.doGet() 이 나온다.
// AbstractCacheInvoker.java L98-L111
protected <T> @Nullable T doGet(Cache cache, Object key, Callable<T> valueLoader) {
try {
// 데이터 조회 부분 Cache 인터페이스 구현체에 위임, 여기서 cache miss 일 경우 원본 메서드 호출도 처리
return cache.get(key, valueLoader);
}
catch (Cache.ValueRetrievalException ex) {
throw ex;
}
catch (RuntimeException ex) {
getErrorHandler().handleCacheGetError(ex, cache, key);
try {
// error 발생하면, 에러 잡아서 원본 메서드 호출(cache 실패시 fallback 로직)
return valueLoader.call();
}
catch (Exception ex2) {
throw new Cache.ValueRetrievalException(key, valueLoader, ex);
}
}
}
cache.get(key, valueLoader) 메서드에서 실제 캐시 로직 + sync 처리 + 원본 메서드 처리를 위임하고 AOP 레벨은 cache.get() 메서드에서 일어나는 에러를 catch 처리만 한다.
그리고 이 메서드가 실제로 동기화된 로딩을 제공하는지는 cache 구현체마다 다르다.
다음의 주석도 Spring의 Cache.get(Object, Callable) 이 의도를 보여준다.
// Cache.java L84-L99
/**
* Return the value to which this cache maps the specified key, obtaining
* that value from {@code valueLoader} if necessary.
*
* If possible, implementations should ensure that the loading operation
* is synchronized so that the specified {@code valueLoader} is only called
* once in case of concurrent access on the same key.
*/
<T> @Nullable T get(Object key, Callable<T> valueLoader);
표현을 보면 must가 아니라 If possible, should에 가깝다.
(인터페이스를 정의할때부터 반드시 synchronized 하게 동작함을 보장하지 않게끔 정의해두었다..!)
즉,
sync=true는 Spring이 직접 synchronized block을 잡는 옵션이라기보다
cache provider에게 "가능하면 같은 key의 valueLoader는 한 번만 실행해줘"라고 요청하는 옵션에 가깝다.
Cache 인터페이스의 구현체의 대표적으로
ConcurrentMapCacheRedisCache 등이 있고 이외에 다른 구현체들이 있지만,
이 두가지 구현체들이 어떻게 동작하는지를 다루면서 sync 옵션에 대해 감을 잡아보자.
Spring의 기본적인 in-memory cache 구현체인 ConcurrentMapCache를 보자.
아래 메서드의 인자는 다음과 같다.
// ConcurrentMapCache.java L133-L144
@SuppressWarnings("unchecked")
@Override
public <T> @Nullable T get(Object key, Callable<T> valueLoader) {
return (T) fromStoreValue(this.store.computeIfAbsent(key, k -> {
try {
return toStoreValue(valueLoader.call());
}
catch (Throwable ex) {
throw new ValueRetrievalException(key, valueLoader, ex);
}
}));
}
여기서는 ConcurrentHashMap.computeIfAbsent()를 사용한다.
즉 같은 JVM 안에서 같은 key에 대해 동시에 접근하면, mapping function이 원자적으로 실행된다.
cache miss가 동시에 발생하더라도 같은 key에 대해서는 valueLoader.call()이 한 번만 실행되는 방향으로 동작한다.
정리하면:
Thread-1 -> computeIfAbsent(product:1) -> valueLoader 실행
Thread-2 -> computeIfAbsent(product:1) -> 같은 key 계산 대기/결과 사용
Thread-3 -> computeIfAbsent(product:1) -> 같은 key 계산 대기/결과 사용
이 방식은 로컬 메모리 캐시에서는 꽤 직관적이다.
하지만 JVM 내부 자료구조에 의존하므로 여러 서버 인스턴스 사이의 Stampede까지 막아주지는 않는다.
서버가 3대라면 각 서버마다 한 번씩 원본 메서드가 실행될 수 있다.
Redis를 쓰면 "분산 캐시니까 sync=true도 분산으로 잘 막아주겠지?"라고 생각하기 쉽다.
실제로 그런지 구현체를 확인해보자.
Spring Data Redis의 RedisCache는 get(key, Callable)에서 RedisCacheWriter로 위임한다.
RedisCacheWriter : 실제 Redis 에 전달할 명령어 생성 및 Redis Conn 객체를 통한 명령어 처리 하는 RedisCache 내부 객체 (RedisTemplate의 VauleOperation 객체 + RedisCon 역할)
// RedisCache.java L136-L147
@Override
@SuppressWarnings("unchecked")
public <T> @Nullable T get(Object key, Callable<T> valueLoader) {
byte[] binaryKey = createAndConvertCacheKey(key); // key 바이트 배열 직렬화
// RedisCacheWriter에게 캐시 처리 부분 위임
byte[] binaryValue = getCacheWriter().get(getName(), binaryKey,
() -> serializeCacheValue(toStoreValue(loadCacheValue(key, valueLoader))), getTimeToLive(key),
getCacheConfiguration().isTimeToIdleEnabled());
// 가져온 결과값 바이트배열 => 원본 데이터로 역직렬화
ValueWrapper result = toValueWrapper(deserializeCacheValue(binaryValue));
return result != null ? (T) result.get() : null;
}
RedisCache.get() 메서드에서도 Cache Hit/Miss 및 데이터 처리는 RedisCacheWriter에게 위임한다
// RedisCacheWriter에게 캐시 처리 부분 위임
byte[] binaryValue = getCacheWriter().get(
getName(), // 캐시 이름 - @Cacheable(cacheNames = "products", key = "#id") 에서 products
binaryKey, // redis 에 보낼 캐시 key 바이너리 값 ex) procucts:1 의 바이너리 값
() -> serializeCacheValue(toStoreValue(loadCacheValue(key, valueLoader))), // 캐시 miss 일때 실행할 원본 메서드
getTimeToLive(key), // TTL(TimeToLive) 설정 - 저장 후 일정 시간 지나면 만료
getCacheConfiguration().isTimeToIdleEnabled() // TTI(Time To Idle) 설정 - 마지막 접근후 일정시간 지나면 만료
);
// RedisCacheWriter.get(name, key, valueLoader, ttl, timeToIdleEnabled); - DefaultRedisCacheWriter override
@Override
public byte[] get(String name, byte[] key, Supplier<byte[]> valueLoader, @Nullable Duration ttl, boolean timeToIdleEnabled) {
Assert.notNull(name, "Name must not be null");
Assert.notNull(key, "Key must not be null");
boolean withTtl = shouldExpireWithin(ttl);
// double-checked locking optimization
if (isLockingCacheWriter()) {
byte[] bytes = get(name, key, timeToIdleEnabled && withTtl ? ttl : null);
if (bytes != null) {
return bytes;
}
}
return execute(name, connection -> {
if (isLockingCacheWriter()) {
doLock(name, key, null, connection);
}
try {
byte[] result = doGet(connection, name, key, timeToIdleEnabled && withTtl ? ttl : null);
if (result != null) {
return result;
}
byte[] value = valueLoader.get();
doPut(connection, name, key, value, ttl);
return value;
} finally {
if (isLockingCacheWriter()) {
doUnlock(name, connection);
}
}
});
}
RedisCacheWriter의 기본 구현체는 DefaultRedisCacheWriter.java 이며 위 메서드는 기본 구현체 메서드이다.
해당 메서드에서 눈여겨봐야할것은 내부적으로 isLockingCacheWriter() 메서드를 통해 락을 거는지 체크하는 부분이 있다.
private boolean isLockingCacheWriter() {
return !this.sleepTime.isZero() && !this.sleepTime.isNegative();
}
해당 메서드는 DefaultRedisCacheWriter 구현체가 locking 모드인지 판단하는 플래그 메서드로, 현재 RedisCacheWriter가 Redis lock을 사용하는 locking 모드인지 판단한다.
Locking 모드를 켜주기 위해서는 다음과 같이 설정을 켜주면 된다.
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
return RedisCacheManager
.builder(RedisCacheWriter.lockingRedisCacheWriter(connectionFactory))
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig())
.build();
}
위의 get() 메서드는 다음과 같은 순서로 동작하게 된다.
@Override
public byte[] get(String name, byte[] key, Supplier<byte[]> valueLoader, @Nullable Duration ttl boolean timeToIdleEnabled) {
// ttl 설정
boolean withTtl = shouldExpireWithin(ttl);
return execute(name, connection -> {
// redis cache read
byte[] result = doGet(connection, name, key, timeToIdleEnabled && withTtl ? ttl : null);
// cache hit
if (result != null) {
return result;
}
// cache miss
byte[] value = valueLoader.get(); // 원본 메서드 호출
doPut(connection, name, key, value, ttl); // cache write
return value;
});
}
non-locking 모드로 지정하면, 별도로 locking 을 하는 과정이 없기 때문에 sync 옵션을 사용하지 않는것과 동일하게 처리된다.
그래서 동시에 요청이 오면,
Thread-1: doGet -> null
Thread-2: doGet -> null
Thread-3: doGet -> null
Thread-1: valueLoader 실행(원본 메서드 => DB 호출)
Thread-2: valueLoader 실행
Thread-3: valueLoader 실행
즉, @Cacheable(sync = true) 옵션을 사용했고, Redis 기반 Cache 를 사용했음에도 CacheStampede 를 막아주지 못한다!
@Override
public byte[] get(String name, byte[] key, Supplier<byte[]> valueLoader, @Nullable Duration ttl, boolean timeToIdleEnabled) {
// ttl 설정
boolean withTtl = shouldExpireWithin(ttl);
// double-checked locking optimization
// cache 가 있는데, lock을 먼저 선점해서 처리하면 성능 저하가 일어나므로 먼저 cache read
if (isLockingCacheWriter()) {
byte[] bytes = get(name, key, timeToIdleEnabled && withTtl ? ttl : null);
if (bytes != null) {
return bytes;
}
}
// cache miss 라면 lock 걸어서 순차적으로 valueLoader 호출
return execute(name, connection -> {
// redis lock 잡기
if (isLockingCacheWriter()) {
doLock(name, key, null, connection);
}
try {
// 그 사이에 다른 thread가 다시 채웠을수도 있으니 다시 cache read
byte[] result = doGet(connection, name, key, timeToIdleEnabled && withTtl ? ttl : null);
if (result != null) {
return result;
}
byte[] value = valueLoader.get();
doPut(connection, name, key, value, ttl);
return value;
} finally {
// redis lock 해제
if (isLockingCacheWriter()) {
doUnlock(name, connection);
}
}
});
}
locking 모드를 켠 상태로 실행하게되면, 해당 메서드는 cache miss 일 경우
valueLoader(원본 메서드 wrapper)를 호출하기전에 Redis SET NX 기반 lock 을 걸어서 각 요청을 동기화 해준다.
이때, 영리하게도
cache read -> cache miss -> lock 얻기
-> cache 다시 read -> cache miss 이면 value loader 호출
lock 을 건 이후에도 cache 를 다시 조회하여 이전에 동기화하여 먼저 처리된 쓰레드가 캐시에 값을 채워넣으면 해당 값을 사용하도록 최적화가 되어있다.
다만, 여기서 한가지 큰 단점이 있는데, 해당 방식으로 lock을 거는건 key 단위가 아니라, CacheName 단위다.
void doLock(String name, Object contextualKey, @Nullable Object contextualValue, RedisConnection connection) {
RedisStringCommands commands = connection.stringCommands();
Expiration expiration = Expiration.from(this.lockTtl.getTimeToLive(contextualKey, contextualValue));
byte[] cacheLockKey = createCacheLockKey(name); // cacheName을 입력받고 해당 cacheName으로 lock을 건다.
while (!ObjectUtils.nullSafeEquals(commands.set(cacheLockKey, new byte[0], expiration, SetOption.SET_IF_ABSENT),
true)) {
checkAndPotentiallyWaitUntilUnlocked(name, connection);
}
}
byte[] createCacheLockKey(String name) {
return (name + "~lock").getBytes(StandardCharsets.UTF_8);
}
즉, 캐싱을 먼저 수행했던 메서드를 상기해보면 다음과 같이 작성했을때,
@Cacheable(cacheNames = "products", key = "#productId", sync = true)
public Product getProduct(Long productId) {
return productRepository.findById(productId)
.orElseThrow();
}
이때 Redis에 저장되는 Cache key와 Cache Name 다음과 같이 만들어진다.
cacheName => "products"
cacheKey => "products:1", "products:2", ...
형태로 구성된다.
하지만, 이렇게 하면 cache key = "products:1", "products:2" 인 키가 모두 만료되었을때, lock 걸게 되는 key는 products~lock 가 된다.
하지만, 일반적으로 해당 캐시를 사용할때 기대하는 lock key는
products::1~lock,products::2~lock, ... 형태의products::{productId}~lock
가 되어야 한다.
이렇게 되면 어떤 문제가 있을까?
기대하는 CacheStampede를 막아주는 상황은 아래와 같을것이다.

하지만, 실제로 DefaultCacheWriter를 통한 CacheStampede를 막아주는 상황은 다음과 같다.

즉, product:2는 별도 key 이므로 별도로 동기화하여 처리하면 되지만
전체 cache Name으로 lock이 걸리기 때문에 앞선 product:1의 요청을 기다리느라 불필요한 병목이 생기게 된다!
@Cacheable을 사용해서 CacheStampede를 막을 수 있다는 것을 배웠습니다.
신기하게도, Spring AOP Level 에서 동일하게 막아주는게 아니라, Cache 구현체에 따라 다르게 동작하기 때문에 사용하는 구현체의 동작을 신경써야 하는게 인상적이었습니다.