캐시를 비우는 비용은 캐시 크기가 아니라 Redis 전체 크기다

작성 · 수정


출처: 우아한형제들 기술블로그 — 이제 Redis를 멈춰보겠습니다: @CacheEvict 파헤치기

@CacheEvict(allEntries = true) 한 줄이 Redis의 KEYS *까지 이어지는 경로를 Spring 코드로 따라 내려간 글이다. 저자는 배치 전략을 SCAN으로 바꿔보고도 API 타임아웃을 만났다. 원문을 읽고 정리한 내용과 내 생각을 함께 적는다.

문제

캐시를 지우는 코드는 대개 둘 중 하나다.

@CacheEvict(cacheNames = "product", key = "#id")
public void update(Long id, ProductRequest request) { ... }

@CacheEvict(cacheNames = "product", allEntries = true)
public void reload() { ... }

두 줄은 거의 같게 생겼다. 어노테이션 이름이 같고 인자가 두 개인 것도 같고 코드 리뷰에서 눈에 걸리는 무게도 같다. 하는 일은 전혀 달라서 앞은 키 하나에 DEL을 한 번 날리지만 뒤는 Redis에 든 키를 전부 훑는다. 비용이 몇 자릿수씩 차이 나는 두 연산을 boolean 필드 하나가 가른다.

원문은 그 필드가 어디까지 흘러가는지를 Spring과 Spring Data Redis 코드로 따라 내려간다. 환경은 Spring Boot 3.3.7, Spring Data Redis 3.3.7, Lettuce 6.3.2. 결말부터 말하면 저자는 위험을 알아채고 배치 전략을 SCAN으로 바꿨는데 그러고도 API가 60초 타임아웃에 걸렸다.

분석

경로는 길어도 분기는 한 곳이다. SpringCacheAnnotationParser.parseEvictAnnotation()이 어노테이션을 CacheEvictOperation으로 옮기면서 이름을 한 번 바꾼다. builder.setCacheWide(cacheEvict.allEntries()). 실행 시점의 갈림길은 이 cacheWide다.

private void performCacheEvicts(List<CacheOperationContext> contexts, @Nullable Object result) {
    for(CacheOperationContext context : contexts) {
        CacheEvictOperation operation = (CacheEvictOperation)context.metadata.operation;
        if (this.isConditionPassing(context, result)) {
            Object key = context.getGeneratedKey();

            for(Cache cache : context.getCaches()) {
                if (operation.isCacheWide()) {
                    this.logInvalidating(context, operation, (Object)null);
                    this.doClear(cache, operation.isBeforeInvocation());
                } else {
                    if (key == null) {
                        key = this.generateKey(context, result);
                    }

                    this.logInvalidating(context, operation, key);
                    this.doEvict(cache, key, operation.isBeforeInvocation());
                }
            }
        }
    }
}

false면 doEvict(), 키 하나에 DEL이다. true면 doClear()로 가고 AbstractCacheInvoker를 거쳐 cache.clear()에 닿는다. Cache는 인터페이스이고 Redis를 쓰면 그 구현체가 RedisCache다.

public class RedisCache extends AbstractValueAdaptingCache {
    ...
    @Override
    public void clear() {

        byte[] pattern = conversionService.convert(createCacheKey("*"), byte[].class);
        cacheWriter.clean(name, pattern);

    }
    ...
}

createCacheKey("*")가 캐시 이름과 *을 붙여 product::* 같은 패턴을 만들고 그 패턴이 DefaultRedisCacheWriter.clean()을 거쳐 batchStrategy.cleanCache(connection, name, pattern)으로 내려가는데, BatchStrategy 구현체는 Keys와 Scan 둘이다.

static class Keys implements BatchStrategy {

    static BatchStrategies.Keys INSTANCE = new BatchStrategies.Keys();

    @Override
    public long cleanCache(RedisConnection connection, String name, byte[] pattern) {

        byte[][] keys = Optional.ofNullable(connection.keys(pattern)).orElse(Collections.emptySet())
                .toArray(new byte[0][]);

        if (keys.length > 0) {
            connection.del(keys);
        }

        return keys.length;
    }
}

KEYS로 전부 긁어서 DEL. Redis는 싱글 스레드라 그동안 들어온 요청이 전부 대기한다. 이게 특수한 설정에서만 나오는 경로도 아니다.

public interface RedisCacheWriter extends CacheStatisticsProvider {

