Redis와 Bloom filter: Spring Boot에서 캐시 관통 방지하기
1. 개요
캐시 관통(cache penetration)은 클라이언트가 계속해서 존재하지 않는 키를 요청할 때 발생합니다. 캐시에는 데이터가 없으므로 모든 요청이 데이터베이스로 내려갑니다. 이 글은 왜 Bloom filter가 이런 상황에 대한 저렴하고 효과적인 방어막인지 설명합니다. Spring Boot 3.x 애플리케이션에서 Redisson을 통해 Redis 위에 구현하는 방법도 배우게 될 것입니다.
이 글을 다 읽으면, filter의 매개변수를 선택하는 방법, 초기 데이터를 로드하는 방법, filter를 읽기 및 쓰기 흐름에 통합하는 방법, 그리고 test를 작성하여 동작을 검증하는 방법을 알게 됩니다.
2. 캐시 관통이란?
ProductService가 있고 잘 알려진 cache-aside 패턴을 사용한다고 가정해봅시다:
public Optional<Product> findById(long id) {
RBucket<Product> bucket = redisson.getBucket("product:" + id, JsonJacksonCodec.INSTANCE);
Product cached = bucket.get();
if (cached != null) {
return Optional.of(cached);
}
Optional<Product> fromDb = productRepository.findById(id); // DB 조회
fromDb.ifPresent(p -> bucket.set(p, Duration.ofMinutes(30)));
return fromDb;
}
이 흐름은 실제 데이터에 대해 괜찮습니다. 이제 누군가가 id = -1, id = 999999999 또는 임의의 id를 가진 수천 개의 요청을 보낼 때 어떤 일이 발생하는지 보세요:
- Redis는 키가 없으므로
null을 반환합니다. - 코드가 DB로 내려가고, DB도 찾을 수 없습니다.
- 캐시에 아무것도 저장되지 않으므로 다음 요청도 정확히 같은 절차를 반복합니다.
결과적으로 캐시는 무용지물이 되고 DB가 전체 부하를 감당하게 됩니다. 이것은 클라이언트 측 버그, 크롤러 또는 의도적인 공격일 수 있습니다.
첫 번째 일반적인 해결책은 null 값을 짧은 TTL로 캐시하는 것입니다. 나쁜 키의 집합이 작을 때 효과적입니다. 공격자가 무작위 id를 사용할 때는 실패합니다: 각 id는 한 번만 나타나고, 수백만 개의 무의미한 빈 키를 저장해야 합니다. 이것이 Bloom filter가 빛을 발하는 순간입니다.
3. Bloom filter는 어떻게 작동하나요?
Bloom filter는 두 가지 구성요소로 이루어진 확률적 데이터 구조입니다:
mbit의 배열, 처음에는 모두 0입니다.khash 함수, 각각 배열의 한 위치에 요소를 매핑합니다.
주요 작업은 다음과 같습니다:
- add(x):
x의khash를 계산하고 해당 비트를 1로 설정합니다. - contains(x):
x의khash를 계산합니다. 어느 하나라도 0이면x는 절대로 추가되지 않았습니다. 모두 1이면x는 추가되었을 수 있습니다.
가장 중요한 특성은 거짓 음성(false negative)은 없고 거짓 양성(false positive)만 있다는 것입니다. filter가 “없음”이라고 답할 때, 당신은 완전히 믿고 캐시나 DB에 접근할 필요 없이 404를 반환할 수 있습니다. filter가 “있을 수 있음”이라고 답할 때는 정상 흐름을 진행하세요. 이 작은 오류율은 완전히 수용 가능합니다.
Filter 크기는 예상 요소 수 n과 원하는 거짓 양성 비율 p에서 계산됩니다:
m = -n * ln(p) / (ln 2)^2
k = (m / n) * ln 2
n = 1_000_000 제품과 p = 0.01(1%)을 예로 들면:
m ≈ 9_585_059 bit ≈ 1.14 MB
k ≈ 7 hash 함수
Redis에서 약 1MB의 RAM만으로 백만 개의 id를 보호할 수 있습니다. 이 숫자는 id 자체를 Set에 저장하는 것보다 훨씬 작습니다.
4. Redisson 설정
Redisson은 RBloomFilter를 제공하며, 순수한 Redis bitmap으로 구현됩니다. 따라서 추가 모듈이 필요 없는 모든 Redis 버전에서 작동합니다.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
이 starter는 spring.data.redis.* 속성에서 연결 설정을 자동으로 읽습니다:
spring.data.redis.host=localhost
spring.data.redis.port=6379
이 단계 후 어디서나 RedissonClient bean을 주입할 수 있습니다.
5. Bloom filter 초기화
filter를 bean으로 만들어 애플리케이션 전체에서 공유하세요:
@Configuration
public class BloomFilterConfig {
public static final String PRODUCT_FILTER = "bf:product:ids";
@Bean
public RBloomFilter<Long> productIdFilter(RedissonClient redisson) {
RBloomFilter<Long> filter = redisson.getBloomFilter(PRODUCT_FILTER);
boolean created = filter.tryInit(1_000_000L, 0.01);
if (!created) {
// Filter가 이미 Redis에 존재, 기존 설정 유지
log.info("Bloom filter {} 이미 존재, size={} bit, k={}",
PRODUCT_FILTER, filter.getSize(), filter.getHashIterations());
}
return filter;
}
private static final Logger log = LoggerFactory.getLogger(BloomFilterConfig.class);
}
위 코드는 다음을 수행합니다:
getBloomFilter(...)는 proxy 객체를 생성할 뿐 아직 Redis에 아무것도 쓰지 않습니다.tryInit(1_000_000L, 0.01)은 섹션 3의 공식에 따라m,k를 계산한 다음 설정을 Redis의 보조 hash에 저장합니다. 예를 들어 애플리케이션의 다른 인스턴스가 이미 실행되었다면 메서드는false를 반환합니다.getSize()와getHashIterations()은 filter가 실제로 사용 중인 bit 수와 hash 함수 수를 알려줍니다.
위 매개변수를 사용하면 로그는 다음과 같이 출력됩니다:
Bloom filter bf:product:ids 이미 존재, size=9585058 bit, k=7
주의: Filter가 이미 존재할 때
tryInit은 설정을 변경하지 않습니다. 코드에서expectedInsertions을 변경했지만 기존 filter를 삭제하지 않으면 새 설정이 무시됩니다.
6. 초기 데이터 로드
빈 filter는 모든 id에 대해 “없음”이라고 답하므로 실제 id도 포함됩니다. 따라서 확인을 활성화하기 전에 기존의 모든 id를 로드해야 합니다.
@Component
public class ProductFilterWarmUp implements ApplicationRunner {
private final ProductRepository productRepository;
private final RBloomFilter<Long> productIdFilter;
public ProductFilterWarmUp(ProductRepository productRepository,
RBloomFilter<Long> productIdFilter) {
this.productRepository = productRepository;
this.productIdFilter = productIdFilter;
}
@Override
public void run(ApplicationArguments args) {
if (productIdFilter.count() > 0) {
return; // 다른 인스턴스에 의해 이미 로드됨
}
List<Long> ids = productRepository.findAllIds();
ids.forEach(productIdFilter::add);
log.info("Bloom filter에 {} 개의 id를 로드했습니다", ids.size());
}
private static final Logger log = LoggerFactory.getLogger(ProductFilterWarmUp.class);
}
여기서 ProductRepository.findAllIds()는 id 열만 가져옵니다, 예를 들어 JPA를 사용한다면 @Query("select p.id from Product p")입니다. count()는 켜진 bit 수를 기반으로 요소 수를 추정하므로 filter에 이미 데이터가 있는지 확인하기에 충분합니다.
수백만 개의 레코드가 있으면 List 대신 pagination이나 fetch size가 있는 Stream을 사용하세요. 각 add 명령은 Redis로의 왕복이므로 애플리케이션 시작을 늦추지 않도록 별도의 job에서 워밍업을 실행하는 것을 고려하세요.
7. ProductService에 통합
filter를 읽기 흐름의 캐시 이전에 배치합니다:
@Service
public class ProductService {
private final RedissonClient redisson;
private final RBloomFilter<Long> productIdFilter;
private final ProductRepository productRepository;
public ProductService(RedissonClient redisson,
RBloomFilter<Long> productIdFilter,
ProductRepository productRepository) {
this.redisson = redisson;
this.productIdFilter = productIdFilter;
this.productRepository = productRepository;
}
public Optional<Product> findById(long id) {
if (!productIdFilter.contains(id)) {
return Optional.empty(); // 확실히 존재하지 않음
}
RBucket<Product> bucket = redisson.getBucket("product:" + id, JsonJacksonCodec.INSTANCE);
Product cached = bucket.get();
if (cached != null) {
return Optional.of(cached);
}
Optional<Product> fromDb = productRepository.findById(id);
fromDb.ifPresent(p -> bucket.set(p, Duration.ofMinutes(30)));
return fromDb;
}
@Transactional
public Product create(Product product) {
Product saved = productRepository.save(product);
productIdFilter.add(saved.id());
return saved;
}
}
여기서 Product는 단순한 record record Product(Long id, String name, BigDecimal price)입니다. 각 단계를 살펴봅시다:
contains(id)가false를 반환하면 id가 확실히 시스템에 없습니다. 요청이 한 번의 Redis 명령 비용으로 즉시 차단되고 DB 조회는 없습니다.- Filter가
true를 반환하면 요청이 일반적인 cache-aside 흐름으로 진행됩니다. 거짓 양성으로 인해 존재하지 않는 약 1%의 id가 여전히 통과합니다. 이 작은 양은 DB가 쉽게 처리합니다. create에서 새 id는 filter에 추가됩니다. 이 단계를 잊으면 새로 생성된 제품이 사용자에게 “사라집니다”.
쓰기 순서에 대해, add가 transaction 내에서 실행되지만 commit이 실패하면 filter에 존재하지 않는 id가 포함됩니다. 그 결과는 거짓 양성만 추가되므로 완전히 무해합니다. 반대 경우가 문제입니다: add를 commit 후에만 실행하면 제품이 이미 DB에 있지만 filter는 여전히 “없음”이라고 응답하는 짧은 기간이 생깁니다. 이것은 당신이 만드는 거짓 음성입니다. 따라서 DB 쓰기 전이나 동시에 filter에 추가하는 것을 선호합니다.
8. Filter 동작 테스트
Redis 위에서 테스트하려면 Testcontainers를 추가하세요:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
다음 테스트는 두 가지 핵심 특성을 검증합니다: 거짓 음성이 없고, 거짓 양성 비율이 구성된 값에 가깝습니다.
@Testcontainers
class ProductBloomFilterTest {
@Container
static GenericContainer<?> redis =
new GenericContainer<>("redis:7.4-alpine").withExposedPorts(6379);
static RedissonClient client;
static RBloomFilter<Long> filter;
@BeforeAll
static void setUp() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(6379));
client = Redisson.create(config);
filter = client.getBloomFilter("test:product:ids");
filter.tryInit(5_000L, 0.01);
LongStream.rangeClosed(1, 5_000).forEach(filter::add);
}
@AfterAll
static void tearDown() {
client.shutdown();
}
@Test
void givenAddedIds_whenContains_thenAlwaysTrue() {
boolean allPresent = LongStream.rangeClosed(1, 5_000).allMatch(filter::contains);
assertThat(allPresent).isTrue();
}
@Test
void givenUnknownIds_whenContains_thenFalsePositiveRateIsLow() {
long falsePositives = LongStream.rangeClosed(5_001, 10_000)
.filter(filter::contains)
.count();
double rate = falsePositives / 5_000.0;
assertThat(rate).isLessThan(0.02);
}
}
첫 번째 테스트는 추가된 모든 id가 인식되는지 확인합니다. 두 번째 테스트는 추가되지 않은 id 중에서 filter가 여전히 “있을 수 있음”이라고 보고하는 수를 셉니다. p = 0.01에서 측정된 비율은 일반적으로 약 1% 근처에서 변동합니다. 2%의 임계값은 test가 flaky하지 않도록 도와줍니다.
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
9. 대안: Redis의 기본 Bloom 명령
Redis Stack 또는 Redis 8 이상을 사용하면 (확률적 데이터 유형이 내장되어 있음) BF.* 명령을 직접 사용할 수 있습니다:
redis-cli BF.RESERVE bf:product:ids 0.01 1000000
redis-cli BF.MADD bf:product:ids 1 2 3
redis-cli BF.EXISTS bf:product:ids 2
redis-cli BF.EXISTS bf:product:ids 42
OK
1) (integer) 1
2) (integer) 1
3) (integer) 1
(integer) 1
(integer) 0
네이티브 버전에는 몇 가지 이점이 있습니다. 각 BF.EXISTS 명령은 서버 측에서 hash를 한 번만 계산합니다. BF.MADD는 한 명령에 여러 요소를 추가할 수 있습니다. 또한 filter는 용량을 초과하면 자동으로 확장될 수 있습니다. 반대로 Redisson의 RBloomFilter는 “순수한” Redis에서 작동하며, 모듈을 지원하지 않는 관리형 서비스도 포함합니다. 현재 인프라에 따라 선택하세요.
10. 일반적인 실수와 사용하면 안 되는 경우
주의: 고전적인 Bloom filter는 삭제를 지원하지 않습니다. bit를 끄면 다른 요소를 “잃을” 수 있어 거짓 음성을 만듭니다.
기억해야 할 점들:
- 데이터 삭제: 제품이 삭제되면 id는 여전히 filter에 남아 있습니다. 이것은 안전합니다. 거짓 양성이 될 뿐입니다. 그러나 삭제 비율이 높으면 filter가 시간에 따라 “더러워집니다”. 해결책은 정기적으로 새 key에 filter를 다시 빌드(예:
bf:product:ids:v2)한 다음RENAME으로 전환하는 것입니다. 또 다른 옵션은 삭제를 지원하는 Cuckoo filter(CF.*)를 사용하는 것입니다. - 예상 용량 초과: 실제로 예상보다 5배 많은 요소가 있고 1백만 개로 선언된 filter가 있다면 거짓 양성 비율이 급증합니다.
count()를getExpectedInsertions()과 비교하여 모니터링하고 임계값에 도달하기 전에 다시 빌드합니다. - 데이터 로드 잊음 또는 미완료: 빈 filter 또는 불완전한 filter는 합법적인 요청도 차단합니다. feature flag를 사용하여 워밍업이 완료될 때까지만 확인을 활성화하세요.
- 여러 쓰기 경로: 데이터가 batch job, CSV import 또는 다른 service를 통해 생성되면 모든 쓰기 경로가 filter를 업데이트해야 합니다. 더 안전한 방법은 CDC나 event를 통해 업데이트하는 것입니다.
- 사용하면 안 되는 경우: 작은 키 집합(수천 개 요소, Redis Set으로 충분함), 미리 열거할 수 없는 키(예: 자유 검색 결과), 또는 DB와 filter 간의 동기화 지연을 허용할 수 없는 시스템입니다.
Bloom filter와 짧은 TTL의 null 값 캐싱을 결합하는 것은 합리적인 다층 방어입니다. Bloom filter는 대부분의 쓰레기 id를 차단하고, null 캐시는 통과한 소수의 거짓 양성을 처리합니다.
11. 결론
캐시 관통은 존재하지 않는 키를 요청할 때 캐시를 무용지물로 만듭니다. Bloom filter는 매우 낮은 비용으로 이를 해결합니다: 백만 개의 id에 약 1MB, 각 확인마다 하나의 Redis 명령, 그리고 거짓 음성 없음을 보장합니다.
이 글에서 filter 크기를 계산하고 Redisson RBloomFilter를 사용하여 초기화하는 방법을 배웠습니다. 초기 데이터를 로드하고, filter를 ProductService의 캐시 이전에 배치하고, Testcontainers로 테스트를 작성하여 동작을 검증했습니다. 마지막으로 filter를 정기적으로 다시 빌드하고 모든 데이터 쓰기 경로를 동기화된 상태로 유지할 계획을 세우세요.