Theme:

캐시를 도입하면 성능이 좋아지는데, 캐시가 오히려 장애의 원인이 되는 경우는 어떤 것들이 있을까요?

캐시 문제 패턴이란

캐시는 성능을 극적으로 향상시키지만, 잘못 설계하면 DB를 보호하기는커녕 장애를 증폭시킵니다. Cache Stampede, Penetration, Avalanche는 대표적인 세 가지 캐시 장애 패턴이고, 이를 이해하고 방어하는 것이 안정적인 캐시 시스템의 핵심입니다.

Cache Stampede (Thundering Herd)

문제 상황

인기 있는 키(핫 키)의 TTL이 만료되는 순간, 수백~수천 개의 요청이 동시에 캐시 미스를 겪고 DB에 몰려가는 현상입니다.

PLAINTEXT
시간 T: 캐시 키 "popular:item" 만료
시간 T+1ms: 요청 1000개가 동시에 캐시 미스
           → 1000개 요청이 모두 DB에 동일 쿼리 실행
           → DB 부하 폭증

해결 1: 뮤텍스 (분산 락)

캐시 미스 시 하나의 요청만 DB를 조회하고, 나머지는 기다립니다.

JAVA
public User getUserWithMutex(Long userId) {
    String key = "user:" + userId;
    String lockKey = "lock:" + key;

    // 1. 캐시 조회
    User cached = redisTemplate.opsForValue().get(key);
    if (cached != null) return cached;

    // 2. 락 획득 시도
    Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(10));

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 3. DB 조회 후 캐시 저장
            User user = userRepository.findById(userId).orElseThrow();
            redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
            return user;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 4. 락을 못 잡으면 잠깐 대기 후 재시도
        Thread.sleep(50);
        return getUserWithMutex(userId);
    }
}

해결 2: 논리적 만료 (Logical Expiration)

실제 TTL은 길게 설정하고, 값 안에 논리적 만료 시간을 넣어서 백그라운드에서 갱신합니다.

JAVA
@Data
public class CacheWrapper<T> {
    private T data;
    private long logicalExpireAt;  // 논리적 만료 시간
}

public User getUserWithLogicalExpire(Long userId) {
    String key = "user:" + userId;
    CacheWrapper<User> wrapper = redisTemplate.opsForValue().get(key);

    if (wrapper == null) {
        // 최초 캐시 미스 — DB 조회 후 저장
        return loadAndCache(userId);
    }

    if (System.currentTimeMillis() > wrapper.getLogicalExpireAt()) {
        // 논리적으로 만료됨 → 백그라운드 갱신 트리거
        CompletableFuture.runAsync(() -> loadAndCache(userId));
    }

    // 기존 데이터 반환 (약간 오래된 데이터일 수 있음)
    return wrapper.getData();
}

해결 3: PER(Probabilistic Early Recomputation)

만료 시간이 가까워질수록 확률적으로 미리 갱신합니다.

JAVA
public User getUserWithPER(Long userId) {
    String key = "user:" + userId;
    User cached = redisTemplate.opsForValue().get(key);
    Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);

    // TTL이 남아있어도 확률적으로 미리 갱신
    // ttl이 짧을수록 갱신 확률이 높아짐
    double beta = 1.0;
    if (cached != null && ttl != null && ttl > 0) {
        double random = -beta * Math.log(Math.random());
        if (random < ttl) {
            return cached;  // 기존 캐시 반환
        }
    }

    // 갱신
    return loadAndCache(userId);
}

Cache Penetration

문제 상황

존재하지 않는 데이터에 대한 요청이 반복되면, 매번 캐시 미스 → DB 조회 → 결과 없음의 사이클이 반복됩니다. 악의적인 공격에 특히 취약합니다.

PLAINTEXT
요청: GET /user/9999999999 (존재하지 않는 ID)
→ 캐시 미스 → DB 조회 → 결과 없음 → 캐시에 아무것도 안 넣음
→ 다음 요청도 같은 과정 반복 → DB 부하

해결 1: Null 캐싱

DB에 데이터가 없을 때 null(또는 빈 값)도 캐싱합니다.

JAVA
public User getUserWithNullCache(Long userId) {
    String key = "user:" + userId;
    // EMPTY 마커와 일반 값 구분
    ValueWrapper cached = cacheManager.getCache("users").get(key);

    if (cached != null) {
        if (cached.get() == null) return null;  // null 캐시 히트
        return (User) cached.get();
    }

    User user = userRepository.findById(userId).orElse(null);

    if (user == null) {
        // null도 캐시 — 짧은 TTL 설정
        redisTemplate.opsForValue().set(key, "NULL", Duration.ofMinutes(5));
    } else {
        redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
    }
    return user;
}

주의: null 캐시의 TTL은 짧게 설정해야 합니다. 나중에 실제 데이터가 생겼을 때 반영되어야 하니까요.

해결 2: 블룸 필터

존재하는 데이터의 ID를 블룸 필터에 넣어두고, 요청이 오면 먼저 블룸 필터로 확인합니다.

JAVA
// Redis의 블룸 필터 모듈 (RedisBloom)
// 또는 Guava BloomFilter 사용

// 1. 데이터 등록 시 블룸 필터에 추가
public void createUser(User user) {
    userRepository.save(user);
    // BF.ADD user_filter {userId}
    redisTemplate.execute("BF.ADD", "user:filter", user.getId().toString());
}

