확률적 자료구조 — HyperLogLog, Count-Min Sketch의 원리
"오늘 사이트 방문자가 몇 명이었나요?"라는 질문에 정확한 답을 구하려면 수백 MB의 메모리가 필요합니다. 그런데 12KB만으로도 꽤 정확한 답을 얻을 수 있다면?
확률적 자료구조란
확률적 자료구조(Probabilistic Data Structure)는 정확성을 약간 포기하는 대신, 메모리와 시간을 대폭 절약 하는 자료구조입니다.
| 문제 | 정확한 방법 | 확률적 방법 |
|---|---|---|
| 고유 개수 세기 | HashSet → O(n) 메모리 | HyperLogLog → 12KB |
| 존재 여부 확인 | HashSet → O(n) 메모리 | 블룸 필터 → ** 수 KB** |
| 빈도 추정 | HashMap → O(n) 메모리 | Count-Min Sketch → ** 수 KB** |
HyperLogLog — 고유 개수 추정
문제
"오늘 우리 사이트의 고유 방문자(UV)가 몇 명인가?"
정확하게 세려면 모든 방문자 ID를 HashSet에 저장해야 합니다. 방문자가 1억 명이면 수백 MB가 필요합니다.
HyperLogLog(HLL)는 약 12KB 의 메모리로 이를 0.81% 오차 이내로 추정합니다.
핵심 아이디어
동전을 던져서 연속으로 앞면이 나오는 최대 횟수를 기록한다고 가정합니다.
- 연속 3번 앞면이 나왔다면 → 아마 8번 정도(2^3) 던졌을 것
- 연속 10번 앞면이 나왔다면 → 아마 1024번 정도(2^10) 던졌을 것
HyperLogLog도 비슷합니다. 해시 값의 이진 표현에서 ** 선행 0의 최대 개수 **를 추적합니다.
동작 원리
1. 각 요소를 해시합니다
hash("Alice") = 0001 0110 1011 ...
^^^^ 선행 0이 3개 → 최대 3
hash("Bob") = 0000 0010 1101 ...
^^^^^^ 선행 0이 5개 → 최대 5로 갱신
2. 선행 0의 최대값 R이면, 추정 카디널리티 ≈ 2^R
R = 5 → 약 32개의 고유 값
3. 정확도를 높이기 위해 여러 버킷(레지스터)으로 나눠서 추정
해시의 앞쪽 비트로 버킷 결정, 나머지로 선행 0 계산
각 버킷의 추정치를 조화 평균으로 합산
간략 구현
public class SimpleHyperLogLog {
private static final int BUCKET_COUNT = 16384; // 2^14 = 16384 버킷
private int[] registers;
public SimpleHyperLogLog() {
registers = new int[BUCKET_COUNT];
}
public void add(String element) {
long hash = murmurhash(element);
// 상위 14비트로 버킷 결정
int bucket = (int) (hash >>> 50); // 상위 14비트
// 나머지 50비트에서 선행 0 개수 + 1
long remaining = hash & ((1L << 50) - 1);
int leadingZeros = Long.numberOfLeadingZeros(remaining) - 14 + 1;
// 최댓값 갱신
registers[bucket] = Math.max(registers[bucket], leadingZeros);
}
public long estimate() {
// 조화 평균으로 추정
double harmonicMean = 0;
int zeroCount = 0;
for (int reg : registers) {
harmonicMean += Math.pow(2, -reg);
if (reg == 0) zeroCount++;
}
double alpha = 0.7213 / (1 + 1.079 / BUCKET_COUNT); // 보정 상수
double estimate = alpha * BUCKET_COUNT * BUCKET_COUNT / harmonicMean;
// 작은 값 보정
if (estimate <= 2.5 * BUCKET_COUNT && zeroCount > 0) {
estimate = BUCKET_COUNT * Math.log((double) BUCKET_COUNT / zeroCount);
}
return Math.round(estimate);
}
}
Redis에서의 HyperLogLog
# 요소 추가: O(1)
PFADD visitors "user:1001"
PFADD visitors "user:1002"
PFADD visitors "user:1001" # 중복 — 카운트에 영향 없음
# 고유 개수 추정: O(1)
PFCOUNT visitors # 2 (추정치)
# 여러 HLL 병합: 일별 UV를 주별 UV로 합산
PFMERGE weekly:visitors daily:mon daily:tue daily:wed ...
PFCOUNT weekly:visitors # 주간 고유 방문자 추정
# 메모리: 최대 12KB (정확한 카운팅은 수백 MB 필요)
실무 활용
@Service
public class UniqueVisitorService {
private final StringRedisTemplate redis;
// 방문자 기록
public void recordVisit(String userId) {
String key = "uv:" + LocalDate.now();
redis.opsForHyperLogLog().add(key, userId);
}
// 오늘의 UV 추정
public long getTodayUV() {
String key = "uv:" + LocalDate.now();
return redis.opsForHyperLogLog().size(key);
}
// 이번 주 UV 추정
public long getWeeklyUV() {
String[] keys = IntStream.range(0, 7)
.mapToObj(i -> "uv:" + LocalDate.now().minusDays(i))
.toArray(String[]::new);
String mergedKey = "uv:weekly:" + LocalDate.now();
redis.opsForHyperLogLog().union(mergedKey, keys);
return redis.opsForHyperLogLog().size(mergedKey);
}
}
Count-Min Sketch — 빈도 추정
문제
"이 URL이 오늘 몇 번 요청되었는가?"
정확하게 세려면 HashMap<URL, Integer>가 필요합니다. URL이 수백만 종류면 메모리가 많이 필요합니다.
Count-Min Sketch는 고정 크기 메모리 로 빈도를 추정합니다.
구조
d개의 해시 함수와 w개의 카운터로 구성된 2차원 배열 입니다.
Hash 1: [ 2 | 0 | 3 | 0 | 1 | 0 | 0 | 4 ]
Hash 2: [ 1 | 3 | 0 | 0 | 2 | 0 | 1 | 0 ]
Hash 3: [ 0 | 0 | 2 | 1 | 0 | 3 | 0 | 0 ]
동작
public class CountMinSketch {
private int[][] table;
private int width;
private int depth;
private int[] hashSeeds;
public CountMinSketch(int width, int depth) {
this.width = width;
this.depth = depth;
this.table = new int[depth][width];
this.hashSeeds = new int[depth];
Random random = new Random(42);
for (int i = 0; i < depth; i++) {
hashSeeds[i] = random.nextInt();
}
}
// 빈도 증가: O(d)
public void increment(String item) {
for (int i = 0; i < depth; i++) {
int index = hash(item, i) % width;
table[i][index]++;
}
}
// 빈도 추정: O(d) — 모든 행의 최솟값을 반환
public int estimate(String item) {
int min = Integer.MAX_VALUE;
for (int i = 0; i < depth; i++) {
int index = hash(item, i) % width;
min = Math.min(min, table[i][index]);
}
return min;
}
private int hash(String item, int i) {
return Math.abs((item.hashCode() ^ hashSeeds[i]) * 31 + hashSeeds[i]);
}
}
특성
- **과대평가는 가능 **, 과소평가는 불가능
- 해시 충돌로 인해 실제보다 큰 값이 나올 수 있음
- 오차 한계: ε(epsilon)과 δ(delta)로 조절
- width = ⌈e/ε⌉ (오차 범위)
- depth = ⌈ln(1/δ)⌉ (신뢰도)
실무 활용: 인기 URL 추적
@Component
public class PopularUrlTracker {
private final CountMinSketch sketch;
private final PriorityQueue<Map.Entry<String, Integer>> topK;
public PopularUrlTracker() {
this.sketch = new CountMinSketch(10000, 5); // w=10000, d=5
this.topK = new PriorityQueue<>(Comparator.comparingInt(Map.Entry::getValue));
}
public void recordAccess(String url) {
sketch.increment(url);
}
public int getEstimatedCount(String url) {
return sketch.estimate(url);
}
}
확률적 자료구조 비교
| 자료구조 | 용도 | 오차 유형 | 메모리 |
|---|---|---|---|
| ** 블룸 필터** | 존재 여부 | False Positive | 수 KB |
| HyperLogLog | 고유 개수 | ±0.81% | 12 KB |
| Count-Min Sketch | 빈도 추정 | 과대평가 | 수 KB |
| Min-Hash | 집합 유사도 | 확률적 추정 | 가변 |
언제 확률적 자료구조를 사용하는가
사용이 적합한 경우
- 데이터가 매우 크고, 정확한 답이 필요 없을 때
- "대략 얼마나?"가 "정확히 얼마"보다 중요한 상황
- 메모리 제약이 있는 스트리밍 환경
- 실시간 통계/모니터링
사용이 부적합한 경우
- 정확한 값이 반드시 필요한 경우 (금융 거래, 인증 등)
- 데이터 크기가 작아서 정확한 방법의 비용이 수용 가능한 경우
정리
- HyperLogLog 는 약 12KB로 수십억 개의 고유 값을 0.81% 오차로 추정합니다. Redis의 PFADD/PFCOUNT로 쉽게 사용할 수 있습니다.
- Count-Min Sketch 는 고정 메모리로 빈도를 추정하며, 과대평가는 가능하지만 과소평가는 하지 않습니다.
- 확률적 자료구조의 핵심은 정확성을 약간 포기하고 메모리와 시간을 대폭 절약 하는 트레이드오프입니다.
- 실무에서는 UV 측정, 인기 콘텐츠 추적, 네트워크 모니터링 등 대규모 스트리밍 통계 에 활용됩니다.
- 정확한 값이 필요한 곳에는 사용하면 안 됩니다. 어디까지나 "대략적인 추정"입니다.
댓글 로딩 중...