Skip to content

✍️ 필사 모드: 확률적 데이터 구조 완전 가이드 2025: HyperLogLog, Count-Min Sketch, MinHash, T-Digest, Cuckoo Filter — Redis/Presto/Spark 실전 분석

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며: 작은 메모리로 큰 숫자를 다루는 마법

한 가지 질문

당신의 웹사이트에 지난 한 달 동안 몇 명의 고유 방문자가 왔는가?

naive한 답: SELECT COUNT(DISTINCT user_id) FROM visits;

그런데 10억 개의 로그 레코드가 있다면? COUNT(DISTINCT)는 거대한 해시셋을 메모리에 유지해야 한다. 수십 GB의 메모리가 필요할 수도 있다.

Redis의 PFCOUNT 명령어는 같은 답을 12KB에 해준다. 오차는 0.81% 미만. 어떻게?

답: HyperLogLog라는 확률적 데이터 구조 덕분이다.

확률적 데이터 구조의 트레이드오프

확률적 데이터 구조(Probabilistic Data Structures, PDS)는 정확성을 조금 포기하고 공간을 극도로 절약하는 기법들이다:

  • Bloom Filter: 멤버십 (있나? 없나?) — false positive O, false negative X.
  • HyperLogLog: 유니크 카운트 (몇 개가 서로 다른가?) — 상대 오차 ~0.81%.
  • Count-Min Sketch: 빈도 (얼마나 자주 나타났는가?) — 과대 추정 가능.
  • MinHash: 집합 유사도 (두 집합이 얼마나 비슷한가?) — 근사치.
  • T-Digest: 분위수 (p99 latency 같은) — 꼬리 부분 정확.
  • Cuckoo Filter: Bloom filter의 개선판 — 삭제 지원.

이 글에서는 각 구조의 수학적 원리, 구현, 실전 사용 사례를 깊이 있게 다룬다.

왜 지금 배워야 하는가?

빅데이터, 스트리밍, 실시간 분석 시대에 정확한 답은 종종 불필요하다:

  • 99.5% 정확한 유니크 방문자 수 vs 정확하지만 10분 뒤에 나오는 답?
  • 페이지 인기 순위가 정확히 10등인지 vs 대략 Top 10 안에 있는지?
  • 스팸 탐지에 0.01% 거짓 긍정 허용?

대부분의 경우 근사치면 충분하다. 그리고 근사치는 수천 배 빠르고 수천 배 적은 메모리를 쓴다. Redis, Presto, Spark, Druid, Datadog 등 거의 모든 현대 시스템이 PDS를 활용한다.


1. HyperLogLog: 10억 개를 12KB로 세기

문제: 카디널리티 추정

집합 S에 몇 개의 서로 다른 원소가 있는가?

  • naive 방법: HashSet에 모두 넣고 크기 반환. O(n) 공간.
  • PDS 방법: HyperLogLog. O(log log n) 공간.

직관: 해시와 선행 0

한 가지 관찰에서 출발한다: 랜덤한 해시값에서 선행 0(leading zeros)의 개수는 극소량이다.

  • 해시가 0b01... → 1개의 선행 0 (확률 1/2).
  • 해시가 0b001... → 2개의 선행 0 (확률 1/4).
  • 해시가 0b0001... → 3개의 선행 0 (확률 1/8).
  • ...
  • k개의 선행 0을 본 적 있다 → 최소 2^k 개의 서로 다른 값을 본 것 같다.

즉, "내가 본 최대 선행 0 개수"만 기억하면 대략의 카디널리티 추정 가능.

문제점 1: 분산이 크다

하나의 관측치로는 오차가 너무 크다. 하나의 아주 긴 선행 0을 보면 카디널리티를 엄청나게 과대 추정한다.

해결: 여러 개의 독립된 관측치를 평균낸다.

문제점 2: 산술 평균은 나쁘다

산술 평균은 outlier에 민감하다. 조화 평균(harmonic mean) 이 훨씬 안정적이다.

HyperLogLog 알고리즘

  1. 해시 함수로 key를 해싱 → 64비트.
  2. 상위 p비트버킷 인덱스 결정 (예: p=14 → 16,384개 버킷).
  3. 나머지 비트에서 선행 0 개수 + 1을 계산.
  4. 각 버킷에 그 버킷에서 본 최대 선행 0 개수만 저장.
  5. 카디널리티 추정 시, 모든 버킷의 조화 평균으로 계산.
import mmh3  # MurmurHash3