    static RedisCacheWriter nonLockingRedisCacheWriter(RedisConnectionFactory connectionFactory) {
        return nonLockingRedisCacheWriter(connectionFactory, BatchStrategies.keys());
    }

Spring Boot 자동 설정을 타고 내려오는 기본값이 BatchStrategies.keys()이고 위험한 쪽이 기본인 까닭은 호환성이다. 공식 문서를 보면 KEYS 전략은 모든 드라이버와 운영 모드(Standalone, Clustered)에서 완전히 지원된다. SCAN 쪽은 Lettuce에서만 완전히 지원되고 Jedis는 비클러스터 모드에서만 지원한다고 적어놨다. 모든 환경에서 동작하는 쪽을 기본으로 골랐더니 모든 환경에서 위험한 쪽이 기본이 됐다.

범인은 명령어가 아니다

여기까지가 원문의 뼈대다. 원문의 처방도 대체로 “KEYS가 위험하니 SCAN을 쓰라”로 모인다. 그런데 원문 자신의 실측이 그 프레임을 반박한다. 저자는 캐시 매니저 Bean을 재정의해 배치 전략을 Scan으로 바꿨는데, Pinpoint에 SCAN 호출이 무수히 찍혔고 캐시량이 많아 결국 API가 60초 타임아웃에 걸렸다. 명령어를 바꿨는데도 장애는 그대로였으니 원인은 다른 곳에 있다.

Redis에는 네임스페이스가 없고 product::*는 전체 키스페이스를 훑는 필터다. 키 앞에 캐시 이름을 붙이는 건 사람이 읽으라고 만든 규약이고 Redis는 그걸 자료구조로 알지 못한다. Redis 입장에서 product::42와 session:abc는 그냥 최상위 키 하나씩이다. 둘을 가르는 그룹 같은 건 존재하지 않는다.

clear()의 비용은 내 캐시에 든 항목 수가 아니라 그 Redis 인스턴스에 든 전체 키 수에 비례한다. 항목이 100개뿐인 캐시를 비울 때도 같은 인스턴스를 남이 천만 개로 채워놨으면 천만 개를 훑는다. 내 코드가 얼마나 무거운지는 내 코드를 봐서 알 수 없다. 그건 옆 팀의 사용량에 적혀 있다. 부하 테스트로도 잘 안 잡힌다. 테스트 환경의 Redis는 대개 비어 있으니까.

같은 인터페이스, 다른 소유 모델

Cache 인터페이스의 clear()는 Map.clear()처럼 생겼고 ConcurrentMapCache나 Caffeine에서는 실제로 그렇게 동작한다. 캐시가 항목을 직접 소유해서 들고 있던 자료구조만 버리면 끝나니 싸다.

Redis 백엔드로 오면 캐시는 아무것도 소유하지 않는다. 소유한 건 이름 규약 하나다. 지울 대상의 목록이 없으니 지우려면 매번 전수 조사로 목록을 다시 만들어야 한다. 로컬 캐시에서 사실상 공짜인 연산이 Redis에서 O(전체 키스페이스)가 되는 이유가 여기 있다. 시그니처는 같은데 그 뒤의 소유 모델이 다르다. 이 사고의 구조적 원인이 거기서 나온다.

SCAN은 총량을 줄이지 않는다

SCAN이 KEYS를 대체하지 못하는 까닭도 같은 자리에서 나온다. 전략을 바꿔도 필터의 대상은 여전히 전체 키스페이스다. SCAN은 총 작업량을 줄이지 않는다. 누가 기다릴지를 바꿀 뿐이다.

KEYS는 한 번의 긴 블로킹 호출이라 그동안 Redis 전체가 멈추고 피해가 남에게 간다. SCAN은 커서로 잘라 짧은 호출을 여러 번 하니 Redis를 오래 붙잡지 않는다. 대신 왕복 횟수가 전체 키스페이스를 batchSize로 나눈 만큼 불어난다. 늘어난 왕복은 이 명령을 부른 스레드가 처음부터 끝까지 기다린다. 네트워크 왕복과 커서 재개 비용까지 더해지니 벽시계 시간으로는 SCAN 쪽이 더 오래 걸릴 수도 있다.

저자가 본 게 정확히 이거다. Redis는 살아 있었고 API가 죽었다. 장애는 사라지지 않고 자리를 옮겼다. 원문은 “SCAN도 여전히 전체 키 탐색이라 데이터가 많으면 타임아웃이 날 수 있다”까지 적어놓고도 이 이동은 짚지 않는다. 짚었다면 처방이 달라졌을 것이다.

남는 문제가 더 있다. Scan.cleanCache()도 잘라낸 배치마다 connection.del()을 부른다. 원문이 KEYS 절에서 지적한 DEL의 문제 — 키가 많으면 메모리 해제가 이벤트 루프를 점유한다 — 는 SCAN으로 바꿔도 그대로다. 해제를 background로 넘기는 UNLINK가 낫고 lazyfree-lazy-user-del 설정을 켜면 DEL도 UNLINK처럼 동작한다. Valkey 8.0부터는 그게 기본이다. 잠금 쪽도 남는다. DefaultRedisCacheWriter.clean()은 isLockingCacheWriter()일 때 cleanCache() 앞뒤로 doLock()/doUnlock()을 건다. SCAN을 쓰더라도 lockingRedisCacheWriter라면 그 구간 전체가 잠기고 전략만 바꿔서는 안전해지지 않는다.

60초를 기다린 건 사용자다

원문이 명시하지 않은 걸 하나 덧붙인다. beforeInvocation의 기본값은 false이고 이 전수 스캔은 비즈니스 메서드가 정상 종료한 뒤에 일어난다. 그때도 여전히 요청을 처리하던 스레드 위에 있고 별도 스레드로 넘어가지 않는다.

60초 타임아웃의 의미도 그래서 분명해진다. 기다린 쪽은 사용자다. 데이터 갱신은 이미 끝나 있었는데 캐시를 비우느라 응답이 나가지 못했다. 이 작업이 사용자 요청 경로 위에 있어야 할 이유는 하나도 없는데 어노테이션이 그렇게 배치했다.

해결

원문의 처방은 셋이다. 캐시 매니저 Bean을 재정의해 배치 전략을 Scan으로 못 박고 인프라에서 KEYS를 막아두고 실무에서 allEntries = true를 쓰지 말라는 것인데, 전체 제거가 정말 필요하면 Scan 기반 별도 로직으로 구현하라고 한다.

# 아래 내용 추가 (고위험 명령 숨김 예시)
# "" 대신 유추가 어려운 임의의 텍스트로 변경하여도 됩니다.
> rename-command KEYS ""

# 정상 적용 확인
127.0.0.1:6379> KEYS *
(error) ERR unknown command `KEYS`, with args beginning with: '*',

우아한형제들도 이렇게 운영한다고 한다. 좋은 조치인데 성격이 앞뒤와 다르다. 이건 성능을 고쳐주지 않는다. 위험한 기본값을 밟았을 때 소리가 나게 만들어 두는 안전장치다. 막아두지 않으면 어느 날 조용히 느려지고 막아두면 RedisCommandExecutionException으로 즉시 터진다. 원문은 이걸 “유의해야 한다”는 주의사항 쪽에 적었지만 나는 이 교환 자체를 이득으로 본다. 조용한 성능 저하보다 시끄러운 실패가 낫다. 예외에는 호출 지점이 스택 트레이스로 찍힌다. 느려진 응답은 원인이 어디 있는지 알려주지 않는다.

마지막 처방은 문제를 풀지 않는다. Scan 기반 별도 로직으로 옮겨도 여전히 O(전체 키스페이스)이고 위치가 사용자 요청 경로 밖으로 나갔을 뿐이다. 그것도 의미는 있다. 앞에서 본 대로 기다리는 쪽이 사용자에서 배치로 바뀌니까. 다만 전체 무효화가 비싸다는 사실 자체는 그대로 남는다.

지우지 말고 이름을 바꾼다

여기서부터는 원문에 없는 이야기다.

전체 무효화가 비싼 건 지울 키를 먼저 찾아내야 해서다. 찾지 않아도 되게 만들면 되는데, 캐시 키 접두사에 세대(generation) 번호를 넣는 방법이 있다.

product:v7:42
product:v7:99

전체 무효화가 필요하면 세대 번호를 8로 올린다. 그 순간부터 조회는 product:v8:42를 찾고 없으니 미스가 나서 원본에서 다시 읽어 채운다. 아무도 product:v7:*을 조회하지 않게 되고 TTL이 만료되면 알아서 사라진다. Redis에 나간 명령은 세대 번호를 갱신하는 쓰기 한 번이다.

N개의 키를 지우는 문제를 N개의 키를 더 이상 가리키지 않는 문제로 바꾼 것이다. 지우려면 전수 조사가 필요하지만 안 가리키는 건 참조 한 곳만 바꾸면 된다.

공짜는 아니다. 옛 세대 키가 TTL 만료까지 메모리에 남고 무효화가 잦으면 세대가 여러 겹 쌓여 메모리 사용량이 늘 수 있다. 그러니 TTL이 반드시 걸려 있어야 한다. TTL 없이 명시적 무효화에만 기대는 캐시에는 쓸 수 없다. 세대 번호를 어디에 둘지도 새로 생기는 문제다. Redis의 별도 키에 두면 캐시 조회마다 읽기가 한 번씩 더 붙으니 짧은 로컬 캐시로 감싸야 한다. 그러면 세대 갱신이 모든 인스턴스에 퍼지는 데 그 캐시 TTL만큼 시차가 생긴다. 무효화가 “즉시”에서 “몇 초 안에”로 바뀌니 그걸 받아들일 수 있는 도메인인지부터 봐야 한다.

Spring Cache 추상화 위에 얹을 수는 있다. RedisCacheConfiguration.computePrefixWith()로 접두사 계산을 가로채거나 커스텀 KeyGenerator로 세대를 키에 섞으면 된다. 걸리는 쪽은 갱신이다. 접두사 계산 함수는 캐시 설정이 만들어질 때 고정돼서 세대를 올린 뒤 즉시 반영하려면 결국 캐시 매니저를 다시 만들거나 추상화 밖에서 키를 직접 다뤄야 한다. 어노테이션은 편의를 주자고 만든 물건이라 이 패턴과 잘 맞지 않는다.

캐시가 전용 Redis 논리 DB나 인스턴스를 통째로 쓴다면 FLUSHDB ASYNC 한 번이 답이 되기도 하고 그 경우엔 세대 번호도 필요 없다. 다만 대부분의 팀은 인스턴스 하나를 여러 용도로 나눠 쓰고 그러면 이 선택지는 처음부터 없다. clear()가 전체 키스페이스를 훑어야 했던 까닭도 결국 같다.

결과

원문이 내놓은 수치는 하나다. 배치 전략을 Scan으로 바꾼 뒤에도 API 타임아웃 60초. 캐시에 들어 있던 키가 몇 개였는지, 인스턴스 전체 키가 몇 개였는지, 응답 시간 분포가 어땠는지, 조치 뒤에 무엇이 얼마나 나아졌는지는 밝히지 않았다.

실무자가 가장 알고 싶은 건 이 글에서 얻을 수 없다. 얼마나 많으면 위험한가. 키 10만 개에서 멀쩡하던 게 100만 개에서 무너지는가, 아니면 그 한참 전에 무너지는가. 임계값이 없으면 “우리는 아직 작으니 괜찮다”도 “우리도 위험하다”도 근거 없는 말이 된다.

그럼에도 임계값이 이 글의 값어치를 정하지는 않는다. 값어치는 어노테이션 필드 하나에서 KEYS *까지 이어지는 경로를 끝까지 그려 보인 데 있다. 경로를 보고 나면 임계값 없이도 판단이 선다. 이 비용은 내 캐시 크기가 아니라 인스턴스 전체 크기를 따라 커진다. 그 크기는 내가 통제하지 않고 시간이 갈수록 줄어들 일도 없다. 임계값을 모르는 채로도 결론은 나온다. 쓰지 않는다.

인상 깊었던 점

이 블로그에 @Transactional을 쓸 상황을 최대한 줄이자를 쓴 적이 있다. 카카오페이 온라인 결제팀에서는 @Transactional(readOnly = true) 하나가 부수 쿼리 여섯 개를 만들었다. 여기서는 allEntries = true 하나가 전체 키스페이스 스캔을 만든다.

두 사례의 공통점은 큰 비용이 아니라 보이지 않는 비용이다. 어노테이션은 선언이다. 선언이 값어치를 갖는 건 비용을 감춰주기 때문이다. 감춘 것의 크기가 다 비슷할 때는 그게 순수한 이득이다. key = "#id"와 key = "#code" 중에 뭐가 더 비싼지 고민할 일은 없다. 크기가 몇 자릿수씩 벌어지기 시작하면 사정이 달라진다. 두 선언이 같은 자리에 나란히 놓여 있는데 사실은 전혀 다른 물건이고 코드는 그 둘을 같은 크기로 그려 보인다. 읽는 사람에게는 어느 쪽이 무거운지 알아낼 단서가 없다.

원문의 마지막은 의심 가는 부분이 있으면 구현체 끝까지 파고들어 그 몰입을 즐겨보라는 말이다. 취향 이야기처럼 들리지만 나는 그렇게 보지 않는다. 편의를 주는 추상화는 그 아래 구현을 서로 바꿔 끼울 수 있을 때만 편의다. ConcurrentMapCache와 RedisCache는 Cache 인터페이스를 공유하지만 서로 바꿔 끼울 수 있는 물건은 아니다. 한쪽은 항목을 소유하고 다른 쪽은 이름만 소유한다. 인터페이스는 그 차이를 표현하지 않고 문서도 대체로 말해주지 않는다. 그 차이를 알아내려면 지금으로선 구현체를 직접 열어보는 수밖에 없다. 즐거워서 파고드는 게 아니라 다른 길이 없어서 파고드는 것이다.