Redis 대량 캐시 무효화, keys 패턴을 넘어서

set 인덱스 기반 구조 개선과 실험기

By HyeonSoo

Redis 대량 캐시 무효화, keys 패턴을 넘어서

set 인덱스 기반 구조 개선과 실전 검증기

1. 도입: 이전 실험의 한계에서 출발하다

이전 포스팅에서 동일한 API 요청의 응답 시간을 줄이기 위해 Redis 기반 캐시를 도입했고, 특정 패턴의 키(이메일-API-키1-키2)를 비동기(keys + 코루틴)로 일괄 삭제하는 구조까지 실험해보았습니다.

이 구조에 나름의 자신감이 있었지만, 운영 사례와 여러 레퍼런스를 찾아보며

“keys/scan 패턴은 대량 데이터 환경에서 Redis 자체가 멈출 수 있다”

와 같은 경고와 실제 장애 사례를 접하게 됐습니다.

덕분에 실제 운영 환경, 혹은 데이터셋이 훨씬 커질 때에도 내 코드가 안전하게 동작할 수 있을까?라는 새로운 고민이 시작됐습니다.

그래서 이번에는 구조적으로 더 안전하고, 확장성까지 고려한 방식으로 Redis 캐시 삭제 구조를 근본적으로 개선해보기로 마음먹었습니다.

2. 왜 keys/scan 패턴은 위험한가?

먼저 keys와 scan 명령이 왜 위험한지, 실제 운영 환경에서 어떤 주의가 필요한지 여러 공식 문서와 운영 사례를 찾아 학습해보았습니다.

그 핵심은 다음과 같습니다.

  • Redis는 싱글 스레드 구조입니다.
  • keys/scan 명령은 전체 keyspace를 순회하며 데이터가 많을수록 서버가 일시적으로 블로킹되거나, 심한 경우 먹통이 될 수 있습니다.
  • scan 명령은 블로킹을 피하지만, 결국 전체 키를 반복적으로 탐색한다는 점에서 부하 발생의 본질은 keys와 동일합니다.

이처럼 실시간 서비스나 대규모 트래픽 환경에서는 O(N) 기반의 일괄 키 삭제 자체가 장애로 이어질 가능성이 매우 높다는 결론을 얻었습니다.

3. 개선 아이디어: set 기반 인덱스 구조의 도입

keys/scan의 구조적 한계를 명확히 인지한 뒤, 그렇다면 대량 삭제를 더 안전하고 효율적으로 처리할 방법이 없을까?를 고민하게 됐습니다.
이후 여러 자료구조와 접근법을 검토하다가 “삭제 대상이 되는 키의 집합 자체를 별도 set 인덱스 형태로 관리하면 어떨까?” 라는 아이디어에 도달했습니다.

즉,

  • 실제 데이터 키들은 기존처럼 Redis의 TTL 등 만료 로직에 맡기고
  • 무효화가 필요할 때마다 set에서 대상 키만 꺼내 일괄 삭제

이 방식이라면 전체 keyspace를 스캔할 필요 없이 필요한 키만 신속하게 삭제할 수 있습니다.

구현을 고민하는 과정에서 set과 hash 중 어떤 자료구조가 더 적합할지 비교해봤는데,

  • hash: 필드별로 만료시간(TTL)을 따로 설정할 수 없고, 전체 hash에만 TTL을 부여할 수 있습니다.
  • set: 각 값(실제 데이터 키)이 모두 독립적으로 만료 가능해서 삭제 인덱싱 역할에는 훨씬 더 유연하게 활용할 수 있습니다.

현재 서비스는 키마다 서로 다른 TTL을 갖고 있었기 때문에 키의 만료/삭제 시나리오를 가장 유연하게 지원하는 set 구조를 삭제 인덱싱 용도로 도입하기로 결정했습니다.

4. 실제 적용과 코드 개선

set 기반 인덱스 구조의 방향을 정한 뒤, 기존 코드와 최대한 호환성을 유지하면서도, 안정적으로 개선된 삭제 방식을 적용하는 데 집중했습니다.

  • set에 email별 캐시 키 관리

캐시에 새로운 값을 저장할 때 기존 데이터 저장과 함께 cache:keys:${email} 형식의 set에도 해당 키를 함께 저장하도록 했습니다.
이렇게 하면, 특정 email의 캐시 무효화가 필요할 때 해당 set 안의 키만 한 번에 삭제하면 충분했죠.

  • 원자성 보장: 삭제 과정 중 오류 방지

set 기반 삭제의 핵심은 set 내부의 모든 키를 동시에, 원자적으로 삭제하는 것이었습니다. 만약 삭제 도중에 누군가 새로운 키를 추가한다면 예기치 못한 불일치나 누락이 생길 수 있기 때문이죠.
이를 해결하기 위해 Lua Script를 활용해 set 내부의 모든 키 삭제와 set 삭제를 하나의 트랜잭션으로 처리했습니다.

실제 활용한 스크립트는 아래와 같습니다

    ```lua
    local setKey = KEYS[1]
    local keys = redis.call('SMEMBERS', setKey)
    for i, key in ipairs(keys) do
      redis.call('UNLINK', key)
    end
    redis.call('DEL', setKey)
    return #keys
    ```
  • 코루틴 기반 비동기 삭제로 오버헤드 최소화

개선된 삭제 함수는 기존 방식과 마찬가지로 코루틴으로 비동기 실행되도록 구현했습니다.
덕분에 실제 서비스 로직에서 삭제 과정이 긴 대기 시간(오버헤드)을 유발하지 않으면서 빠르고 안전하게 동작할 수 있었습니다.

실제 구현 코드 (Kotlin)