class HyperLogLog:
    def __init__(self, p=14):
        self.p = p
        self.m = 1 << p  # 버킷 수
        self.buckets = [0] * self.m
        # 보정 상수 (p에 따라 다름)
        self.alpha_mm = self._get_alpha() * self.m * self.m

    def _get_alpha(self):
        if self.m == 16:
            return 0.673
        elif self.m == 32:
            return 0.697
        elif self.m == 64:
            return 0.709
        else:
            return 0.7213 / (1 + 1.079 / self.m)

    def add(self, item):
        x = mmh3.hash64(str(item))[0] & ((1 << 64) - 1)
        # 상위 p비트는 버킷 인덱스
        idx = x >> (64 - self.p)
        # 나머지 비트에서 선행 0 + 1 계산
        w = (x << self.p) & ((1 << 64) - 1)
        w |= 1 << (self.p - 1)  # 무한 루프 방지
        leading_zeros = 64 - w.bit_length() + 1
        self.buckets[idx] = max(self.buckets[idx], leading_zeros)

    def count(self):
        # 조화 평균 기반 추정
        z = sum(2.0 ** -b for b in self.buckets)
        estimate = self.alpha_mm / z

        # Small range correction (작은 값에서 편향 보정)
        if estimate <= 2.5 * self.m:
            zeros = self.buckets.count(0)
            if zeros > 0:
                estimate = self.m * (self.m / zeros).bit_length()  # 간이 log

        return int(estimate)

# 사용 예시
hll = HyperLogLog(p=14)
for i in range(1_000_000):
    hll.add(f"user_{i}")

print(f"Estimated: {hll.count()}")  # ~1,000,000 (오차 1% 이내)
print(f"Memory: {hll.m} buckets = {hll.m * 6 / 1024:.1f} KB")  # 12 KB

정확도와 공간

표준 오차 σ = 1.04 / √m:

  • m = 16 (4비트 인덱스): σ ≈ 26%. 1KB 미만.
  • m = 1024 (10비트): σ ≈ 3.25%. 약 768B.
  • m = 16384 (14비트): σ ≈ 0.81%. 약 12KB. ← Redis 기본값.
  • m = 65536 (16비트): σ ≈ 0.4%. 약 48KB.

카디널리티가 2^32 (약 42억) 에 도달해도 12KB로 충분하다.

HyperLogLog의 연산

  1. Union: HLL_C = HLL_A ∪ HLL_B → 각 버킷의 max만 취하면 됨. 시간 O(m).
  2. Intersection: 바로는 계산 못 하지만 |A ∩ B| = |A| + |B| - |A ∪ B|로 간접 가능.
  3. Merge: Union과 동일.

이 중 Union이 PDS의 마법이다. 분산 시스템에서 각 노드가 로컬 HLL을 유지하고, 중앙에서 merge해서 전역 카디널리티를 얻을 수 있다.

Redis의 구현

Redis는 HyperLogLog를 PFADD, PFCOUNT, PFMERGE 명령어로 제공한다:

PFADD visitors:2025-04-15 user1 user2 user3
PFADD visitors:2025-04-15 user1  # 이미 있음, 카운트 증가 안 됨

PFCOUNT visitors:2025-04-15  # → 3

# 여러 날짜의 유니크 방문자 합계 (중복 제거)
PFCOUNT visitors:2025-04-15 visitors:2025-04-16 visitors:2025-04-17

# 병합
PFMERGE visitors:2025-04 visitors:2025-04-15 visitors:2025-04-16 ...
PFCOUNT visitors:2025-04  # → 월간 UV

내부적으로 Redis는 p=14, m=16384, 6비트/버킷으로 12KB 고정이다.

Sparse vs Dense 표현

Redis의 HLL은 두 가지 저장 형식을 쓴다:

  • Sparse: 버킷 대부분이 0인 초기 상태. Run-length 인코딩으로 더 작게.
  • Dense: 버킷이 대부분 채워지면 12KB 배열로 전환.

이 덕분에 실제 메모리는 종종 1KB 미만이다. 카운트가 증가할수록 dense로 전환.

LogLog++ 개선

Google의 HyperLogLog++ (2013)는 몇 가지 개선을 추가했다:

  • Bias correction: 작은 카디널리티에서의 편향 보정.
  • Sparse representation: 압축 표현.
  • 64비트 해시: 2^64까지 지원.

BigQuery와 Spark, ClickHouse가 HyperLogLog++를 사용한다.


2. Count-Min Sketch: 빈도 추정의 왕

문제: Heavy Hitter 탐지

