1. 문제 상황 - Cache Penetration
1.1. 시스템 배경 및 현황
실제와 유사한 사례를 통해 Redis 운영 중 발생할 수 있는 문제를 경험하고, 이를 구현하여 해결 전략 적용 전후의 성능 차이를 수치로 검증하고자 한다. 현재 'BookHub'라는 도서 관리 시스템을 운영 중인 상황을 가정한다. BookHub 시스템은 현재까지 안정적으로 동작해 왔으며, 하루 평균 100만 건의 도서 조회 요청을 처리하고 있다. 이 중 약 85%는 Redis 캐시를 통해 응답하며 효율적으로 운영되고 있다. 사용자들은 API를 통해 실시간으로 도서 정보를 조회하며, 특히 인기 도서 목록과 신간 도서 정보에 대한 요청 비중이 가장 높다.
1.2. 이상 징후 포착
서버와 Redis에 대한 지표를 시각화하여 모니터링하던 중, 지난 15분 동안의 Cache Hit Ratio가 85%에서 26%로 급락했다는 알림을 확인하였다.
1.3. 원인 파악 및 분석
로그를 분석한 결과, 무작위 IP로부터 존재하지 않는 도서 ID를 지속적으로 조회하는 패턴이 포착되었다. 즉, 서버에 존재하지 않는 리소스를 대상으로 한 반복적인 요청이 발생하고 있으며, 특히 무작위 숫자 조합의 ID로 검색하는 형태가 두드러졌다.
이러한 요청들은 매번 캐시 미스(Cache Miss)를 발생시키고 결과적으로 DB 조회로 이어져 서버 부하를 가중시킨다. 이는 존재하지 않는 데이터를 반복 요청하여 캐시를 무력화하는 'Cache Penetration' 현상이다. 이러한 상황은 악의적인 공격이나 버그성 트래픽에 의해 발생할 수도 있고, 외부 블로그나 광고에 잘못된 URL이 노출되어 사라진 키 값에 대한 조회가 집중될 때 발생하기도 한다.
이로 인해 DB 부하가 급증하여 DB CPU 사용률이 평소 대비 30% 증가하였다. 요약하자면, Cache Penetration은 캐시와 DB 어디에도 존재하지 않는 데이터에 대한 요청이 반복되어, 캐시의 존재 의미가 사라지고 DB에 직접적인 부하를 주는 문제를 의미한다.
2. 문제 상황 다이어그램
존재하지 않는 ID로 반복 요청이 들어올 때의 시스템 흐름은 다음과 같다.

3. 문제 해결 - NULL 값도 캐싱한다
3.1. 해결 전략의 핵심
이 문제의 핵심은 "존재하지 않는 데이터에 대해 캐시가 형성되지 않는다"는 점에 있다. 해결 방법은 간단하다. 존재하지 않는 데이터에 대해서도 '조회 결과가 없음'을 나타내는 값을 캐싱하는 것이다.
예를 들어, 존재하지 않는 게시글 ID(예: -1)에 대해 반복적인 요청이 들어올 경우, __NULL__과 같이 사전에 정의된 특수한 값을 캐싱하여 다음 요청 시 DB까지 도달하지 않도록 차단한다.
3.2. 해결 전략 적용 다이어그램

