Skip to content
Published on

확률적 자료구조 완전 가이드 2025: Bloom Filter, HyperLogLog, Count-Min Sketch, 실전 활용

Authors

TL;DR

  • 확률적 자료구조의 본질: 100% 정확도를 포기하고 메모리/속도를 극단적으로 절약
  • Bloom Filter: "이 원소가 집합에 있을 수 있다" — false positive O, false negative X. RocksDB, Cassandra, BigTable의 핵심
  • HyperLogLog: 12KB로 수십억 원소의 고유 카운트. Redis PFADD/PFCOUNT, BigQuery, Druid 사용
  • Count-Min Sketch: 스트림에서 원소 빈도 추정. 핫 키 탐지, DDoS 감지
  • 트레이드오프: 정확도 vs 메모리 vs 속도 — 정확한 답이 필요 없을 때 폭발적 효율

1. 확률적 자료구조란?

1.1 핵심 아이디어

"100% 정확한 답이 필요 없다면, 메모리와 속도를 극단적으로 절약할 수 있다."

전통적 자료구조:

  • HashMap으로 100만 원소 저장 → 약 50 MB
  • "이 원소가 있는가?" → 정확한 답

확률적 자료구조:

  • Bloom Filter로 100만 원소 저장 → 약 1 MB (50배 절약!)
  • "이 원소가 있는가?" → "있을 수도 있다" 또는 "확실히 없다"

트레이드오프: 메모리 50배 절약 + 약간의 false positive (1%).

1.2 왜 필요한가?

대규모 시스템에서:

  • 메모리는 비싸다 — RAM은 디스크의 100배+
  • L1/L2 캐시에 들어가야 빠름 → 작을수록 좋음
  • 수십억 원소 — HashMap이 불가능
  • 분산 시스템 — 작은 데이터가 네트워크 효율적

1.3 정확도가 필요 없는 경우

문제정확한 답 필요?확률적 OK?
사용자 인증
"이 URL을 본 적 있나?"
일일 활성 사용자 수❌ (대략 1000만이면 충분)
핫 페이지 탐지
캐시 사전 검증

2. Bloom Filter — 가장 유명한 확률적 자료구조

2.1 원리

비트 배열 + 여러 해시 함수.

삽입:

  1. 원소를 K개의 해시 함수에 통과 → K개의 인덱스
  2. 각 인덱스의 비트를 1로 설정

조회:

  1. 같은 K개 해시 함수
  2. 모든 인덱스의 비트가 1이면 → "있을 수도"
  3. 하나라도 0이면 → "확실히 없음"

2.2 시각화

비트 배열 (8비트):  [0, 0, 0, 0, 0, 0, 0, 0]

"apple" 삽입 (해시 → [1, 4]):
                  [0, 1, 0, 0, 1, 0, 0, 0]

"banana" 삽입 (해시 → [3, 6]):
                  [0, 1, 0, 1, 1, 0, 1, 0]

"apple" 조회 (해시 → [1, 4]): 둘 다 1"있을 수도""grape" 조회 (해시 → [2, 5]): 0이 있음 → "확실히 없음""cherry" 조회 (해시 → [1, 6]): 우연히 둘 다 1"있을 수도" ⚠️ (false positive)

2.3 false positive율 계산

수식:

P(fp) = (1 - e^(-k*n/m))^k
  • n: 원소 수
  • m: 비트 수
  • k: 해시 함수 수

최적 k:

k = (m/n) * ln(2)

1% false positive가 목표라면:

m/n ≈ 9.6 비트
k ≈ 7

100만 원소 = 1.2 MB + 7 해시 함수 = HashMap의 50배 작음.

2.4 Python 구현

import hashlib
import math

class BloomFilter:
    def __init__(self, n, fp_rate):
        self.size = -int(n * math.log(fp_rate) / (math.log(2) ** 2))
        self.hash_count = int(self.size / n * math.log(2))
        self.bits = [0] * self.size

    def _hashes(self, item):
        for i in range(self.hash_count):
            h = hashlib.md5(f"{item}{i}".encode()).hexdigest()
            yield int(h, 16) % self.size

    def add(self, item):
        for h in self._hashes(item):
            self.bits[h] = 1

    def contains(self, item):
        return all(self.bits[h] for h in self._hashes(item))