// 2. 조회 시 블룸 필터 먼저 확인
public User getUser(Long userId) {
    // BF.EXISTS user_filter {userId}
    Boolean exists = redisTemplate.execute("BF.EXISTS", "user:filter", userId.toString());
    if (!Boolean.TRUE.equals(exists)) {
        return null;  // 블룸 필터에 없으면 DB도 조회하지 않음
    }

    // 블룸 필터에 있으면 캐시/DB 조회
    return getUserFromCacheOrDb(userId);
}

블룸 필터는 "없는 것"은 확실하게 판별하고, "있는 것"은 확률적으로 판별합니다 (false positive 가능, false negative 불가).

해결 3: 요청 검증

애플리케이션 레벨에서 유효하지 않은 요청을 사전에 차단합니다.

JAVA
public User getUser(Long userId) {
    // ID 범위 검증
    if (userId <= 0 || userId > MAX_USER_ID) {
        return null;
    }
    // 패턴 검증, Rate Limiting 등
    return getUserFromCacheOrDb(userId);
}

Cache Avalanche

문제 상황

대량의 캐시 키가 동시에 만료되거나, 캐시 서버 자체가 다운되어 모든 요청이 DB로 몰리는 현상입니다.

PLAINTEXT
시나리오 1: 배포 시 캐시 초기화 → 모든 키 만료 → DB 폭주
시나리오 2: TTL을 3600초로 동일하게 설정 → 1시간 후 동시 만료
시나리오 3: Redis 서버 장애 → 모든 트래픽 DB 직행

해결 1: TTL 분산 (Jitter)

JAVA
private Duration randomizeTtl(Duration baseTtl) {
    // 기본 TTL에 ±20% 랜덤 추가
    long baseSeconds = baseTtl.getSeconds();
    long jitter = (long) (baseSeconds * 0.2 * (Math.random() * 2 - 1));
    return Duration.ofSeconds(baseSeconds + jitter);
}

// 사용
redisTemplate.opsForValue().set(key, value, randomizeTtl(Duration.ofMinutes(30)));
// 24~36분 사이의 랜덤 TTL

해결 2: 다단계 캐시 (Multi-Level Cache)

PLAINTEXT
L1: 로컬 캐시 (Caffeine, Guava) — 수 초~수 분
L2: Redis — 수 분~수 시간
L3: DB
JAVA
// L1 로컬 캐시
private final Cache<String, User> localCache = Caffeine.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(Duration.ofSeconds(30))
    .build();

public User getUser(Long userId) {
    String key = "user:" + userId;

    // L1 확인
    User local = localCache.getIfPresent(key);
    if (local != null) return local;

    // L2 확인 (Redis)
    User redis = redisTemplate.opsForValue().get(key);
    if (redis != null) {
        localCache.put(key, redis);
        return redis;
    }

    // L3 (DB)
    User user = userRepository.findById(userId).orElseThrow();
    redisTemplate.opsForValue().set(key, user, randomizeTtl(Duration.ofMinutes(30)));
    localCache.put(key, user);
    return user;
}

해결 3: 서킷 브레이커

Redis나 DB 장애 시 요청을 빠르게 실패시켜 시스템을 보호합니다.

JAVA
@CircuitBreaker(name = "redis", fallbackMethod = "fallback")
public User getUser(Long userId) {
    return getUserFromRedis(userId);
}

public User fallback(Long userId, Throwable t) {
    // 폴백: 기본값 반환, 로컬 캐시 조회, 또는 에러 응답
    return localCache.getIfPresent("user:" + userId);
}

핫 키(Hot Key) 문제

특정 키에 요청이 극단적으로 집중되는 현상입니다. Redis는 싱글 스레드이므로 하나의 키에 초당 수만 요청이 몰리면 해당 Redis 인스턴스가 병목이 됩니다.

해결 전략

JAVA
// 1. 키 복제 — 같은 데이터를 여러 키에 분산
String key = "hot:item:" + (userId % 10);  // 10개 복제본
redisTemplate.opsForValue().get(key);

// 2. 로컬 캐시 — Redis 앞에 애플리케이션 캐시
// Redis에는 초당 1회만 접근, 나머지는 로컬에서 처리

// 3. 읽기 레플리카 — Redis Cluster에서 READONLY 사용

캐시 워밍업 전략

새 캐시 서버를 띄우거나 배포 후 캐시가 비어있는 Cold Start 문제를 해결합니다.

JAVA
@Component
public class CacheWarmer implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) {
        // 1. 인기 데이터 사전 로딩
        List<Long> hotUserIds = analyticsService.getTopUserIds(1000);
        hotUserIds.parallelStream().forEach(id -> {
            User user = userRepository.findById(id).orElse(null);
            if (user != null) {
                redisTemplate.opsForValue().set(
                    "user:" + id, user, randomizeTtl(Duration.ofMinutes(30))
                );
            }
        });

        // 2. 이전 캐시 서버에서 데이터 마이그레이션 (가능한 경우)
        // 3. RDB 파일로 초기 데이터 로드
    }
}

정리

문제원인핵심 해결책
Stampede핫 키 만료 시 동시 DB 조회뮤텍스, 논리적 만료, PER
Penetration존재하지 않는 데이터 반복 요청Null 캐싱, 블룸 필터
Avalanche대량 키 동시 만료 또는 캐시 장애TTL 분산, 다단계 캐시, 서킷 브레이커
Hot Key특정 키에 트래픽 집중키 복제, 로컬 캐시, 읽기 레플리카

캐시 문제 패턴은 독립적으로 발생하기도 하지만 동시에 발생하기도 합니다. Avalanche 중에 Stampede가 연쇄적으로 발생하는 식입니다. 따라서 여러 방어 전략을 조합하여 적용하는 것이 중요합니다.

댓글 로딩 중...