4. 코드 구현: ImprovedBookRedisService
존재하지 않는 데이터에 대해 __NULL__ 값을 캐싱하는 로직을 구현한 코드는 다음과 같다.
@RequiredArgsConstructor
@Service
public class ImprovedBookRedisService {
private static final String NULL_VALUE = "__NULL__"; // null을 대체할 객체
private final BookRepository bookRepository;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private final MeterRegistry meterRegistry;
// Null 값 캐싱을 통한 Cache Penetration 방지
public Book findBookByIdWithNullCache(Long id) throws JsonProcessingException {
// 1. 캐시에서 먼저 조회
String cachedResult = redisTemplate.opsForValue().get(id.toString());
// 2. 캐시에 존재하지만 NULL_VALUE가 저장되어 있다면 (데이터가 없음을 의미)
if (cachedResult != null && cachedResult.equals(NULL_VALUE)) {
meterRegistry.counter("cache.hit", "service", "ImprovedBookRedisService").increment();
return null;
}
// 3. 캐시에 존재하지만 NULL_VALUE 는 아닌 경우
if (cachedResult != null) {
meterRegistry.counter("cache.hit", "service", "ImprovedBookRedisService").increment();
return objectMapper.readValue(cachedResult, new TypeReference<>() {
});
}
meterRegistry.counter("cache.miss", "service", "ImprovedBookRedisService").increment();
// 3. 캐시에 아예 없는 경우 DB에서 조회
Book bookFromDb = bookRepository.findById(id).orElse(null);
meterRegistry.counter("db.select", "service", "ImprovedBookRedisService").increment();
// 5. DB 조회 결과가 null이더라도 캐시에 저장 (짧은 TTL 적용)
if (bookFromDb == null) {
redisTemplate.opsForValue().set(id.toString(), NULL_VALUE, 5, TimeUnit.MINUTES);
return bookFromDb;
}
String serializedBookFromDb = objectMapper.writeValueAsString(bookFromDb);
redisTemplate.opsForValue().set(id.toString(), serializedBookFromDb, 30, TimeUnit.MINUTES);
return bookFromDb;
}
}
5. 성능 검증 테스트
개선 전후의 성능을 비교하기 위한 테스트 코드를 작성하였다.
@Slf4j
class CachePenetrationTest extends AbstractBookTest {
@Autowired
private SimpleMeterRegistry registry;
@Autowired
private BookRedisService bookRedisService;
@Autowired
private ImprovedBookRedisService improvedBookRedisService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@DisplayName("Cache Penetration 발생")
@RepeatedTest(REPEATED_COUNT)
void findBookByIdWithCachePenetration(RepetitionInfo repetitionInfo) {
measureAndRecordTime("Cache Penetration 발생", repetitionInfo.getCurrentRepetition(), () -> {
try {
bookRedisService.findBookById(-1L);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
});
if (repetitionInfo.getCurrentRepetition() == REPEATED_COUNT) {
printCacheStats("BookRedisService");
clean();
}
}
@DisplayName("NULL 캐싱 적용")
@RepeatedTest(REPEATED_COUNT)
void findBookByIdWithNullCache(RepetitionInfo repetitionInfo) {
measureAndRecordTime("NULL 캐싱 적용", repetitionInfo.getCurrentRepetition(), () -> {
try {
improvedBookRedisService.findBookByIdWithNullCache(-1L);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
});
if (repetitionInfo.getCurrentRepetition() == REPEATED_COUNT) {
printCacheStats("ImprovedBookRedisService");
clean();
}
}
private void clean() {
TestLogUtil.CleanStart();
Boolean delete = redisTemplate.delete("-1");
if (delete) {
log.info(">>> 삭제 성공");
} else {
log.info(">>> 삭제 실패");
}
TestLogUtil.CleanEnd();
}
private void printCacheStats(String serviceName) {
double hit = registry.counter("cache.hit", "service", serviceName).count();
double miss = registry.counter("cache.miss", "service", serviceName).count();
double total = hit + miss;
double hitRate = calculateHitRatio(hit, total);
double dbSelect = registry.counter("db.select", "service", serviceName).count();
log.info(">>> 캐시 매트릭[{}] 캐시 히트: {}, 미스: {}, 전체 요청: {}, 히트율: {}%", serviceName, (int) hit, (int) miss, (int) total, String.format("%.2f", hitRate));
log.info(">>> DB 매트릭[{}] 조회 요청: {}", serviceName, dbSelect);
}
private static double calculateHitRatio(double hit, double total) {
if (total == 0) {
return 0;
}
return (hit / total) * 100;
}
}
6. 테스트 결과 분석 및 결론
6.1. 개선 전 결과 분석
Cache Penetration 발생 케이스에서는 20번의 테스트가 수행되는 동안 매번 다음과 같은 쿼리가 실행되었다.
select b1_0.id, b1_0.is_sold_out, b1_0.name
from ch6_book b1_0
where b1_0.id=?
이에 따른 로그 출력 결과는 다음과 같다.
- 평균 소요 시간: 3.88ms
- 캐시 매트릭: 캐시 히트 0, 미스 20, 히트율 0.00%
- DB 매트릭: 조회 요청 20.0회
- 기타: Redis 키 삭제 실패 (캐싱된 데이터가 없음)
20회의 테스트 중 모든 요청에서 캐시 미스가 발생하여 매번 DB 조회가 일어났음을 확인할 수 있다.
6.2. NULL 캐싱 적용 후 결과 분석
반면 NULL 캐싱을 적용한 케이스에서는 20회의 테스트 중 쿼리가 단 한 번만 수행되었다.
select b1_0.id, b1_0.is_sold_out, b1_0.name
from ch6_book b1_0
where b1_0.id=?
-- 위 쿼리가 단 1회만 출력됨
로그 분석 결과는 다음과 같다.
- 평균 소요 시간: 0.66ms (상당히 단축됨)
- 캐시 매트릭: 캐시 히트 19, 미스 1, 히트율 95.00%
- DB 매트릭: 조회 요청 1.0회
- 기타: Redis 키 삭제 성공 (캐싱된 값이 존재함)
결과적으로 캐시 히트율이 95%로 크게 향상되었으며, DB 조회가 1회로 제한되어 시스템 부하가 획기적으로 감소하였다. 실행 시간 또한 훨씬 빨라졌음을 알 수 있다.
6.3. 결론
존재하지 않는 데이터에 대해서도 '없음(Null)' 상태를 짧은 TTL과 함께 캐싱함으로써, 악의적이거나 비정상적인 요청으로부터 DB를 보호하고 시스템의 전반적인 안정성과 효율성을 확보할 수 있다.
'Database > Redis' 카테고리의 다른 글
| [Optimization-10] 발생 가능 문제(3) - Hot Key (1) | 2025.12.28 |
|---|---|
| [Optimization-9] 발생 가능 문제(2) - Cache Avalanche (0) | 2025.12.28 |
| [Optimization-7] 모니터링 환경 구성 (0) | 2025.12.28 |
| [Optimization-6] Remote 캐싱의 이해와 실무 기초 (0) | 2025.12.28 |
| [Optimization-5] Remote 캐싱의 필요성 (0) | 2025.12.28 |
