Bloom Filter란?
Bloom Filter는 1970년 Burton Howard Bloom이 제안한 공간 효율적인 확률적 자료구조입니다. “특정 원소가 집합에 포함되어 있는가?”라는 질문에 답하는데, 재미있게도 “아마도 있다” 또는 **“확실히 없다”**라는 두 가지 대답만 합니다.
┌─────────────────────────────────────────────────────────┐│ Bloom Filter │├─────────────────────────────────────────────────────────┤│ "apple" 검색 → "아마도 있음" (실제로 있을 수도, 없을 수도) ││ "grape" 검색 → "확실히 없음" (100% 보장) │└─────────────────────────────────────────────────────────┘이 특성 때문에 False Positive(없는데 있다고 함)는 발생하지만, False Negative(있는데 없다고 함)는 절대 발생하지 않습니다.
왜 Bloom Filter를 사용하는가?
문제 상황
수십억 개의 URL을 저장한 데이터베이스가 있다고 가정해봅시다. 사용자가 입력한 URL이 이미 존재하는지 확인하려면:
# 순진한 접근법def url_exists(url: str) -> bool: return db.query("SELECT 1 FROM urls WHERE url = ?", url) is not None매번 디스크 I/O가 발생하며, 수십억 레코드에서의 조회는 인덱스가 있어도 비용이 큽니다.
Bloom Filter 해결책
# Bloom Filter 사용def url_exists_optimized(url: str) -> bool: if not bloom_filter.might_contain(url): return False # 확실히 없음 - DB 조회 불필요!
# "아마도 있음" - DB에서 확인 필요 return db.query("SELECT 1 FROM urls WHERE url = ?", url) is not NoneBloom Filter가 “없다”고 하면 DB 조회를 완전히 건너뛸 수 있습니다. 대부분의 조회가 “없음”인 경우(예: 스팸 필터링), 엄청난 성능 향상을 얻을 수 있습니다.
작동 원리
기본 구조
Bloom Filter는 두 가지 요소로 구성됩니다:
- 비트 배열: m비트 크기의 배열 (초기값 모두 0)
- 해시 함수들: k개의 독립적인 해시 함수
삽입 연산
원소를 삽입할 때, k개의 해시 함수로 k개의 위치를 계산하고 해당 비트를 1로 설정합니다.
"apple" 삽입 (k=3 해시 함수 사용)├── hash1("apple") = 2├── hash2("apple") = 5└── hash3("apple") = 9
비트 배열 변화:Before: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]After: [0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0] ↑ ↑ ↑ [2] [5] [9]조회 연산
원소를 조회할 때, 동일한 k개의 해시 함수로 위치를 계산하고 모든 비트가 1인지 확인합니다.
"apple" 조회├── hash1("apple") = 2 → 비트[2] = 1 ✓├── hash2("apple") = 5 → 비트[5] = 1 ✓└── hash3("apple") = 9 → 비트[9] = 1 ✓→ 모두 1이므로 "아마도 있음" 반환
"grape" 조회├── hash1("grape") = 3 → 비트[3] = 0 ✗→ 하나라도 0이면 "확실히 없음" 반환TypeScript로 구현하기
실제로 동작하는 Bloom Filter를 구현해보겠습니다.
import { createHash } from 'crypto';
class BloomFilter { private bitArray: Uint8Array; private size: number; private hashCount: number;
constructor(size: number, hashCount: number) { this.size = size; this.hashCount = hashCount; // 비트 배열을 바이트 단위로 저장 this.bitArray = new Uint8Array(Math.ceil(size / 8)); }
// 여러 해시 함수를 시뮬레이션 (Double Hashing 기법) private getHashPositions(item: string): number[] { const hash1 = this.hash(item, 'sha256'); const hash2 = this.hash(item, 'md5');
const positions: number[] = []; for (let i = 0; i < this.hashCount; i++) { // h(i) = (hash1 + i * hash2) mod size const position = Math.abs((hash1 + i * hash2) % this.size); positions.push(position); } return positions; }
private hash(item: string, algorithm: string): number { const hashBuffer = createHash(algorithm).update(item).digest(); // 첫 4바이트를 정수로 변환 return hashBuffer.readUInt32BE(0); }
private setBit(position: number): void { const byteIndex = Math.floor(position / 8); const bitIndex = position % 8; this.bitArray[byteIndex] |= (1 << bitIndex); }
private getBit(position: number): boolean { const byteIndex = Math.floor(position / 8); const bitIndex = position % 8; return (this.bitArray[byteIndex] & (1 << bitIndex)) !== 0; }
add(item: string): void { const positions = this.getHashPositions(item); for (const pos of positions) { this.setBit(pos); } }
mightContain(item: string): boolean { const positions = this.getHashPositions(item); return positions.every(pos => this.getBit(pos)); }
// False Positive 확률 계산 getFalsePositiveRate(insertedCount: number): number { // (1 - e^(-k*n/m))^k const exponent = -this.hashCount * insertedCount / this.size; return Math.pow(1 - Math.exp(exponent), this.hashCount); }}사용 예시
// 10만 비트, 7개 해시 함수const bloom = new BloomFilter(100000, 7);
// URL 추가bloom.add('https://example.com/page1');bloom.add('https://example.com/page2');bloom.add('https://example.com/page3');
// 조회console.log(bloom.mightContain('https://example.com/page1')); // trueconsole.log(bloom.mightContain('https://example.com/unknown')); // false (대부분)
// False Positive 확률 확인console.log(bloom.getFalsePositiveRate(3)); // 약 0.000002%최적의 파라미터 선택
Bloom Filter의 성능은 세 가지 파라미터에 의해 결정됩니다:
- n: 예상 삽입 원소 수
- m: 비트 배열 크기
- k: 해시 함수 개수
False Positive 확률 공식
$$ P(fp) = \left(1 - e^{-\frac{kn}{m}}\right)^k $$
최적 해시 함수 개수
주어진 m과 n에 대해 최적의 k는:
$$ k_{optimal} = \frac{m}{n} \ln 2 \approx 0.693 \times \frac{m}{n} $$
필요한 비트 수 계산
원하는 False Positive 확률 p에 대해 필요한 비트 수:
$$ m = -\frac{n \ln p}{(\ln 2)^2} $$
function calculateOptimalParameters( expectedItems: number, falsePositiveRate: number): { bits: number; hashFunctions: number } { // 필요한 비트 수 const bits = Math.ceil( -expectedItems * Math.log(falsePositiveRate) / Math.pow(Math.log(2), 2) );
// 최적 해시 함수 개수 const hashFunctions = Math.round( (bits / expectedItems) * Math.log(2) );
return { bits, hashFunctions };}
// 100만 개 원소, 1% False Positive 확률const params = calculateOptimalParameters(1_000_000, 0.01);console.log(params);// { bits: 9585059, hashFunctions: 7 }// 약 1.2MB로 100만 개 원소 처리 가능!실전 활용 사례
1. 캐시 관통(Cache Penetration) 방지
존재하지 않는 키에 대한 요청이 캐시를 우회하여 DB에 직접 도달하는 문제를 방지합니다.
class CacheWithBloomFilter { private bloom: BloomFilter; private cache: Map<string, any>;
constructor() { this.bloom = new BloomFilter(1000000, 7); this.cache = new Map(); }
async get(key: string): Promise<any | null> { // 1. Bloom Filter로 먼저 확인 if (!this.bloom.mightContain(key)) { return null; // 확실히 없음 - DB 조회 불필요 }
// 2. 캐시 확인 if (this.cache.has(key)) { return this.cache.get(key); }
// 3. DB 조회 (Bloom Filter가 "있을 수도"라고 했으므로) const value = await this.fetchFromDB(key); if (value !== null) { this.cache.set(key, value); } return value; }
async set(key: string, value: any): Promise<void> { this.bloom.add(key); this.cache.set(key, value); await this.saveToDb(key, value); }}2. 중복 요청 필터링
크롤러나 스트리밍 시스템에서 이미 처리한 항목을 필터링합니다.
class WebCrawler { private visitedUrls: BloomFilter;
constructor() { // 1억 개 URL, 0.1% False Positive const { bits, hashFunctions } = calculateOptimalParameters(100_000_000, 0.001); this.visitedUrls = new BloomFilter(bits, hashFunctions); }
async crawl(url: string): Promise<void> { if (this.visitedUrls.mightContain(url)) { console.log(`Skipping likely visited URL: ${url}`); return; }
this.visitedUrls.add(url);
// 실제 크롤링 수행 const html = await fetch(url).then(r => r.text()); const links = this.extractLinks(html);
for (const link of links) { await this.crawl(link); } }}3. 스팸/악성 URL 필터링
브라우저의 세이프 브라우징 기능은 Bloom Filter를 사용합니다.
class SafeBrowsingFilter { private maliciousUrls: BloomFilter;
constructor(knownMaliciousUrls: string[]) { const { bits, hashFunctions } = calculateOptimalParameters( knownMaliciousUrls.length, 0.0001 // 0.01% False Positive );
this.maliciousUrls = new BloomFilter(bits, hashFunctions);
for (const url of knownMaliciousUrls) { this.maliciousUrls.add(url); } }
checkUrl(url: string): 'safe' | 'potentially_malicious' { if (this.maliciousUrls.mightContain(url)) { return 'potentially_malicious'; // 서버에 추가 확인 필요 } return 'safe'; // 100% 안전 }}Redis의 Bloom Filter
Redis는 RedisBloom 모듈을 통해 Bloom Filter를 네이티브로 지원합니다.
# RedisBloom 모듈 로드redis-server --loadmodule /path/to/redisbloom.soimport { createClient } from 'redis';
const client = createClient();await client.connect();
// Bloom Filter 생성 (100만 원소, 1% 에러율)await client.bf.reserve('myfilter', 0.01, 1000000);
// 원소 추가await client.bf.add('myfilter', 'item1');await client.bf.mAdd('myfilter', ['item2', 'item3', 'item4']);
// 존재 여부 확인const exists = await client.bf.exists('myfilter', 'item1'); // trueconst notExists = await client.bf.exists('myfilter', 'unknown'); // false (대부분)
// 여러 원소 한번에 확인const results = await client.bf.mExists('myfilter', ['item1', 'unknown']);// [true, false]Redis Bloom Filter 활용 패턴
class DistributedDuplicateChecker { private redis: RedisClient; private filterKey: string;
constructor(redis: RedisClient, filterKey: string) { this.redis = redis; this.filterKey = filterKey; }
async initialize(expectedItems: number, errorRate: number): Promise<void> { try { await this.redis.bf.reserve(this.filterKey, errorRate, expectedItems); } catch (e) { // 이미 존재하는 경우 무시 if (!e.message.includes('item exists')) throw e; } }
async addIfNotExists(item: string): Promise<boolean> { // BF.ADD는 이미 존재하면 0, 새로 추가하면 1 반환 const added = await this.redis.bf.add(this.filterKey, item); return added === 1; }
async processEvent(eventId: string, handler: () => Promise<void>): Promise<void> { const isNew = await this.addIfNotExists(eventId);
if (isNew) { await handler(); } else { console.log(`Event ${eventId} likely already processed, skipping`); } }}Counting Bloom Filter
기본 Bloom Filter의 단점 중 하나는 삭제가 불가능하다는 것입니다. 비트를 0으로 설정하면 다른 원소까지 영향을 받을 수 있기 때문입니다.
Counting Bloom Filter는 각 비트 대신 카운터를 사용하여 이 문제를 해결합니다.
class CountingBloomFilter { private counters: Uint8Array; private size: number; private hashCount: number;
constructor(size: number, hashCount: number) { this.size = size; this.hashCount = hashCount; this.counters = new Uint8Array(size); // 각 위치에 카운터 }
add(item: string): void { const positions = this.getHashPositions(item); for (const pos of positions) { if (this.counters[pos] < 255) { // 오버플로우 방지 this.counters[pos]++; } } }
remove(item: string): boolean { // 먼저 존재하는지 확인 if (!this.mightContain(item)) { return false; }
const positions = this.getHashPositions(item); for (const pos of positions) { if (this.counters[pos] > 0) { this.counters[pos]--; } } return true; }
mightContain(item: string): boolean { const positions = this.getHashPositions(item); return positions.every(pos => this.counters[pos] > 0); }}Bloom Filter vs 다른 자료구조
| 특성 | HashSet | Bloom Filter | Cuckoo Filter |
|---|---|---|---|
| 메모리 | 높음 | 매우 낮음 | 낮음 |
| 삭제 지원 | O | X | O |
| False Positive | 없음 | 있음 | 있음 |
| False Negative | 없음 | 없음 | 없음 |
| 조회 시간 | O(1) | O(k) | O(1) |
주의사항
1. False Positive 처리
Bloom Filter가 “있음”이라고 해도 반드시 존재하는 것은 아닙니다. 중요한 작업에는 이중 확인이 필요합니다.
async function safeCheck(item: string): Promise<boolean> { if (!bloomFilter.mightContain(item)) { return false; // 확실히 없음 }
// Bloom Filter 긍정 응답은 반드시 실제 저장소에서 확인 return await actualDataStore.contains(item);}2. 용량 초과 시 성능 저하
예상보다 많은 원소가 삽입되면 False Positive 확률이 급격히 증가합니다.
// 모니터링 추가class MonitoredBloomFilter extends BloomFilter { private insertCount = 0; private expectedCapacity: number;
constructor(size: number, hashCount: number, expectedCapacity: number) { super(size, hashCount); this.expectedCapacity = expectedCapacity; }
add(item: string): void { super.add(item); this.insertCount++;
if (this.insertCount > this.expectedCapacity * 0.9) { console.warn('Bloom Filter approaching capacity, consider resizing'); } }}3. 크기 조정 불가
Bloom Filter는 생성 후 크기를 변경할 수 없습니다. Scalable Bloom Filter를 사용하면 이 문제를 해결할 수 있습니다.
class ScalableBloomFilter { private filters: BloomFilter[] = []; private targetFPR: number; private scaleFactor: number; private initialSize: number;
constructor(initialSize: number, targetFPR: number, scaleFactor = 2) { this.initialSize = initialSize; this.targetFPR = targetFPR; this.scaleFactor = scaleFactor; this.addNewFilter(); }
private addNewFilter(): void { const filterIndex = this.filters.length; // 각 새 필터는 더 낮은 FPR을 가짐 const fpr = this.targetFPR * Math.pow(0.5, filterIndex); const size = this.initialSize * Math.pow(this.scaleFactor, filterIndex); const { hashFunctions } = calculateOptimalParameters(size, fpr);
this.filters.push(new BloomFilter(size, hashFunctions)); }
add(item: string): void { const lastFilter = this.filters[this.filters.length - 1];
// 마지막 필터가 가득 찼으면 새 필터 추가 if (lastFilter.isFull()) { this.addNewFilter(); }
this.filters[this.filters.length - 1].add(item); }
mightContain(item: string): boolean { // 모든 필터를 확인 return this.filters.some(filter => filter.mightContain(item)); }}마치며
Bloom Filter는 “확실히 없음”을 빠르게 판단할 수 있는 강력한 도구입니다. 메모리가 제한적이거나 대용량 데이터의 존재 여부를 빠르게 확인해야 할 때 특히 유용합니다.
핵심 포인트를 정리하면:
- False Negative 없음: “없다”는 응답은 100% 신뢰할 수 있음
- 공간 효율성: 원본 데이터 대비 극소량의 메모리로 동작
- O(k) 시간 복잡도: 해시 함수 개수에만 의존, 데이터 크기와 무관
- 단방향: 기본 Bloom Filter는 삭제 불가
캐시 시스템, 분산 데이터베이스, 네트워크 라우터 등 다양한 곳에서 활용되는 이 우아한 자료구조를 여러분의 시스템에도 적용해보세요.