"이 스트림에서 가장 자주 나타난 IP 주소는?"

naive 방법: dict[IP] += 1. 메모리 폭발.

PDS 방법: Count-Min Sketch. 고정 크기 2D 배열.

알고리즘

  1. d개의 해시 함수w 크기의 배열 d개를 준비. (d × w 행렬)
  2. Add(item): 각 행 i에서 hash_i(item) % w 위치를 1 증가.
  3. Estimate(item): 각 행 i에서 hash_i(item) % w 위치의 값들 중 최솟값을 반환.
import mmh3

class CountMinSketch:
    def __init__(self, width=2048, depth=5):
        self.width = width
        self.depth = depth
        self.table = [[0] * width for _ in range(depth)]
        self.seeds = [i * 31 + 7 for i in range(depth)]

    def add(self, item, count=1):
        for i in range(self.depth):
            idx = mmh3.hash(str(item), self.seeds[i]) % self.width
            self.table[i][idx] += count

    def estimate(self, item):
        return min(
            self.table[i][mmh3.hash(str(item), self.seeds[i]) % self.width]
            for i in range(self.depth)
        )

왜 Min인가?

해시 충돌 시 같은 셀에 여러 아이템이 카운트된다. 즉, 저장된 값은 실제 값의 상한이다. 여러 행의 추정치 중 최솟값이 가장 낮은(가장 진실에 가까운) 추정치다.

보장: Count-Min Sketch는 false negative가 없다. estimate(x) >= true_count(x). 과대 추정은 있지만 과소 추정은 없다.

정확도 파라미터

  • ε (epsilon): 과대 추정 오차 비율. ε = e / w (e는 자연로그).
  • δ (delta): 이 오차가 넘어갈 확률. δ = e^(-d).

즉, w = ⌈e/ε⌉, d = ⌈ln(1/δ)⌉.

  • ε=0.001, δ=0.01 → w≈2,718, d≈5. 약 54KB.

공간 비교

10^8개의 유니크 이벤트:

  • 정확한 해시맵: ~1GB
  • Count-Min Sketch (0.1% 오차): ~54KB
  • 약 18,000배 절약

실전: Heavy Hitter (Top-K)

Count-Min Sketch 혼자로는 "top 10 아이템"을 바로 알 수 없다 (어떤 아이템이 있는지 모름). 별도의 min-heap을 함께 유지한다:

import heapq

class TopK:
    def __init__(self, k, cms):
        self.k = k
        self.cms = cms
        self.heap = []  # (count, item)
        self.items_in_heap = set()

    def add(self, item):
        self.cms.add(item)
        count = self.cms.estimate(item)

        if item in self.items_in_heap:
            # heap 재구성
            for i, (c, it) in enumerate(self.heap):
                if it == item:
                    self.heap[i] = (count, item)
                    heapq.heapify(self.heap)
                    return
        elif len(self.heap) < self.k:
            heapq.heappush(self.heap, (count, item))
            self.items_in_heap.add(item)
        elif count > self.heap[0][0]:
            removed = heapq.heappushpop(self.heap, (count, item))
            self.items_in_heap.remove(removed[1])
            self.items_in_heap.add(item)

    def top_k(self):
        return sorted(self.heap, reverse=True)

실전 사용 사례

  1. Network Monitoring: 가장 트래픽 많은 IP 찾기.
  2. DDoS 탐지: 비정상적으로 많은 요청을 보내는 클라이언트.
  3. 웹 분석: 인기 페이지 Top 100.
  4. 스트림 가공: Spark, Flink에서 윈도우별 heavy hitter.
  5. Databricks, Datadog: 내부적으로 Count-Min Sketch 사용.

개선판: Count-Min-Log Sketch

빈도가 로그 스케일로 분포할 때(Zipfian) Count-Min-Log Sketch가 더 정확하다. 값을 선형이 아닌 로그 공간에 저장해서 오차를 크게 줄인다.


3. MinHash: 집합 유사도

문제: Jaccard Similarity 계산

두 집합 A, B의 유사도를 Jaccard index로 정의:

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

1부터 0까지, 1은 동일, 0은 완전히 다름.

거대한 집합들에 대해 이를 계산하는 건 비싸다. MinHash가 해결한다.

핵심 아이디어

해시 함수 h를 사용해 집합 A의 모든 원소를 해싱하고, 최솟값 minHash_h(A) = min(h(x) for x in A) 를 계산한다.

놀라운 성질:

P(minHash_h(A) == minHash_h(B)) = J(A, B)

