Cache-Aside의 기본 구조는 단순하다.
캐시 조회
→ Cache Miss
→ DB 조회
→ 캐시 저장
하지만 요청이 많은 운영 환경에서는 Cache Miss 하나가 예상보다 큰 장애로 이어질 수 있다.
대표적으로 다음 문제들을 고려해야 한다.
Cache Stampede : 캐시 만료 순간 DB 요청이 폭증하는 문제
Hot Key : 특정 Redis Key에 요청이 집중되는 문제
Cache Penetration: 존재하지 않는 데이터를 반복 조회하는 문제
Cache Avalanche : 많은 캐시가 동시에 만료되는 문제
장애 전파 : Redis 장애가 DB 장애로 이어지는 문제
각각 어떤 상황에서 발생하고, 실무에서는 어떻게 대응하는지 알아보자.
Cache Stampede란?
Cache Stampede는 인기 데이터의 캐시가 만료되는 순간 여러 요청이 동시에 DB를 조회하는 문제다.
예를 들어 초당 1,000번 조회되는 상품이 있다고 해보자.
product:100 캐시 만료
↓
1,000개 요청이 동시에 Cache Miss
↓
1,000개 요청이 동시에 DB 조회
↓
DB 부하 급증
캐시를 사용한 이유는 DB 요청을 줄이기 위해서였지만, 캐시가 만료되는 순간 오히려 DB에 더 큰 부하가 발생한다.
분산 락으로 한 요청만 DB를 조회한다
대표적인 해결 방법은 캐시를 다시 생성하는 작업에 락을 거는 것이다.
여러 요청에서 Cache Miss 발생
↓
분산 락 획득 경쟁
↓
한 요청만 락 획득 성공
↓
DB 조회
↓
캐시 저장
↓
나머지 요청은
생성된 캐시를 다시 조회
Java 형태의 의사 코드로 표현하면 다음과 같다.
public ProductResponse getProduct(Long productId) {
String key = "product:" + productId;
String lockKey = "lock:" + key;
ProductResponse cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
String lockToken = UUID.randomUUID().toString();
boolean acquired = redisLock.tryLock(
lockKey,
lockToken,
Duration.ofSeconds(3)
);
if (!acquired) {
return waitAndReadCache(key);
}
try {
// 락을 기다리는 동안 다른 요청이 캐시를 만들었을 수 있다.
cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
Product product = productRepository.findById(productId)
.orElseThrow(ProductNotFoundException::new);
ProductResponse response = ProductResponse.from(product);
redisTemplate.opsForValue().set(
key,
response,
createRandomTtl()
);
return response;
} finally {
redisLock.unlock(lockKey, lockToken);
}
}
여기서 중요한 부분은 락을 획득한 뒤 캐시를 한 번 더 확인하는 것이다.
1. Worker A와 B 모두 Cache Miss
2. Worker A가 락 획득
3. Worker A가 DB 조회 후 캐시 저장
4. Worker B가 뒤늦게 락 획득
5. Worker B가 캐시를 다시 확인
6. 이미 데이터가 있으므로 DB를 조회하지 않음
이를 Double Check라고 볼 수 있다.
Redis로 락을 직접 구현한다면 SET NX PX로 만료 시간이 있는 락을 만들고, 락을 해제할 때는 자신이 생성한 토큰과 일치하는지 확인해야 한다.
SET lock:product:100 random-token NX PX 3000
단순히 DEL lock:product:100을 실행하면 안 된다. 내 락이 만료된 뒤 다른 요청이 획득한 락을 삭제할 수 있기 때문이다. Redis 공식 문서도 고유한 토큰을 저장하고, 토큰이 일치할 때만 Lua Script 등으로 삭제하는 방식을 설명한다. Redis 분산 락 문서
락 획득에 실패한 요청도 DB를 조회하면 안 된다
다음과 같이 구현하면 분산 락을 사용한 의미가 없다.
락 획득 성공 → DB 조회
락 획득 실패 → DB 조회
락 획득에 실패한 요청은 다음 중 하나를 선택해야 한다.
잠시 기다린 뒤 캐시 재조회
기존의 오래된 캐시 반환
요청을 빠르게 실패 처리
제한된 수의 요청만 DB 조회 허용
모든 요청을 무조건 DB로 보내면 결국 Cache Stampede가 다시 발생한다.
인기 데이터는 오래된 값을 잠시 반환할 수도 있다
분산 락 방식은 캐시를 생성하는 동안 일부 요청이 기다려야 한다.
상품 상세나 인기 게시글처럼 약간 오래된 데이터를 보여줘도 괜찮다면, 만료된 데이터를 즉시 제거하지 않고 잠시 반환하는 방법도 사용할 수 있다.
이를 흔히 stale-while-revalidate 또는 논리적 만료 방식이라고 한다.
캐시에는 데이터와 논리적인 만료 시간을 함께 저장한다.
{
"data": {
"productId": 100,
"name": "키보드",
"price": 15000
},
"expiresAt": "2026-08-29T12:00:00"
}
논리적 만료 시간이 지났더라도 데이터를 바로 삭제하지 않는다.
논리적 만료 전
→ 캐시 데이터 즉시 반환
논리적 만료 후
→ 기존 캐시 데이터는 우선 반환
→ 한 워커만 백그라운드에서 최신 데이터로 갱신
그러면 인기 데이터가 만료되더라도 사용자의 요청이 DB 조회를 기다리지 않는다.
다만 가격, 재고, 권한 정보처럼 오래된 값이 사용자에게 직접적인 문제를 일으킬 수 있는 데이터에는 신중하게 적용해야 한다.
TTL에 무작위 시간을 추가한다
많은 캐시를 동일한 시각에 생성하면서 모두 10분 TTL을 설정하면, 10분 뒤 비슷한 시점에 캐시가 한꺼번에 만료될 수 있다.
12:00 상품 캐시 10만 개 생성
TTL 모두 10분
↓
12:10 캐시 10만 개 만료
↓
DB 요청 급증
이를 줄이기 위해 TTL에 무작위 값을 추가한다.
private Duration createRandomTtl() {
long baseTtlSeconds = 600;
long jitterSeconds = ThreadLocalRandom.current()
.nextLong(0, 121);
return Duration.ofSeconds(
baseTtlSeconds + jitterSeconds
);
}
그러면 각 캐시가 10분에서 12분 사이에 나뉘어 만료된다.
product:1 → 10분 13초
product:2 → 11분 42초
product:3 → 10분 55초
TTL Jitter는 캐시 만료 시점을 분산할 뿐, 특정 핫키 하나가 만료될 때 발생하는 Stampede를 완전히 막지는 못한다.
따라서 상황에 따라 분산 락이나 사전 갱신과 함께 사용해야 한다.
Hot Key란?
Hot Key는 특정 Redis Key에 요청이 과도하게 집중되는 문제다.
product:100 → 초당 50,000회 조회
product:101 → 초당 10회 조회
product:102 → 초당 3회 조회
Redis Cluster를 사용해도 하나의 Key는 하나의 shard에 저장된다.
Redis Cluster
Shard 1 → product:100 요청 집중
Shard 2 → 한가함
Shard 3 → 한가함
따라서 전체 Redis Cluster의 용량이 충분하더라도 Hot Key를 가진 특정 shard의 CPU나 네트워크가 병목이 될 수 있다.
Redis 공식 문서에서는 Hot Key를 특정 key가 초당 수천 회 이상처럼 매우 자주 접근되는 상황으로 설명하며, 읽기 전용 Hot Key라면 애플리케이션 로컬 캐시를 고려하라고 안내한다. Redis Hot Key 모니터링
애플리케이션 로컬 캐시 사용
자주 조회되면서 몇 초 정도의 오래된 값을 허용할 수 있는 데이터는 애플리케이션 내부 메모리에 짧게 저장할 수 있다.
Application Local Cache
↓ Cache Miss
Redis
↓ Cache Miss
DB
예를 들어 각 애플리케이션 서버에 5초짜리 로컬 캐시를 둔다.
초당 50,000 요청
→ 각 서버에서 5초 동안 로컬 캐시 사용
→ Redis 요청 횟수 감소
Caffeine 같은 로컬 캐시를 사용할 수 있다.
private final Cache<Long, ProductResponse> localCache =
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(5))
.build();
public ProductResponse getProduct(Long productId) {
return localCache.get(
productId,
this::getProductFromRedisOrDatabase
);
}
단, 애플리케이션 서버마다 서로 다른 데이터를 보유할 수 있으므로 정합성에 민감한 데이터에는 적합하지 않을 수 있다.
필요하다면 Redis의 client-side caching과 invalidation 기능도 검토할 수 있다. Redis Client-side Caching
Hot Key의 다른 대응 방법
데이터 특성에 따라 다음 방법을 사용할 수 있다.
- CDN이나 HTTP Cache로 애플리케이션 요청 자체를 줄인다.
- Redis Read Replica로 읽기 요청을 분산한다.
- Hot Key 데이터를 여러 key에 복제하고 요청을 분산한다.
- 큰 value라면 데이터를 분리하거나 크기를 줄인다.
- 인기 데이터를 만료 전에 미리 갱신한다.
- 애플리케이션에서 동일 요청을 합치는 Request Coalescing을 적용한다.
Hot Key를 여러 key로 복제하면 갱신 시 모든 복제본을 함께 무효화해야 하므로 운영 복잡성이 증가한다. 따라서 로컬 캐시나 CDN처럼 더 단순한 방법을 먼저 검토하는 편이 좋다.
Cache Penetration이란?
Cache Penetration은 DB에 존재하지 않는 데이터를 반복해서 요청하는 문제다.
예를 들어 다음 상품은 존재하지 않는다고 해보자.
product:999999
요청할 때마다 다음 흐름이 반복된다.
Redis 조회 → 없음
DB 조회 → 없음
응답 종료
다음 요청
Redis 조회 → 없음
DB 조회 → 없음
DB 조회 결과가 없어서 캐시에 아무것도 저장하지 않으면 같은 요청이 들어올 때마다 DB를 조회한다.
잘못된 ID를 대량으로 보내는 공격이나 크롤러로 인해 DB 부하가 발생할 수도 있다.
존재하지 않는 결과도 짧게 캐시한다
조회 결과가 없다는 사실을 짧은 시간 동안 캐시할 수 있다.
private static final String NOT_FOUND = "__NOT_FOUND__";
public ProductResponse getProduct(Long productId) {
String key = "product:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (NOT_FOUND.equals(cached)) {
throw new ProductNotFoundException();
}
if (cached != null) {
return deserialize(cached);
}
Optional<Product> product =
productRepository.findById(productId);
if (product.isEmpty()) {
redisTemplate.opsForValue().set(
key,
NOT_FOUND,
Duration.ofSeconds(30)
);
throw new ProductNotFoundException();
}
ProductResponse response =
ProductResponse.from(product.get());
redisTemplate.opsForValue().set(
key,
serialize(response),
createRandomTtl()
);
return response;
}
존재하지 않는 결과는 실제 데이터보다 짧은 TTL을 적용하는 것이 좋다.
일반 상품 캐시 → 10~12분
존재하지 않는 상품 → 30초
상품이 새로 생성될 가능성이 있으므로 존재하지 않는 결과를 너무 오래 캐시하면 안 된다.
또한 DB timeout이나 일시적인 장애를 “상품이 존재하지 않음”으로 캐시해서도 안 된다.
DB 조회 결과 없음
→ Negative Cache 가능
DB timeout 또는 DB 장애
→ Negative Cache로 저장하면 안 됨
요청 가능한 ID 범위가 매우 크고 존재하지 않는 key 공격이 심하다면 Bloom Filter도 고려할 수 있다. 다만 데이터 추가 시 Bloom Filter도 갱신해야 하므로 일반적인 서비스에 무조건 필요한 것은 아니다.
Cache Avalanche란?
Cache Avalanche는 많은 캐시가 동시에 만료되거나 Redis 자체에 장애가 발생하면서 DB 요청이 대량으로 증가하는 문제다.
많은 Key 동시 만료
또는
Redis 장애
↓
대부분의 요청이 Cache Miss
↓
DB 요청 폭증
Cache Stampede가 특정 인기 key 하나를 중심으로 발생한다면, Cache Avalanche는 많은 key 또는 캐시 시스템 전체를 중심으로 발생한다.
대응 방법은 다음과 같다.
TTL Jitter 적용
캐시를 여러 시점에 나누어 미리 적재
인기 데이터 사전 갱신
Redis 고가용성 구성
DB 동시 요청 수 제한
Rate Limiting
Circuit Breaker
오래된 로컬 캐시 반환
Redis 장애 시 모든 요청을 DB로 보내면 될까?
Cache-Aside는 캐시가 없어도 DB를 조회할 수 있다는 장점이 있다.
하지만 Redis 장애 시 모든 요청을 그대로 DB로 보내는 것은 위험하다.
평상시
100,000 요청
→ Redis 99,000
→ DB 1,000
Redis 장애
100,000 요청
→ DB 100,000
DB가 평소보다 100배 많은 요청을 견딜 수 없다면 Redis 장애가 DB 장애로 전파된다.
따라서 Redis 장애 시에도 DB를 보호해야 한다.
예를 들면:
동시에 DB로 내려갈 수 있는 요청 수 제한
중요하지 않은 요청은 빠르게 실패
오래된 로컬 캐시가 있으면 임시 반환
특정 API Rate Limit
Redis timeout을 짧게 설정
Circuit Breaker로 반복되는 Redis 호출 차단
서비스 특성에 따라 장애 정책도 달라진다.
상품 상세
→ 오래된 데이터를 잠시 반환할 수 있음
재고 확인
→ 오래된 데이터 반환 위험
→ 실패 처리하거나 원본 DB 확인
권한 정보
→ 잘못된 캐시 반환 위험
→ 정합성을 우선
모든 데이터에 동일한 장애 대응 정책을 적용하기보다, 데이터의 정합성 중요도에 따라 결정해야 한다.
DB 변경과 캐시 삭제의 순서
기존 예제처럼 @Transactional 메서드 내부에서 바로 Redis 캐시를 삭제하면 DB가 아직 커밋되기 전에 캐시가 먼저 삭제될 수 있다.
1. DB 데이터 수정
2. 캐시 삭제
3. DB 트랜잭션 롤백
이 경우 DB 변경은 취소됐지만 캐시만 삭제된다. 데이터가 잘못되는 것은 아니더라도 불필요한 Cache Miss가 발생한다.
일반적으로는 DB 트랜잭션이 정상적으로 커밋된 뒤 캐시를 삭제한다.
@Transactional
public void updateProduct(
Long productId,
UpdateProductRequest request
) {
Product product = productRepository.findById(productId)
.orElseThrow(ProductNotFoundException::new);
product.update(request);
eventPublisher.publishEvent(
new ProductUpdatedEvent(productId)
);
}
커밋 이후 이벤트를 처리한다.
@Component
@RequiredArgsConstructor
public class ProductCacheEvictListener {
private final RedisTemplate<String, Object> redisTemplate;
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
public void evict(ProductUpdatedEvent event) {
redisTemplate.delete(
"product:" + event.productId()
);
}
}
Spring의 @TransactionalEventListener는 이벤트 처리를 트랜잭션 단계와 연결할 수 있으며, AFTER_COMMIT은 트랜잭션이 정상적으로 커밋된 뒤 실행된다. Spring Transaction-bound Events
다만 이것도 완벽한 원자성을 보장하지는 않는다.
DB 커밋 성공
→ 애플리케이션 프로세스 종료
→ 캐시 삭제 실행 실패
이 경우 이전 캐시가 TTL 동안 남을 수 있다.
짧은 불일치를 허용할 수 있다면 TTL을 최종 안전장치로 사용한다. 캐시 무효화가 반드시 전달돼야 한다면 재시도 가능한 이벤트나 Transactional Outbox, CDC 기반 캐시 무효화를 검토할 수 있다.
Redis 메모리가 가득 차면 어떻게 될까?
TTL만 설정한다고 Redis 메모리 문제가 모두 해결되는 것은 아니다.
Redis를 캐시로 사용한다면 maxmemory와 eviction policy를 설정해야 한다.
대표적인 정책은 다음과 같다.
allkeys-lru
→ 최근에 사용되지 않은 key부터 제거
allkeys-lfu
→ 사용 빈도가 낮은 key부터 제거
noeviction
→ 메모리가 가득 차면 새로운 쓰기 명령 실패
Redis 공식 문서는 일부 데이터가 다른 데이터보다 자주 조회되는 일반적인 캐시 패턴에서는 allkeys-lru를 기본 선택지로 고려할 수 있다고 설명한다. 실제 정책은 서비스의 접근 패턴에 맞춰 결정해야 한다. Redis Key Eviction
무엇을 모니터링해야 할까?
Cache-Aside는 구현 후 캐시가 존재하는지만 확인해서는 안 된다.
최소한 다음 지표를 관찰하는 것이 좋다.
Cache Hit / Miss 비율
Redis 응답 시간 p95, p99
DB fallback 요청 수
캐시 재생성 시간
분산 락 획득 실패 수
Redis shard별 CPU 사용량
Hot Key와 Large Key
Evicted Key 수
Redis timeout 및 connection 오류
DB connection pool 사용량
Redis의 INFO stats에서는 keyspace_hits, keyspace_misses 등을 확인할 수 있다. Redis 캐시 통계와 Eviction
Cache Hit 비율이 갑자기 낮아졌다면 다음과 같은 상황을 의심할 수 있다.
대량 캐시 만료
TTL 설정 오류
eviction 증가
캐시 key 변경
Redis 장애
특정 배포 후 캐시 무효화 증가
정리
Cache-Aside는 캐시가 없으면 DB에서 조회하고, 그 결과를 캐시에 저장하는 비교적 단순한 패턴이다.
하지만 실무에서는 단순히 Cache Miss → DB 조회만 구현해서는 부족하다.
Cache Stampede
→ 한 요청만 캐시를 생성하도록 분산 락 또는 Singleflight 적용
인기 데이터 갱신
→ 오래된 데이터를 반환하면서 백그라운드 갱신 고려
동시 만료
→ TTL Jitter로 만료 시점 분산
Hot Key
→ 로컬 캐시, CDN, Read Replica 등으로 요청 분산
Cache Penetration
→ 존재하지 않는 결과를 짧게 캐시
Redis 장애
→ DB 동시 요청 제한과 Circuit Breaker로 장애 전파 방지
데이터 변경
→ DB 커밋 후 캐시 삭제
캐시 삭제 실패
→ TTL, 재시도 이벤트, Outbox 등을 최종 안전장치로 사용
결국 Cache-Aside를 실무에서 운영한다는 것은 단순히 Redis를 앞에 두는 것이 아니다.
캐시가 만료되거나 Redis에 장애가 발생했을 때, 트래픽이 원본 데이터베이스로 한꺼번에 몰리지 않도록 보호하고 데이터의 중요도에 맞는 정합성 정책을 설계하는 것까지 포함한다.
'CS 정리' 카테고리의 다른 글
| 이벤트 루프란? 싱글 스레드는 어떻게 수많은 요청을 처리할까 (0) | 2026.09.01 |
|---|---|
| DNS TTL이란? TTL을 300초로 설정하면 정말 5분 뒤에 IP가 바뀔까? (0) | 2026.08.31 |
| Cache-Aside란? (0) | 2026.08.28 |
| 분산 트랜잭션이란? MSA에서 발생하는 문제와 Saga 패턴 (0) | 2026.08.24 |
| CQRS란? 읽기와 쓰기를 분리하는 이유 (0) | 2026.08.24 |