// [캐시 저장 시, 키 인덱스용 set에도 추가]
// - email별로 관리되는 set에 현재 캐시 키를 추가
// - 추후 특정 email의 캐시를 빠르게 일괄 삭제할 수 있도록 인덱스 역할 수행
fun saveCacheSet(email: String, cacheKey: String) {
    val setKey = "cache:keys:$email"
    redisTemplate.opsForSet().add(setKey, cacheKey)
}

// [set 기반 일괄 삭제 – 루아 스크립트 활용]
// - email별 set에 저장된 모든 키를 원자적으로 삭제
// - set과 실제 데이터 키를 모두 삭제한 뒤, set 자체도 삭제함
suspend fun deleteKeysAndSet(email: String): Long = withContext(Dispatchers.IO) {
    val setKey = "cache:keys:$email"
    redisTemplate.execute(script, listOf(setKey))
}

// [루아 스크립트 로드]
// - resources에서 lua 스크립트 파일을 읽어와 문자열로 반환
// - 스크립트는 set 내 모든 키를 삭제 후 set까지 삭제하는 로직
private fun readLuaScript(resourcePath: String = "redis/delete_set_and_keys.lua"): String {
    val resource = ClassPathResource(resourcePath)
    return resource.inputStream.bufferedReader().use { it.readText() }
}

// [루아 스크립트를 Redis 실행 객체로 변환]
// - redisTemplate에서 사용할 수 있도록 DefaultRedisScript 형태로 래핑
// - 반환 타입은 Long (삭제된 키 개수)
private fun deleteSetAndKeysScript(): DefaultRedisScript<Long> {
    val script = readLuaScript()
    val redisScript = DefaultRedisScript<Long>()
    redisScript.setScriptText(script)
    redisScript.resultType = Long::class.java
    return redisScript
}

5. 실험 설계와 실제 성능 변화

작은 데이터셋에서 시작한 실험

개선된 삭제 구조를 적용한 뒤, 먼저 소규모(100건 미만) 데이터셋에서 keys 패턴과 set 패턴의 삭제 성능을 비교해보았습니다.

  • 5회 시행 시 평균 속도
데이터셋 keys 패턴 set 패턴
100건 32.3ms 50.4ms

예상과 달리, 이 단계에서는 오히려 keys 패턴이 set 패턴보다 더 빠르게 동작하는 결과가 나왔습니다.
이는 set 삭제 과정에서 발생하는 오버헤드(set 조회/삭제 비용)가 keys 패턴의 오버헤드보다 더 크게 작용한 것이 주요 원인으로 보였습니다.


데이터셋 확장: 대용량 환경에서의 성능 재실험

소규모에서는 차이가 거의 없거나 keys가 오히려 빠르기도 했지만, 실제 서비스 환경처럼 데이터가 수십만~백만 건까지 많아진다면 결과가 어떻게 달라질까?라는 궁금증이 남았습니다.

그래서 이번에는 테스트 데이터를 50만 건, 100만 건 단위로 대폭 확장하여 keys 패턴과 set 패턴의 삭제 성능을 다시 비교해보기로 했습니다.
하지만 이 정도 규모의 데이터를 매번 새로 준비하려면 관리와 세팅에 많은 시간이 들 수밖에 없었죠.

이를 해결하기 위해

  • 미리 대용량 데이터를 Redis에 입력해 rdb 스냅샷을 생성한 뒤
  • docker compose로 테스트 환경을 올릴 때마다 동일한 데이터셋에서 빠르고 일관된 실험이 가능하도록 환경을 자동화했습니다.

(이 자동화 과정 및 테스트 인프라 설계는 별도 포스팅에서 더 자세히 다룰 생각입니다.)

본격적인 대용량 실험과 성능 변화

50만 건, 100만 건 환경에서 keys 패턴과 set 패턴의 삭제 성능을 다시 비교해본 결과는 이전과 완전히 달랐습니다.
먼저 keys 패턴은 데이터셋 크기가 2배로 늘어나면 삭제 시간도 거의 2배 증가하는, 전형적인 O(N) 특성을 보였습니다.
반면, set 패턴은 데이터셋 크기와 관계없이 삭제 시간이 거의 일정하게 유지되어 O(1) 성능임을 확인할 수 있었습니다.

아래는 실제 측정값(5회 평균)입니다.

데이터셋 keys 패턴 set 패턴
50만건 103.2ms 56.2ms
100만건 177.4ms 60.1ms

이 실험을 통해 작은 데이터셋에서는 오히려 set 오버헤드가 부담이 되지만, 실제 서비스처럼 데이터가 많아질수록 구조적 차이가 성능에 결정적으로 드러난다는 사실을 직접 확인할 수 있었습니다.

마치며

이번 실험을 통해 작은 구조적 변화도 운영환경에서는 예상보다 훨씬 큰 차이를 만들어낼 수 있다는 점을 배울 수 있었습니다.

keys/scan 방식은 간단하고 직관적이지만, 데이터가 많아질수록 장애 위험과 성능 저하, 운영 리스크가 기하급수적으로 커집니다.
반면, set 인덱스 기반 구조는 초기 설계와 구현은 조금 더 복잡할 수 있지만, 데이터가 많아져도 키 전체 탐색으로 인한 성능 저하 걱정이 없죠.

혹시 더 나은 구조, 실험 아이디어, 혹은 현장에서의 경험을 공유해주실 분이 계시다면 언제든 이메일로 의견을 나눠주시면 좋을 것 같습니다. 앞으로도 더 나은 구조를 위해 고민하고 찾아보겠습니다.

읽어주셔서 감사합니다!

Tags: project Mockin