즉, 무작위 해시로 두 집합의 MinHash가 같을 확률이 정확히 Jaccard similarity다!

증명 스케치: 두 집합의 합집합 A∪B에서 최소 해시값을 내는 원소가 교집합 A∩B에 속할 확률은 |A∩B|/|A∪B| = J(A,B)이다.

알고리즘

  1. K개의 독립적인 해시 함수 h_1, ..., h_K 준비.
  2. 각 집합에 대해 K개의 minHash 저장.
  3. 유사도 추정: 두 집합의 K개 minHash 중 같은 것의 비율.
import mmh3

class MinHash:
    def __init__(self, num_hashes=128):
        self.k = num_hashes
        self.seeds = list(range(num_hashes))
        self.signature = [float('inf')] * num_hashes

    def update(self, item):
        s = str(item)
        for i in range(self.k):
            h = mmh3.hash(s, self.seeds[i]) & 0xFFFFFFFF
            if h < self.signature[i]:
                self.signature[i] = h

    def jaccard(self, other):
        assert self.k == other.k
        matches = sum(1 for a, b in zip(self.signature, other.signature) if a == b)
        return matches / self.k

# 사용 예시
m1 = MinHash(num_hashes=128)
for word in ["apple", "banana", "cherry"]:
    m1.update(word)

m2 = MinHash(num_hashes=128)
for word in ["banana", "cherry", "date"]:
    m2.update(word)

print(m1.jaccard(m2))  # ≈ 0.5 (2/4)

정확도

K개 해시 사용 시 표준 오차: σ = √(J(1-J)/K).

  • K=64: σ ≈ 6% (J=0.5일 때).
  • K=128: σ ≈ 4%.
  • K=256: σ ≈ 3%.

실전: LSH (Locality-Sensitive Hashing)

MinHash만으로는 두 집합 비교만 가능하다. 수백만 개의 집합 중 유사한 쌍을 찾는 건 O(N²)로 여전히 무리다.

LSH는 이를 해결한다: "유사한 집합은 같은 버킷에 해싱"되도록 한다. 그 결과 후보 쌍만 비교하면 되므로 O(N)에 가까워진다.

사용 사례

  1. 중복 문서 탐지: 구글 뉴스가 유사한 기사를 클러스터링.
  2. 웹 크롤러: 이미 크롤링한 거의 같은 페이지 감지.
  3. 표절 탐지: Shingle(n-gram) + MinHash.
  4. 추천 시스템: 비슷한 취향의 사용자 찾기.
  5. 패키지 유사도: npm, PyPI의 유사 패키지 탐지.

Datasketches와 Apache Spark

Apache DataSketches 라이브러리와 Spark MLlib은 MinHash LSH를 기본 제공한다:

from pyspark.ml.feature import MinHashLSH
from pyspark.ml.linalg import Vectors

data = spark.createDataFrame([
    (0, Vectors.sparse(6, [0, 1, 2], [1.0, 1.0, 1.0])),
    (1, Vectors.sparse(6, [2, 3, 4], [1.0, 1.0, 1.0])),
], ["id", "features"])

mh = MinHashLSH(inputCol="features", outputCol="hashes", numHashTables=5)
model = mh.fit(data)
model.approxSimilarityJoin(data, data, 0.6).show()

4. T-Digest: 분위수 추정

문제: p99 Latency

"우리 서비스의 p99 응답 시간은?"

  • 모든 데이터를 정렬해서 99번째 백분위를 고르기: 메모리와 시간 ×.
  • 정확한 근사: T-Digest.

핵심 아이디어

극단(꼬리) 부분은 정밀하게, 중앙 부분은 거칠게.

T-Digest는 값들을 centroid라는 그룹으로 묶는다. 각 centroid는 (mean, count) 페어다. 극단 쪽일수록 centroid가 적은 값을 포함해 정밀도를 높인다.

알고리즘 개요

  1. 새 값이 들어오면 가장 가까운 centroid에 병합.
  2. 각 centroid가 포함할 수 있는 최대 크기는 분위수 위치에 따라 다름:
    • 중앙 근처: 많은 값 허용.
    • 꼬리 근처: 적은 값만 허용.
  3. 주기적으로 압축(compress)해서 centroid 수 제한.

왜 꼬리가 정밀한가?

분위수 쿼리에서 p50보다 p99가 훨씬 중요한 경우가 많다. 평균 응답 시간은 봐도 소용없고, 최악의 5%가 사용자 경험을 결정한다. T-Digest는 이런 실전 요구에 맞게 설계됐다.