# 사용
bf = BloomFilter(n=1000000, fp_rate=0.01)
bf.add("alice@example.com")
print(bf.contains("alice@example.com"))  # True
print(bf.contains("bob@example.com"))    # 거의 항상 False

2.5 Bloom Filter의 한계

  • 삭제 불가능 — 비트를 0으로 하면 다른 원소에 영향
  • resize 어려움 — 새 필터 만들고 모든 원소 재삽입 필요
  • count 불가능 — 몇 개 들어있는지 모름

해결책: Counting Bloom Filter, Cuckoo Filter (아래 참조).

2.6 Bloom Filter의 실전 활용

RocksDB / Cassandra / BigTable

LSM 트리 기반 DB에서, SSTable마다 Bloom Filter:

쿼리: "key123"

SSTable 1Bloom Filter: "확실히 없음" → 디스크 안 읽음 ✅
SSTable 2Bloom Filter: "확실히 없음" → 디스크 안 읽음 ✅
SSTable 3Bloom Filter: "있을 수도"  → 디스크 읽음 (실제 검색)

효과: 디스크 I/O 99% 감소. 전체 SSTable을 안 읽음.

CDN 캐시

요청: GET /image/123.jpg

Bloom Filter: "캐시에 없음" → 즉시 origin 요청 (캐시 lookup 생략)
            "있을 수도"   → 캐시 lookup

효과: 캐시 미스 시 시간 절약.

비밀번호 누출 검사

HaveIBeenPwned의 비밀번호 1억+ 데이터베이스 → 클라이언트가 직접 확인 가능 (Bloom Filter 다운로드).

Chrome 악성 URL 검사

Google이 알려진 악성 URL 데이터베이스 → Bloom Filter로 클라이언트에 전송 → 브라우저가 즉시 확인.


3. Counting Bloom Filter

3.1 문제 해결

기본 Bloom Filter는 삭제 불가능. Counting Bloom Filter는 비트 대신 카운터:

배열: [0, 0, 0, 0, 0, 0, 0, 0]  (각 셀이 4비트 카운터)

"apple" 삽입 ([1, 4]):
        [0, 1, 0, 0, 1, 0, 0, 0]

"apple" 또 삽입:
        [0, 2, 0, 0, 2, 0, 0, 0]

"apple" 삭제:
        [0, 1, 0, 0, 1, 0, 0, 0]

3.2 단점

  • 메모리 4배 — 1비트 → 4비트 (오버플로 방지 위해)
  • 여전히 false positive 있음

3.3 Cuckoo Filter (대안)

Cuckoo Filter는 Bloom Filter보다 우수한 대안:

  • 삭제 가능
  • false positive율 더 낮음
  • 메모리 효율 비슷
  • "Cuckoo hashing" 기반
# pycuckoofilter 예시
from cuckoofilter import CuckooFilter

cf = CuckooFilter(capacity=1000000, error_rate=0.01)
cf.insert("alice")
print(cf.contains("alice"))  # True
cf.delete("alice")  # 가능!

언제 Cuckoo > Bloom: 삭제가 필요할 때, 더 낮은 false positive 필요할 때.


4. HyperLogLog — 카디널리티 추정

4.1 문제

"오늘 우리 사이트에 몇 명의 고유 방문자가 왔나?"

전통적 방법: HashSet에 모든 방문자 ID 저장 → 1000만 방문자 = 800 MB.

HyperLogLog: 12 KB로 수십억 원소까지 추정. 1% 오차.

4.2 기본 아이디어

원소를 해시 → 이진수의 선행 0의 개수 추적.

"alice" → hash → 00000010110...  (5개의 선행 0)
"bob"   → hash → 00100110...     (2개의 선행 0)
"carol" → hash → 0001110...      (3개의 선행 0)

