캐시 문제 패턴 — Stampede, Penetration, Avalanche
캐시를 도입하면 성능이 좋아지는데, 캐시가 오히려 장애의 원인이 되는 경우는 어떤 것들이 있을까요?
캐시 문제 패턴이란
캐시는 성능을 극적으로 향상시키지만, 잘못 설계하면 DB를 보호하기는커녕 장애를 증폭시킵니다. Cache Stampede, Penetration, Avalanche는 대표적인 세 가지 캐시 장애 패턴이고, 이를 이해하고 방어하는 것이 안정적인 캐시 시스템의 핵심입니다.
Cache Stampede (Thundering Herd)
문제 상황
인기 있는 키(핫 키)의 TTL이 만료되는 순간, 수백~수천 개의 요청이 동시에 캐시 미스를 겪고 DB에 몰려가는 현상입니다.
시간 T: 캐시 키 "popular:item" 만료
시간 T+1ms: 요청 1000개가 동시에 캐시 미스
→ 1000개 요청이 모두 DB에 동일 쿼리 실행
→ DB 부하 폭증
해결 1: 뮤텍스 (분산 락)
캐시 미스 시 하나의 요청만 DB를 조회하고, 나머지는 기다립니다.
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은 길게 설정하고, 값 안에 논리적 만료 시간을 넣어서 백그라운드에서 갱신합니다.
@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)
만료 시간이 가까워질수록 확률적으로 미리 갱신합니다.
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 조회 → 결과 없음의 사이클이 반복됩니다. 악의적인 공격에 특히 취약합니다.
요청: GET /user/9999999999 (존재하지 않는 ID)
→ 캐시 미스 → DB 조회 → 결과 없음 → 캐시에 아무것도 안 넣음
→ 다음 요청도 같은 과정 반복 → DB 부하
해결 1: Null 캐싱
DB에 데이터가 없을 때 null(또는 빈 값)도 캐싱합니다.
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를 블룸 필터에 넣어두고, 요청이 오면 먼저 블룸 필터로 확인합니다.
// 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: 요청 검증
애플리케이션 레벨에서 유효하지 않은 요청을 사전에 차단합니다.
public User getUser(Long userId) {
// ID 범위 검증
if (userId <= 0 || userId > MAX_USER_ID) {
return null;
}
// 패턴 검증, Rate Limiting 등
return getUserFromCacheOrDb(userId);
}
Cache Avalanche
문제 상황
대량의 캐시 키가 동시에 만료되거나, 캐시 서버 자체가 다운되어 모든 요청이 DB로 몰리는 현상입니다.
시나리오 1: 배포 시 캐시 초기화 → 모든 키 만료 → DB 폭주
시나리오 2: TTL을 3600초로 동일하게 설정 → 1시간 후 동시 만료
시나리오 3: Redis 서버 장애 → 모든 트래픽 DB 직행
해결 1: TTL 분산 (Jitter)
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)
L1: 로컬 캐시 (Caffeine, Guava) — 수 초~수 분
L2: Redis — 수 분~수 시간
L3: DB
// 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 장애 시 요청을 빠르게 실패시켜 시스템을 보호합니다.
@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 인스턴스가 병목이 됩니다.
해결 전략
// 1. 키 복제 — 같은 데이터를 여러 키에 분산
String key = "hot:item:" + (userId % 10); // 10개 복제본
redisTemplate.opsForValue().get(key);
// 2. 로컬 캐시 — Redis 앞에 애플리케이션 캐시
// Redis에는 초당 1회만 접근, 나머지는 로컬에서 처리
// 3. 읽기 레플리카 — Redis Cluster에서 READONLY 사용
캐시 워밍업 전략
새 캐시 서버를 띄우거나 배포 후 캐시가 비어있는 Cold Start 문제를 해결합니다.
@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가 연쇄적으로 발생하는 식입니다. 따라서 여러 방어 전략을 조합하여 적용하는 것이 중요합니다.