간단한 구현 (개념 수준)

from sortedcontainers import SortedList

class TDigest:
    def __init__(self, compression=100):
        self.compression = compression
        self.centroids = SortedList(key=lambda c: c[0])  # (mean, count)
        self.total = 0

    def add(self, x, w=1):
        self.total += w
        if not self.centroids:
            self.centroids.add((x, w))
            return

        # 가장 가까운 centroid 찾기
        idx = self.centroids.bisect_left((x, 0))
        candidates = []
        if idx > 0:
            candidates.append(idx - 1)
        if idx < len(self.centroids):
            candidates.append(idx)

        closest = min(candidates, key=lambda i: abs(self.centroids[i][0] - x))
        c_mean, c_count = self.centroids[closest]

        # 이 centroid가 병합 가능한 최대 크기 (분위수 기반)
        q = self._quantile_of_centroid(closest)
        k = 4 * self.total * q * (1 - q) / self.compression

        if c_count + w <= k:
            new_count = c_count + w
            new_mean = (c_mean * c_count + x * w) / new_count
            del self.centroids[closest]
            self.centroids.add((new_mean, new_count))
        else:
            self.centroids.add((x, w))

    def quantile(self, q):
        """q번째 분위수 반환."""
        if not self.centroids:
            return None

        target = q * self.total
        cum = 0
        for mean, count in self.centroids:
            if cum + count / 2 >= target:
                return mean
            cum += count
        return self.centroids[-1][0]

    def _quantile_of_centroid(self, idx):
        cum = 0
        for i, (_, c) in enumerate(self.centroids):
            if i == idx:
                return (cum + c / 2) / self.total
            cum += c
        return 1.0

이는 단순화된 버전이다. 실제 구현은 Stephen Dunning의 T-Digest Java 라이브러리를 참고하자.

T-Digest의 장점

  1. 정확한 꼬리: p99, p99.9가 실제 값에 매우 가까움.
  2. 병합 가능: 여러 T-digest를 합쳐서 전역 분위수 계산.
  3. 고정 공간: compression 파라미터로 조절, 보통 100~1000 centroid = KB 수준.
  4. O(log n) add / quantile.

실전 사용

  1. Datadog, Prometheus: 내부 분위수 계산에 T-Digest 또는 유사한 HDR Histogram 사용.
  2. Spark SQL: percentile_approx 함수가 T-Digest 기반.
  3. Apache Druid: 실시간 분위수 집계.
  4. ClickHouse: quantileTDigest 함수.

HDR Histogram과의 비교

HDR Histogram도 비슷한 목적인데 접근이 다르다:

  • HDR: 고정 구간(bucket)으로 값 범위 분할. 빠름, 정확한 범위.
  • T-Digest: 동적 centroid. 꼬리가 더 정확.

둘 다 실전에서 널리 쓰이며 성격이 조금 다르다.


5. Cuckoo Filter: 삭제 가능한 Bloom의 후계자

Bloom Filter의 단점

Bloom filter는 훌륭하지만 몇 가지 단점이 있다:

  1. 삭제 불가능: 한 번 추가한 것을 제거할 수 없다 (카운팅 블룸 필터는 가능하지만 공간 2배 이상).
  2. 불법 인덱스 증가: 요소 수가 늘면 false positive 급증.
  3. 캐시 미스: k개 해시 위치가 메모리에 흩어짐.

Cuckoo Filter의 등장

2014년 Bin Fan 등이 발표한 Cuckoo FilterCuckoo Hashingfingerprint를 조합한다:

  1. 아이템의 fingerprint(8~16비트 해시값)를 저장.
  2. 각 아이템은 두 개의 가능한 버킷에 배치.
  3. 삽입 시 두 버킷이 다 차 있으면 기존 아이템을 밀어내서 다른 버킷으로 이동.
  4. 삭제 가능: fingerprint를 찾아서 제거.

알고리즘

import mmh3