관찰: 무작위 해시에서 K개의 선행 0이 나올 확률 = 1/2^K.

→ "최대 5개의 선행 0이 나왔다" → 약 2^5 = 32개의 고유 원소 추정.

현실: 단일 측정은 부정확. 여러 버킷에 나누어 평균 → 정확도 향상. 이게 HyperLogLog.

4.3 정확도

원소 수메모리표준 오차
1,00012 KB0.81%
1,000,00012 KB0.81%
1,000,000,00012 KB0.81%

핵심: 메모리는 원소 수에 무관! 1억 원소나 100만 원소나 같은 메모리.

4.4 Redis HyperLogLog

PFADD visitors:2025-04-15 "user1" "user2" "user3"
PFADD visitors:2025-04-15 "user1"  # 중복 무시
PFCOUNT visitors:2025-04-15  # 약 3 (2일 수도, 3일 수도)

# 여러 HLL 병합
PFMERGE visitors:week visitors:2025-04-14 visitors:2025-04-15

4.5 HyperLogLog의 실전 활용

일일 활성 사용자 (DAU)

import redis

r = redis.Redis()

def track_user(user_id):
    today = datetime.now().strftime("%Y-%m-%d")
    r.pfadd(f"dau:{today}", user_id)

def get_dau(date):
    return r.pfcount(f"dau:{date}")

수억 사용자도 12 KB로 처리 가능.

고유 IP 카운트 (DDoS 분석)

PFADD unique_ips:2025-04-15 "1.2.3.4" "5.6.7.8"
PFCOUNT unique_ips:2025-04-15

Big Data — BigQuery, Druid

SELECT APPROX_COUNT_DISTINCT(user_id) FROM events;

내부적으로 HyperLogLog 사용. 정확한 COUNT(DISTINCT) 대비 100배+ 빠름.


5. Count-Min Sketch — 빈도 추정

5.1 문제

스트림에서 "각 원소가 몇 번 나타났나?"

전통적: HashMap → 메모리 폭증.

Count-Min Sketch: 작은 메모리로 빈도 추정.

5.2 원리

2D 배열 (d × w) + d개의 해시 함수.

삽입:

  • 각 해시 함수로 인덱스 계산
  • 해당 카운터 증가

조회:

  • 같은 해시 함수
  • 모든 카운터 중 최솟값 반환 (그래서 "Count-Min")

5.3 시각화

배열 (3 x 5):
[0, 0, 0, 0, 0]  ← hash1
[0, 0, 0, 0, 0]  ← hash2
[0, 0, 0, 0, 0]  ← hash3

"apple" 삽입 5:
hash1("apple") = 1 → 카운터 [0, 5, 0, 0, 0]
hash2("apple") = 3 → 카운터 [0, 0, 0, 5, 0]
hash3("apple") = 0 → 카운터 [5, 0, 0, 0, 0]

"banana" 삽입 2:
hash1("banana") = 4[0, 5, 0, 0, 2]
hash2("banana") = 1[0, 2, 0, 5, 0]
hash3("banana") = 2[5, 0, 2, 0, 0]

"apple" 조회: min(5, 5, 5) = 5"banana" 조회: min(2, 2, 2) = 2

collision 발생 시: 다른 원소와 충돌하면 카운터가 부풀려질 수 있지만, 최솟값을 취하므로 항상 진짜 카운트보다 ≥ (overestimate).

5.4 정확도

오차 ε, 신뢰도 1-δ
필요 크기:
  w = ⌈e/ε⌉
  d = ⌈ln(1/δ)

: 1% 오차, 99% 신뢰도 → w=272, d=5 → 1360 카운터 = 5 KB. 수십억 원소 처리 가능.

5.5 실전 활용

핫 키 탐지 (Heavy Hitters)

# 가장 자주 본 키 찾기
sketch = CountMinSketch(width=272, depth=5)

for query in stream:
    sketch.add(query)

# Top-K 찾기 (separate min-heap 사용)

Memcached의 LRU 대안

tinylfu 캐시는 Count-Min Sketch로 빈도 추적 → 자주 쓰이는 키만 캐시에 유지.

