sync=true를 걸어도 RedisCache에서 원본 조회가 여러 번 나가는 이유

seonwoo_jung·5일 전

"@Cacheable(sync = true)를 붙였으니 캐시가 만료되는 순간 동시 요청이 몰려도 원본 조회는 한 번만 나간다"고 믿고 배포했는데, 인스턴스를 3대로 늘리고 RedisCache를 캐시 스토어로 쓰는 순간부터 이 믿음은 절반만 맞는다. 스프링 레퍼런스 문서는 sync=true를 "동시 호출 시 로더가 한 번만 실행되도록 보장한다"고 짧게 설명하는데, 그 보장이 스프링 자체의 락에서 나오는 게 아니라 캐시 구현체에게 떠넘겨진 계약이라는 사실은 여기 적혀 있지 않다.

스프링이 실제로 하는 일: 계약을 아래로 떠넘기기

@Cacheable(sync=true)가 걸린 메서드는 CacheAspectSupport.executeSynchronized() 경로로 들어가고, 여기서 최종적으로 호출하는 건 Cache.get(Object key, Callable<T> valueLoader)다. 이 메서드의 자바독을 그대로 읽으면 "If possible, implementations should ensure that the loading operation is synchronized so that the specified valueLoader is only called once in case of concurrent access on the same key"다. if possible, should — 스프링이 강제하는 불변식이 아니라 구현체에 위임된 권고다.

sync=true는 "내가 한 번만 실행되게 보장한다"는 스위치가 아니라, "그 보장의 책임을 캐시 구현체에게 넘긴다"는 스위치다.

계약을 지키는 쪽과 흘리는 쪽

인메모리 구현 ConcurrentMapCache는 이 계약을 문자 그대로 지킨다.

public <T> 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는 키가 속한 버킷에 대해 원자적으로 동작해서, 동시에 들어온 스레드 중 하나만 valueLoader를 실행하고 나머지는 그 버킷에서 대기하다 같은 결과를 받는다. 단일 JVM 안에서는 "한 번만 실행"이 문자 그대로 성립한다.

RedisCacheWriter로 넘어가면 이 계약의 강도가 달라진다. Spring Data Redis 소스(DefaultRedisCacheWriter)를 보면 이 구현은 애초에 두 모드로 나뉜다 — nonLockingRedisCacheWriter(기본값, 성능 우선)와 lockingRedisCacheWriter(명시적으로 선택해야 켜짐). 기본 모드의 get(name, key, valueLoader, ttl, ...)는 Redis에서 조회 → 미스면 valueLoader.get() 실행 → SET으로 저장, 이 세 단계 사이에 배타 제어가 전혀 없다. 여러 인스턴스가 같은 키로 동시에 미스를 겪으면 인스턴스 수만큼 원본 조회가 그대로 나간다 — sync=true를 걸지 않은 것과 동시성 관점에서 차이가 없다.

locking 모드를 명시적으로 켜야 그림이 바뀐다. 이때는 <캐시이름>~lock이라는 별도 키에 SET ... NX EX <ttl>을 걸어 락을 흉내 낸다. 여기서 헷갈리기 쉬운 지점 하나 — 이 락은 캐시 이름 단위다. ConcurrentMapCache처럼 키(버킷) 단위가 아니라서, 같은 캐시의 서로 다른 키를 읽는 요청들도 이 락이 풀리기까지 함께 기다린다. 대기 방식도 다르다. 락 획득에 실패한 스레드는 큐에 들어가 깨워지는 게 아니라 sleepTime(기본 50ms) 간격으로 EXISTS를 다시 찔러보는 스핀 대기다.

실패 케이스: sync=true는 아무 데나 못 붙는다

determineSyncFlag는 sync=true에 네 조건을 강제한다 — 같은 메서드에 다른 캐시 오퍼레이션 결합 금지, @Cacheable 하나만, 캐시도 하나만, unless 미지원. 이 검증은 빈 등록 시점이 아니라 그 메서드가 처음 호출되는 순간 실행된다. @Cacheable(sync=true, unless="#result == null")처럼 실수로 두 옵션을 같이 쓰면 애플리케이션은 정상 기동하고도, 그 경로에 첫 트래픽이 들어오는 순간 IllegalStateException으로 터진다.

정리

sync=true가 실제로 보장하는 강도는 "스프링이 무엇을 하느냐"가 아니라 "어떤 Cache 구현체를 뒤에 뒀느냐"로 결정된다. ConcurrentMapCache는 JDK의 버킷 락으로 계약을 그대로 지키지만, RedisCache는 기본값이 non-locking이라 명시적으로 lockingRedisCacheWriter를 고르지 않으면 sync=true는 분산 환경에서 장식에 가깝다. 그 잠금을 켜더라도 캐시 이름 단위 스핀 락이라 ConcurrentMapCache만큼 세밀하지는 않다. Redis를 캐시로 쓰면서 캐시 스탬피드를 진짜로 막고 싶다면, sync=true 뒤에 어떤 RedisCacheWriter가 붙어 있는지부터 확인해야 한다.

Hibernate 2차 캐시의 READ_WRITE 전략도 비슷한 모양이었다 — "락을 건다"는 문서 문장 뒤에 DB 락과는 무관한, 캐시 계층 자체의 soft lock이 숨어 있었다. Hibernate READ_WRITE 캐시가 거는 락은 DB 락과 무엇이 다른가에서 그 구조를 다뤘다.

참고 자료

  • Spring Framework 소스 CacheAspectSupport(executeSynchronized, determineSyncFlag) / Cache.get(Object, Callable) 자바독
  • Spring Data Redis 소스 DefaultRedisCacheWriter(get, doLock, checkAndPotentiallyWaitUntilUnlocked) / RedisCacheWriter(lockingRedisCacheWriter, nonLockingRedisCacheWriter)

0개의 댓글