class CuckooFilter:
    def __init__(self, capacity, bucket_size=4, max_kicks=500):
        self.buckets = [[] for _ in range(capacity)]
        self.bucket_size = bucket_size
        self.max_kicks = max_kicks
        self.capacity = capacity

    def _fingerprint(self, item):
        return mmh3.hash(str(item)) & 0xFF  # 8비트 fingerprint

    def _index(self, fp, bucket):
        return (bucket ^ mmh3.hash(str(fp))) % self.capacity

    def add(self, item):
        fp = self._fingerprint(item)
        i1 = mmh3.hash(str(item)) % self.capacity
        i2 = self._index(fp, i1)

        if len(self.buckets[i1]) < self.bucket_size:
            self.buckets[i1].append(fp)
            return True
        if len(self.buckets[i2]) < self.bucket_size:
            self.buckets[i2].append(fp)
            return True

        # Eviction loop
        i = i1  # 또는 i2를 골라도 됨
        for _ in range(self.max_kicks):
            # 랜덤 fingerprint 쫓아내기
            import random
            idx = random.randint(0, self.bucket_size - 1)
            fp, self.buckets[i][idx] = self.buckets[i][idx], fp
            i = self._index(fp, i)
            if len(self.buckets[i]) < self.bucket_size:
                self.buckets[i].append(fp)
                return True

        return False  # 가득 참

    def contains(self, item):
        fp = self._fingerprint(item)
        i1 = mmh3.hash(str(item)) % self.capacity
        i2 = self._index(fp, i1)
        return fp in self.buckets[i1] or fp in self.buckets[i2]

    def remove(self, item):
        fp = self._fingerprint(item)
        i1 = mmh3.hash(str(item)) % self.capacity
        i2 = self._index(fp, i1)

        if fp in self.buckets[i1]:
            self.buckets[i1].remove(fp)
            return True
        if fp in self.buckets[i2]:
            self.buckets[i2].remove(fp)
            return True
        return False

Bloom vs Cuckoo 비교

항목Bloom FilterCuckoo Filter
False positive있음있음
False negative없음없음
삭제불가능 (카운팅 필요)가능
공간 효율좋음약간 더 좋음 (95% 이하 load에서)
캐시 지역성나쁨 (k개 위치 랜덤)좋음 (최대 2개 버킷)
구현 복잡도단순중간
Load factor 한계100% 가능~95% (이후 삽입 실패 가능)

실전 사용

  • Apache Doris: 쿼리 최적화에 Cuckoo filter 사용.
  • 분산 시스템의 members set: 삭제 지원이 필요한 경우.
  • SSD LSM-Tree: Bloom 대신 Cuckoo로 포인트 조회 최적화.

6. 퀀타일 스케치와 스트리밍

Count-Min, HyperLogLog가 집계 스케치라면, T-Digest는 분위수 스케치다. 다른 분위수 구조도 있다:

Quantile Digest (q-digest)

T-Digest의 이전 버전. 트리 기반으로 값을 압축. 정확도는 T-Digest보다 낮지만 단순.

KLL Sketch

Datasketches 라이브러리의 KLL Sketch는 T-Digest와 비슷한 용도지만 수학적 보장이 더 엄격하다. 오차가 상한으로 보장됨.

Apache DataSketches

Yahoo!가 오픈소스화한 DataSketches 라이브러리는 수십 가지 PDS를 제공한다:

  • CPC sketch: HLL보다 정확도 높음.
  • Theta sketch: 카디널리티 + 집합 연산.
  • Frequent Items sketch: Top-K.
  • Quantiles sketch: 분위수.

Spark, Druid, Pinot 등 많은 빅데이터 시스템이 이를 통합하고 있다.


7. 실전 시스템에서의 PDS 활용

Redis

Redis는 여러 PDS를 내장한다:

# HyperLogLog
PFADD mykey a b c
PFCOUNT mykey

# Bloom Filter (RedisBloom 모듈)
BF.RESERVE myfilter 0.01 1000000  # 1% 오차, 100만 개
BF.ADD myfilter user1
BF.EXISTS myfilter user1

# Count-Min Sketch
CMS.INITBYPROB mycms 0.001 0.01
CMS.INCRBY mycms item 1
CMS.QUERY mycms item

# T-Digest
TDIGEST.CREATE mydigest
TDIGEST.ADD mydigest 1.5 2.5 3.5 ...
TDIGEST.QUANTILE mydigest 0.99

# Top-K
TOPK.RESERVE mytopk 10 1000 3 0.9
TOPK.ADD mytopk item1 item2 ...
TOPK.LIST mytopk

Apache Spark

# Spark SQL
df.agg(F.approx_count_distinct("user_id").alias("unique_users"))  # HyperLogLog
df.agg(F.percentile_approx("latency", 0.99))  # T-Digest

# PySpark MLlib
from pyspark.ml.feature import MinHashLSH

Presto / Trino

SELECT approx_distinct(user_id) FROM events;  -- HyperLogLog
SELECT approx_percentile(latency, 0.99) FROM requests;  -- Quantile Digest