DDoS 감지

각 IP의 요청 수를 추정 → 임계값 초과 시 차단.

대규모 스트림 처리에서 빈도 분석.


6. MinHash — Jaccard 유사도

6.1 문제

"두 집합이 얼마나 비슷한가?" (Jaccard 유사도)

J(A, B) = |AB| / |AB|

전통적 방법: 모든 원소 비교 → O(|A| × |B|).

6.2 MinHash 원리

각 집합의 "최소 해시 값"이 같을 확률 = Jaccard 유사도.

def minhash(set_, num_hashes=128):
    return [min(hash(item, seed=i) for item in set_) for i in range(num_hashes)]

# 두 집합의 MinHash 비교
mh1 = minhash(set1)
mh2 = minhash(set2)

similarity = sum(a == b for a, b in zip(mh1, mh2)) / len(mh1)
# similarity ≈ Jaccard(set1, set2)

6.3 활용

중복 문서 탐지

수십억 웹 페이지에서 거의 같은 페이지 찾기. Google이 사용.

표절 탐지

논문, 코드 비교.

추천 시스템

"비슷한 사용자" 찾기.

클러스터링

LSH (Locality Sensitive Hashing)와 결합하여 비슷한 항목 그룹화.


7. Skip List — 확률적 균형 트리

7.1 정렬된 자료구조

균형 이진 트리(AVL, Red-Black) 대신 확률적 접근.

7.2 원리

다층 연결 리스트. 상위 레벨은 sparse, 하위 레벨은 dense.

Level 3:  1 ───────────────→ 9
Level 2:  1 ────→ 4 ────→ 7 ─→ 9
Level 1:  1345789

검색: 상위에서 시작, 너무 크면 한 레벨 내려감.

삽입 시 레벨: 동전 던지기로 결정. 50% 확률로 다음 레벨.

7.3 성능

작업평균최악
검색O(log n)O(n)
삽입O(log n)O(n)
삭제O(log n)O(n)

장점: 균형 트리보다 구현 단순, lock-free 동시성 쉬움.

7.4 활용

  • Redis Sorted Set: ZADD/ZRANGE의 내부 구조
  • LevelDB/RocksDB의 memtable
  • Java ConcurrentSkipListMap

8. T-Digest — 분위수 추정

8.1 문제

대규모 스트림에서 p99 latency 같은 분위수를 어떻게 계산하나?

전통적: 모든 값 저장 후 정렬 → 메모리 폭증.

T-Digest: 분포의 "스케치"를 작은 메모리로 유지.

8.2 원리

값을 buckets로 그룹화. 분포의 끝(p99, p99.9)에 더 많은 buckets, 중간(p50)에 적게.

근거: 우리는 보통 **꼬리(tail latency)**에 관심. 중간값은 대략적이어도 OK.

8.3 사용

from tdigest import TDigest

t = TDigest()
for value in stream:
    t.update(value)

p50 = t.percentile(50)
p95 = t.percentile(95)
p99 = t.percentile(99)
p999 = t.percentile(99.9)

8.4 활용

  • 모니터링: Prometheus, Datadog, New Relic
  • 데이터베이스: Druid, Apache Pinot
  • 스트림 처리: Apache Beam, Flink

9. 트레이드오프 비교

9.1 모든 자료구조 비교

자료구조답하는 질문메모리정확도사용
HashSet정확한 멤버십O(n)100%작은 데이터
Bloom Filter"있을 수도?"O(n) very smallFP O, FN X캐시 사전 검증
Cuckoo Filter멤버십 + 삭제O(n) very smallFP 더 낮음Bloom 대안
Counter정확한 카운트O(n)100%작은 데이터
HyperLogLog"고유 원소 수?"12 KB 고정~1%DAU, 카디널리티
Count-Min Sketch"X가 몇 번?"smalloverestimate핫 키, DDoS
MinHash + LSH"비슷한 집합?"smallJaccard 추정중복 탐지
T-Digest"분위수?"small분포 추정latency 모니터링
Skip List정렬된 셋O(n)100%Redis Sorted Set

