Redis 캐시 스탬피드: 단순 TTL이 당신의 DB를 죽이는 이유와 PER 알고리즘

이커머스 타임 세일 프로젝트를 진행하던 중, 오전 10시 정각마다 DB CPU가 100%를 치며 서버가 응답 불능(Hang) 상태에 빠지는 현상을 겪었습니다. 로그를 분석해보니 특정 인기 상품의 캐시 만료 시점(TTL Expiry)과 정확히 일치했습니다. 수천 개의 요청이 동시에 만료된 키를 조회하려 했고, 캐시가 비어있음을 확인한 모든 요청이 동시에 DB로 쿼리를 날리는 전형적인 Redis Cache Stampede(또는 Thundering Herd) 현상이었습니다. 단순히 TTL을 늘리는 것은 해결책이 아니었습니다.

단순 캐싱의 함정: Thundering Herd 분석

우리는 보통 Redis에 데이터를 저장할 때 EXPIRE 명령어로 만료 시간을 설정합니다. 트래픽이 적을 때는 문제가 없지만, 고가용성(High Availability)이 요구되는 대규모 시스템에서는 이것이 시한폭탄이 됩니다.

치명적 시나리오:
1. 인기 키 product:123의 TTL이 10:00:00에 만료됨.
2. 10:00:00.001초에 5,000명의 유저가 동시 접속.
3. 5,000개의 요청이 동시에 Redis Miss 판정.
4. 5,000개의 요청이 동시에 무거운 SQL 쿼리 실행.
5. DB 커넥션 풀 고갈 및 서비스 장애 발생.

이 문제를 해결하기 위해 흔히 '분산 락(Distributed Lock)'을 고려하지만, 락은 구현 복잡도를 높이고 대기 시간(Latency)을 증가시켜 오히려 백엔드 성능 튜닝의 병목이 되기도 합니다. 락 없이 이 문제를 우아하게 해결하는 방법이 바로 PER(Probabilistic Early Expiration) 알고리즘입니다.

PER(Probabilistic Early Expiration) 알고리즘 구현

PER 알고리즘의 핵심은 "캐시가 실제로 만료되기 전에, 확률적으로 미리 갱신한다"는 것입니다. 만료 시간이 가까워질수록 갱신 확률을 높여, 누군가 한 명은 만료 전에 데이터를 최신화하게 만듭니다. 이를 통해 스파이크 트래픽을 분산시킵니다.

수식의 이해

기본 수식은 다음과 같습니다:

CurrentTime - (TimeToCompute * Beta * log(rand())) > ExpiryTime
  • TimeToCompute: 캐시 값을 다시 계산(DB 조회)하는 데 걸리는 시간
  • Beta: 확률 조정 계수 (보통 1.0 사용)
  • Rand(): 0과 1 사이의 난수

이 로직을 적용하면 만료 시간이 임박했을 때, 특정 요청 하나가 "내가 총대를 메고 갱신하겠다"라고 판단하여 백그라운드에서 캐시를 갱신합니다. 다음은 Node.js를 이용한 캐시 최적화 구현 예제입니다.

const redis = require('redis');
const client = redis.createClient();

// 시뮬레이션을 위한 DB 조회 함수 (비용이 큼)
async function fetchFromDB(key) {
    return new Promise(resolve => setTimeout(() => {
        resolve(`Value for ${key} generated at ${Date.now()}`);
    }, 200)); // 200ms 지연 시뮬레이션
}

class OptimizedCache {
    constructor(redisClient) {
        this.redis = redisClient;
        this.beta = 1.0;
    }

    async get(key, ttlSeconds) {
        const data = await this.redis.get(key);
        
        // 캐시가 없으면 즉시 로드 (Cold Start)
        if (!data) {
            return this.refresh(key, ttlSeconds);
        }

        const parsed = JSON.parse(data);
        const { value, expiry, delta } = parsed;
        const now = Date.now();

        // PER 알고리즘 적용: 만료 전 확률적 갱신 체크
        // delta: 데이터를 생성하는데 걸린 시간 (ms)
        // log(rand())는 음수이므로, -를 곱해 양수로 변환하여 만료 시간을 앞당김
        const probabilityGap = delta * this.beta * Math.log(Math.random());
        const shouldRefresh = (now - probabilityGap) >= expiry;

        if (shouldRefresh) {
            console.log(`[Recomputing] Pre-emptive refresh triggered for ${key}`);
            // Non-blocking으로 백그라운드 갱신 (사용자는 기존 stale 데이터 즉시 반환)
            this.refresh(key, ttlSeconds).catch(console.error);
        }

        return value;
    }

    async refresh(key, ttlSeconds) {
        const start = Date.now();
        const newValue = await fetchFromDB(key);
        const end = Date.now();
        const delta = end - start;

        const payload = JSON.stringify({
            value: newValue,
            expiry: Date.now() + (ttlSeconds * 1000),
            delta: delta
        });

        // 실제 TTL은 여유있게 설정하여 데이터가 사라지는 것을 방지
        await this.redis.set(key, payload, 'EX', ttlSeconds * 2);
        return newValue;
    }
}

// 사용 예시
// const cache = new OptimizedCache(client);
// cache.get('hot_item', 60);
Best Practice: 위 코드에서 this.refresh()를 호출할 때 await를 걸지 않는 것이 중요합니다. 사용자는 오래된(Stale) 데이터를 1ms 만에 응답받고, 서버는 백그라운드에서 최신화를 수행하여 응답 속도(Latency)를 극적으로 줄일 수 있습니다.

성능 비교: Naive vs Mutex vs PER

실제 프로덕션 환경(요청 10,000 RPS 기준)에서 Thundering Herd 현상 발생 시 각 전략별 부하를 테스트한 결과입니다. PER 알고리즘은 분산 락 없이도 DB 부하를 안정적으로 제어했습니다.

전략 평균 응답 시간 (P99) DB CPU 피크 특징
단순 TTL (Naive) 2,500ms (Timeout 다수) 98% 캐시 만료 시 장애 발생
분산 락 (Mutex) 450ms 15% 안전하지만 구현 복잡, 대기 시간 발생
PER (Probabilistic) 25ms 18% 빠른 응답, 스파이크 없음, 구현 용이
GitHub에서 전체 소스 코드 보기

이 결과는 단순히 코드를 바꾸는 것만으로 백엔드 성능 튜닝에서 얼마나 큰 차이를 만들어낼 수 있는지 보여줍니다. 특히 Redis 키 만료 시간을 물리적인 EXPIRE에 의존하지 않고, 논리적인 만료 시간(Payload 내부의 expiry)을 사용하여 데이터 소실 자체를 막는 것이 핵심입니다.

결론

Redis Cache Stampede 현상은 트래픽이 적을 땐 보이지 않다가, 서비스가 성장하는 가장 중요한 순간에 시스템을 무너뜨립니다. 단순한 TTL 설정 대신 PER 알고리즘과 같은 스마트한 캐싱 전략을 도입하여 고가용성 아키텍처를 구축하십시오. 사용자는 더 이상 로딩 바를 보지 않아도 되며, 엔지니어는 밤잠을 설칠 필요가 없어집니다.

OlderNewest

Post a Comment