-- HyperLogLog 병합
SELECT cardinality(merge(cast(hll_sketch AS HyperLogLog)))
FROM daily_hlls;

ClickHouse

SELECT uniqExact(user_id);       -- 정확한 distinct
SELECT uniqHLL12(user_id);       -- HyperLogLog
SELECT quantileTDigest(0.99)(latency);

Datadog / Prometheus

Datadog의 DDSketch, Prometheus의 Summary/Histogram은 서로 다른 분위수 PDS다. Histogram은 고정 buckets, Summary는 클라이언트 측 분위수, DDSketch는 log-bucket 기반 동적.

Apache Druid

Druid는 실시간 OLAP를 위해 PDS를 적극 활용:

  • Theta sketch로 distinct count.
  • Quantiles sketch로 p50/p99/p999.
  • HLL sketch도 지원.

이 덕분에 수십억 이벤트의 실시간 대시보드가 가능하다.


8. PDS를 쓸 때 주의점

1. 정확도 vs 성능 트레이드오프 이해

"1% 오차는 괜찮은가?"는 비즈니스 질문이다. 금융 거래 집계에는 부적절하고, 웹 분석에는 완벽하게 적절하다.

2. 조합의 함정

두 스케치를 결합할 때 오차가 누적된다. 10개 서브쿼리의 HLL을 합치면 각각 1% 오차가 제곱으로 늘 수 있다.

3. Seed와 해시 함수 일관성

두 PDS를 병합할 때 같은 해시 함수와 seed를 써야 한다. 다르면 결과가 무의미.

4. 공간 파라미터 튜닝

너무 작으면 정확도 떨어짐, 너무 크면 공간 낭비. 데이터 규모를 예측하고 설정해야 한다.

5. Cold Start 문제

초반에 데이터가 적으면 추정치가 불안정할 수 있다. 많은 PDS가 작은 값에서의 bias correction을 제공하지만 완벽하지 않다.

6. 디버깅 어려움

"왜 숫자가 이상하지?"를 디버깅하기 어렵다. PDS는 블랙박스처럼 느껴진다. 샘플 데이터로 검증하는 테스트를 꼭 작성하자.


9. 새로운 방향: ML 기반 PDS

최근 Learned Index 연구에서 머신러닝으로 PDS를 대체하거나 보강하는 흐름이 있다:

  • Learned Bloom Filter: 모델 예측 + 보조 Bloom filter.
  • Learned Cardinality: 뉴럴 네트워크로 distinct count 추정.
  • Learned Indexes: B-Tree 대신 학습된 모델.

아직 실전 투입은 제한적이지만, 특정 워크로드에서 전통 PDS보다 더 나은 공간/정확도 trade-off를 보인다.


퀴즈로 복습하기

Q1. HyperLogLog는 왜 "조화 평균"을 쓰는가? 산술 평균과 차이는?

A. 산술 평균은 outlier에 매우 민감하다. HyperLogLog의 각 버킷은 해당 버킷에서 본 최대 선행 0 개수를 저장하는데, 이 값들의 분포는 long-tail이다. 드물게 큰 값이 나오면 산술 평균이 크게 부풀어 올라 카디널리티를 과대 추정한다. 조화 평균 n / Σ(1/x_i)은 큰 값의 영향을 줄여 안정적인 추정을 제공한다. 또한 조화 평균은 "2^x"형 분포에 대해 수학적으로 편향이 적은 추정량이 된다. 여기에 보정 상수 α_m을 곱해 최종 편향을 제거한다.

Q2. Count-Min Sketch가 false negative가 없는 이유는?

A. Count-Min Sketch는 아이템이 추가될 때마다 d개의 셀을 항상 증가시킨다. 아이템 x가 실제로 n번 추가되었다면, 어떤 해시 함수를 쓰더라도 x가 매핑된 셀의 값은 최소 n이다. 다른 아이템과의 충돌로 값이 더 커질 수는 있지만 더 작아질 수는 없다. 따라서 estimate(x) >= true_count(x)가 항상 성립한다. 여러 행 중 최솟값을 택하는 이유는, 충돌이 가장 적게 일어난 셀을 고르기 위함이다. 이 최솟값도 여전히 실제 값 이상임이 보장된다.

Q3. MinHash가 Jaccard similarity를 추정할 수 있는 수학적 근거는?

