존재하지 않는 키를 반복 조회하면 캐시는 매번 미스이고 DB 도 매번 "없음" 을 답한다. 캐시가 아무것도 막아 주지 못하고 모든 요청이 그대로 DB 로 관통한다.
캐시의 전제는 "한 번 읽은 것을 담아 두면 다음엔 안 간다" 인데, 담을 것이 없으므로 그 전제가 깨진다. 스탬피드나 어밸런치가 특정 순간의 사고인 반면 이것은 상시로 새는 구멍이다.
정상 트래픽에서도 생기고(삭제된 상품 링크가 SNS 에 도는 경우), 존재하지 않는 ID 를 무작위로 던지면 캐시를 우회해 DB 를 직접 때리는 공격 통로가 된다.
⓪ 애초에 들어오지 못하게 한다
가장 싼 방어가 먼저다. 상품 ID 가 양의 정수인데 -1 이나 abc 가 들어왔다면 캐시도 DB 도 볼 필요가 없다. 컨트롤러 검증에서 잘라 내면 그만이다.
무작위 공격의 상당수가 형식조차 맞지 않으므로 이것만으로 걸러지는 양이 많다. 아래 두 방법은 형식은 맞는데 실제로 없는 키를 위한 것이다.
① 없음도 캐시한다
Product p = repo.findById(id).orElse(null);
cache.put(id, p == null ? NULL_MARKER : p,
p == null ? Duration.ofSeconds(60) : Duration.ofHours(1));
가장 간단하다. 다만 두 가지를 지켜야 한다.
- TTL 을 짧게 — 나중에 진짜로 생성됐을 때 오래 "없음" 으로 남으면 안 된다. 정상 데이터가 1시간이면 없음은 1분 정도로 둔다
- 없음을 나타내는 표시를 따로 정한다 — 캐시에
null을 그대로 넣으면 "캐시에 없다(미스)" 와 "없다고 캐시돼 있다" 를 구분할 수 없다. 빈 문자열이나 전용 표식 객체를 쓴다
무작위 ID 공격에는 약하다. 매번 다른 키라 없음 캐시가 쌓이기만 하고 재사용되지 않아 메모리를 잡아먹고, 결국 정상 데이터를 축출한다.
② 블룸 필터 — 캐시에 닿기 전에 거른다
"이 키가 존재할 수 있는가" 만 판단하는 아주 작은 자료구조다.
비트 배열 하나 + 서로 다른 해시 함수 k개
넣을 때 : 키를 k번 해싱해 나온 k자리의 비트를 1로 세운다
확인할 때: k자리가 전부 1이면 "아마 있다"
한 자리라도 0이면 "확실히 없다"
핵심 성질은 거짓 음성이 없다는 것이다. 비트가 0 이라는 것은 그 자리를 세운 적이 없다는 뜻이고, 넣었다면 반드시 세웠을 것이므로 확실히 없다. 반대로 전부 1 이어도 다른 키들이 우연히 그 자리들을 채웠을 수 있어 거짓 양성은 생긴다.
이 비대칭이 정확히 우리가 원하는 모양이다.
- "확실히 없다" 로 걸러 낸 것은 캐시도 DB 도 갈 필요가 없다 — 확실하므로 안심하고 잘라 낸다
- 거짓 양성은 그냥 평소대로 캐시·DB 를 거칠 뿐이라 틀려도 손해가 없다
값을 저장하지 않고 비트만 세우므로 수백만 개 키의 존재 여부를 몇 MB 로 들 수 있다. 거짓 양성률은 비트 배열 크기와 해시 개수로 조절하는데, 넉넉히 잡아도 해시맵으로 같은 일을 하는 것보다 훨씬 작다. 메모리를 아끼는 대신 정확도를 한 방향으로만 내주는 맞바꿈이다.
대신 개별 삭제가 안 된다. 비트를 0 으로 되돌리면 그 자리를 공유하던 다른 키까지 "없다" 고 답하게 되고, 그 순간 거짓 음성이 생겨 성질 자체가 무너진다. 그래서 주기적으로 통째로 다시 만들거나, 비트 대신 카운터를 두는 변형(counting Bloom filter)을 쓴다.
면접 함정
- ❌ "블룸 필터가 있으면 없음 캐시는 필요 없다" → 거짓 양성은 통과하므로 둘을 같이 쓴다.
- ❌ "거짓 양성 때문에 못 믿는다" → 없다고 답한 것은 항상 옳다. 그 방향만 쓰면 안전하다.
- ❌ "필터에서 키를 지우면 된다" → 지우는 순간 거짓 음성이 생겨 못 쓰게 된다.