9.2 메모리 비교 (1억 고유 사용자)

방법메모리
HashSet (UUID)~3.6 GB
Bitmap (sequential ID)12.5 MB
Bloom Filter (1% FP)120 MB
HyperLogLog12 KB

HyperLogLog가 30만 배 작습니다.


10. 실전 — Kafka의 Heavy Hitter 탐지

10.1 시나리오

Kafka 토픽에 초당 100만 메시지. "어떤 user_id가 가장 활발한가?" (스팸 탐지)

10.2 구현

from countminsketch import CountMinSketch
import heapq

# Count-Min Sketch + Top-K 힙
sketch = CountMinSketch(width=10000, depth=5)
top_k = []  # min-heap of (count, user_id)
TOP_K_SIZE = 100

def process_message(user_id):
    sketch.add(user_id)
    estimated_count = sketch.estimate(user_id)
    
    if len(top_k) < TOP_K_SIZE:
        heapq.heappush(top_k, (estimated_count, user_id))
    elif estimated_count > top_k[0][0]:
        heapq.heapreplace(top_k, (estimated_count, user_id))

# 메모리: ~200 KB로 1억 사용자 처리 가능

10.3 결과

전통적 HashMap = 수 GB → CMS = 200 KB (10,000배 절약).

약간의 부정확성을 받아들이면 메모리 효율이 폭발적입니다.


퀴즈

1. Bloom Filter는 왜 false negative가 없나요?

: 원소를 삽입할 때 K개의 비트를 모두 1로 설정합니다. 같은 원소를 조회할 때 K개의 같은 비트를 검사하면 반드시 모두 1입니다. 따라서 "없음"이라고 답하는 것은 비트가 0이라는 의미 = 그 원소는 절대 삽입된 적 없음. False negative 불가능. 단, 다른 원소들이 우연히 같은 비트를 1로 만들면 false positive 발생.

2. HyperLogLog가 12 KB로 수십억을 처리할 수 있는 이유는?

: HyperLogLog는 원소 자체를 저장하지 않습니다. 대신 해시의 선행 0 개수의 분포를 추적합니다. 1억 원소든 1조 원소든 같은 분포 정보를 저장하므로 메모리는 일정합니다. 12 KB 안에 1024개의 6비트 버킷이 들어가고, 이걸로 평균 0.81% 오차의 카디널리티 추정이 가능합니다. 메모리는 데이터 크기와 무관하다는 것이 핵심.

3. Count-Min Sketch가 항상 overestimate인 이유는?

: 여러 원소가 같은 카운터 위치에 충돌할 수 있습니다. 충돌 시 카운터가 여러 원소의 합이 되어 실제보다 큰 값. 최솟값을 취해도 충돌의 영향이 남아있을 수 있어 항상 ≥ 진짜 값입니다. 반대로 underestimate는 불가능 — 어떤 위치도 진짜 카운트보다 작을 수 없기 때문입니다.

4. Cuckoo Filter가 Bloom Filter보다 좋은 점은?

: (1) 삭제 가능 — Bloom Filter는 비트를 0으로 못 만듭니다 (다른 원소 영향), Cuckoo는 안전하게 삭제. (2) 더 낮은 false positive율 — 같은 메모리에서 더 정확. (3) 더 좋은 캐시 성능 — 메모리 접근 패턴이 더 효율적. 단점: 구현이 조금 더 복잡, 만수 시 (capacity 초과) 삽입 실패 가능.

5. p99 latency 측정에 T-Digest가 유리한 이유는?

: 전통적 방법은 모든 latency 값을 저장한 후 정렬 → 1억 요청이면 800 MB+ 메모리. T-Digest는 분포의 "스케치"를 수 KB로 저장합니다. 핵심 트릭: 꼬리(p99, p99.9)에 더 많은 buckets, 중간(p50)에 적게. 모니터링은 보통 꼬리에 관심이 있으므로 정확도 손실 없이 메모리 절약. Datadog, Prometheus 등이 사용합니다.


참고 자료