A. 핵심은 "A∪B의 원소들을 랜덤하게 섞어놓고 맨 앞의 원소를 골랐을 때, 그것이 A∩B에 속할 확률 = |A∩B|/|A∪B| = J(A,B)" 라는 점이다. 해시 함수는 사실상 이 "랜덤 순열"을 시뮬레이션한다. minHash(A)는 A의 원소들 중 해시값이 가장 작은 것이고, 이는 "랜덤 순서로 섞었을 때 맨 앞에 오는 A의 원소"에 해당한다. 두 집합의 minHash가 같을 확률 = "그 최소 원소가 교집합에 속할 확률" = J(A,B). 단일 해시로는 분산이 크지만, K개의 독립 해시를 써서 평균내면 정확도가 올라간다.

Q4. T-Digest가 p50보다 p99를 더 정확히 추정하는 이유는?

A. T-Digest는 centroid의 크기를 분위수 위치에 따라 동적으로 제한한다. 중앙(q=0.5) 근처의 centroid는 많은 값을 포함할 수 있지만, 꼬리(q=0.99) 근처는 적은 값만 허용된다. 구체적으로 centroid의 최대 크기는 k = 4N·q·(1-q)/compression이며, q가 0이나 1에 가까울수록 이 값이 작아진다. 결과적으로 꼬리 부분은 더 세밀한 해상도로 표현되고, 중앙은 더 거친 해상도로 표현된다. 이는 실전에서 p50보다 p99가 더 중요한 경우(latency, 리스크 측정)에 매우 유용한 설계다.

Q5. Cuckoo Filter가 Bloom Filter보다 삭제를 지원할 수 있는 이유는?

A. Bloom filter는 k개 해시 위치에 비트를 1로 설정하는데, 이 비트들이 다른 아이템과 공유된다. 한 아이템의 비트를 0으로 만들면 다른 아이템의 상태가 깨진다. Cuckoo filter는 **실제 fingerprint(부분 해시)**를 저장한다. 특정 아이템의 fingerprint를 찾아서 제거하면, 다른 아이템의 fingerprint는 그대로 남는다. 따라서 안전한 삭제가 가능하다. 대신 Cuckoo filter는 load factor가 ~95%를 넘으면 삽입 실패할 수 있고, eviction 루프로 인해 삽입이 약간 더 느리다. 삭제가 필요 없으면 여전히 Bloom이 단순해서 좋을 수 있다.


마치며: 근사치의 예술

핵심 정리

  1. HyperLogLog: 카디널리티. 12KB로 10억. 조화 평균 + 선행 0 트릭.
  2. Count-Min Sketch: 빈도. d×w 행렬. 최솟값으로 추정. False negative 없음.
  3. MinHash: 집합 유사도. P(minHash 같음) = Jaccard.
  4. T-Digest: 분위수. 꼬리가 더 정확한 동적 centroid.
  5. Cuckoo Filter: Bloom의 후계자. 삭제 지원, 캐시 친화적.

언제 무엇을 써야 하는가?

질문
"이 키가 있나?"Bloom / Cuckoo Filter
"몇 개가 서로 다른가?"HyperLogLog
"이 아이템이 얼마나 자주 나왔나?"Count-Min Sketch
"Top K는 무엇인가?"Count-Min + Heap, Misra-Gries
"두 집합이 얼마나 비슷한가?"MinHash
"p99 지연은?"T-Digest, HDR Histogram

실전 조언

  1. 먼저 요구사항을 이해하라: 정확한 답이 정말 필요한가?
  2. 라이브러리를 활용하라: Apache DataSketches, Redis 모듈, algebird (Scala).
  3. 파라미터를 튜닝하라: 너무 작거나 크면 안 됨.
  4. Merge 속성을 활용하라: 분산 집계의 핵심.
  5. 정확도를 검증하라: 작은 규모에서 정확한 값과 비교.

마지막 생각

확률적 데이터 구조는 공학의 아름다움을 보여준다. 이론(해시, 확률)과 실용(메모리 제약, 분산 시스템)이 만나 적은 자원으로 놀라운 결과를 낸다.

다음번에 "distinct count를 어떻게 하지?" 또는 "p99를 어떻게 구하지?"라는 질문이 떠오르면, 이 글을 기억하자. 답은 정확함보다 '충분히 정확함' 에 있을지 모른다. 그리고 그 '충분함'이 수천 배의 성능 향상을 가져다준다.


참고 자료

현재 단락 (1/451)

당신의 웹사이트에 지난 한 달 동안 **몇 명의 고유 방문자**가 왔는가?

작성 글자: 0원문 글자: 17,845작성 단락: 0/451