HyperLogLog — 대규모 카디널리티 추정의 원리
하루 방문자가 1억 명인 서비스에서, 고유 방문자 수를 정확히 세려면 메모리가 얼마나 필요할까요?
개념 정의
**HyperLogLog(HLL)**는 집합의 **카디널리티(고유 원소 수)**를 추정하는 확률적 자료구조입니다. 정확한 값이 아닌 근사값을 제공하지만, 12KB의 고정 메모리만으로 수십억 개의 고유 원소를 0.81% 오차로 추정할 수 있습니다.
왜 필요한가
고유 방문자 수를 정확히 세는 방법과 비교해봅니다.
방법 1: Set 사용
1억 개의 고유 ID를 Set에 저장
→ 메모리: ~6.4GB (ID당 ~64바이트)
방법 2: 비트맵 사용 (정수 ID일 때)
→ 메모리: ~12.5MB (1억 비트)
방법 3: HyperLogLog
→ 메모리: 12KB (고정)
→ 오차: ±0.81%
정확한 수가 필요한 게 아니라 "대략 몇 명인지"만 알면 되는 경우, HyperLogLog는 메모리 효율이 압도적입니다.
기본 명령어
# 원소 추가
PFADD visitors "user:1" "user:2" "user:3"
PFADD visitors "user:1" "user:4" # user:1은 이미 있으므로 카디널리티 변화 없음
# 카디널리티 추정
PFCOUNT visitors
# (integer) 4
# 여러 HLL의 합집합 카디널리티
PFADD visitors:page1 "user:1" "user:2" "user:3"
PFADD visitors:page2 "user:2" "user:4" "user:5"
PFCOUNT visitors:page1 visitors:page2
# (integer) 5 (합집합의 카디널리티)
# HLL 합치기 (결과를 새 키에 저장)
PFMERGE visitors:total visitors:page1 visitors:page2
PFCOUNT visitors:total
# (integer) 5
동작 원리 — 확률적 카디널리티 추정
직관적 이해: 동전 던지기 비유
동전을 반복해서 던졌을 때 연속 앞면의 최대 길이를 관찰합니다.
1회 시행: 앞 → 최대 연속 1회 → 약 2^1 = 2번 정도 던진 것으로 추정
4회 시행: 앞앞 → 최대 연속 2회 → 약 2^2 = 4번 정도 던진 것으로 추정
8회 시행: 앞앞앞 → 최대 연속 3회 → 약 2^3 = 8번 정도 던진 것으로 추정
연속 앞면이 k번 나올 확률은 1/2^k이므로, 최대 k를 관찰했다면 약 2^k개의 시행이 있었을 것으로 추정합니다.
해시 함수와 Leading Zeros
실제 HyperLogLog는 해시 함수의 출력에서 leading zeros(선행 0의 수)를 관찰합니다.
입력: "user:1" → hash → 0001011010... → leading zeros = 3
입력: "user:2" → hash → 0000001101... → leading zeros = 6
입력: "user:3" → hash → 0100110100... → leading zeros = 1
입력: "user:4" → hash → 0000000010... → leading zeros = 8
최대 leading zeros = 8 → 추정 카디널리티 ≈ 2^8 = 256
하지만 이것만으로는 분산이 너무 큽니다. 운이 나쁘면 첫 번째 원소에서 많은 leading zeros가 나올 수 있습니다.
분할(Stochastic Averaging)
HyperLogLog는 해시값의 앞부분 비트로 레지스터를 선택하고, 나머지 비트에서 leading zeros를 관찰합니다.
해시값 64비트:
[14비트: 레지스터 인덱스][50비트: leading zeros 관찰 대상]
레지스터 수: 2^14 = 16384개
각 레지스터: leading zeros의 최댓값을 저장 (6비트 = 최대 63)
총 메모리: 16384 × 6비트 = 12,288바이트 ≈ 12KB
원소: "user:1"
hash = 00101100001110...
^^^^^^
레지스터 인덱스 = 0b001011 = 11 (실제로는 14비트)
^^^^^^^^^
나머지에서 leading zeros 관찰
register[11] = max(register[11], leading_zeros)
카디널리티 추정 공식
E = α_m × m² × (Σ 2^(-register[j]))^(-1)
α_m: 보정 상수 (m=16384일 때 약 0.7213)
m: 레지스터 수 (16384)
register[j]: j번째 레지스터의 값
추가로 소규모 카디널리티에서는 Linear Counting, 대규모에서는 별도의 보정이 적용됩니다.
오차율
표준 오차 = 1.04 / sqrt(m)
m = 16384일 때: 1.04 / sqrt(16384) = 1.04 / 128 ≈ 0.0081 = 0.81%
내부 표현
Sparse 표현 (소규모)
원소가 적을 때는 메모리를 절약하기 위해 sparse 인코딩을 사용합니다.
# 원소가 적으면 sparse 표현 사용
PFADD test "a" "b" "c"
OBJECT ENCODING test # 실제로는 string 타입
# hll-sparse-max-bytes (기본: 3000바이트)
# sparse 표현이 이 크기를 초과하면 dense로 전환
CONFIG GET hll-sparse-max-bytes
Dense 표현 (대규모)
16384개의 6비트 레지스터를 연속된 바이트 배열로 저장합니다.
┌──────────────┬──────────────────────────┐
│ 헤더 (16B) │ 레지스터 데이터 (12KB) │
└──────────────┴──────────────────────────┘
실전 활용 패턴
패턴 1: 일일 활성 사용자 (DAU)
import redis
from datetime import date
r = redis.Redis()
def track_user(user_id):
"""사용자 활동 기록"""
today = date.today().isoformat()
r.pfadd(f"dau:{today}", user_id)
def get_dau(target_date=None):
"""일일 활성 사용자 수"""
if target_date is None:
target_date = date.today().isoformat()
return r.pfcount(f"dau:{target_date}")
def get_wau():
"""주간 활성 사용자 수 (7일 합집합)"""
keys = [f"dau:{date.today() - timedelta(days=i)}"
for i in range(7)]
# PFCOUNT에 여러 키를 전달하면 합집합 카디널리티 반환
return r.pfcount(*keys)
def get_mau():
"""월간 활성 사용자 수"""
# 30일치를 합쳐서 계산
keys = [f"dau:{date.today() - timedelta(days=i)}"
for i in range(30)]
# 성능을 위해 PFMERGE 후 PFCOUNT
r.pfmerge("mau:current", *keys)
return r.pfcount("mau:current")
패턴 2: 고유 페이지 뷰
def track_page_view(page_id, user_id):
"""페이지별 고유 조회자 추적"""
r.pfadd(f"pageview:{page_id}", user_id)
def get_unique_viewers(page_id):
return r.pfcount(f"pageview:{page_id}")
def get_site_unique_visitors():
"""전체 사이트 고유 방문자"""
keys = r.keys("pageview:*") # SCAN 사용 권장
if keys:
return r.pfcount(*keys)
return 0
패턴 3: 고유 검색어 카운트
def track_search(query):
today = date.today().isoformat()
r.pfadd(f"search:unique:{today}", query.lower().strip())
def get_unique_searches(target_date):
return r.pfcount(f"search:unique:{target_date}")
HyperLogLog vs Set vs Bitmap
| 기준 | HyperLogLog | Set | Bitmap |
|---|---|---|---|
| 메모리 (1억 원소) | 12KB | ~6.4GB | ~12.5MB |
| 정확도 | ±0.81% | 100% | 100% |
| 멤버십 확인 | 불가 | O(1) | O(1) |
| 합집합 연산 | PFMERGE | SUNIONSTORE | BITOP OR |
| 교집합 연산 | 불가 | SINTERSTORE | BITOP AND |
| 원소 열거 | 불가 | SMEMBERS | 비트 순회 |
선택 기준
- 정확한 수 + 멤버십 확인 필요: Set
- 정수 ID + 멤버십 확인 필요: Bitmap
- "대략 몇 개인지"만 필요: HyperLogLog
주의사항
PFADD의 반환값
PFADD hll "new_element"
# (integer) 1 → 내부 레지스터가 변경됨 (새 원소일 가능성 높음)
# (integer) 0 → 레지스터 변경 없음 (이미 본 원소이거나 영향 없음)
# 주의: 반환값 1이 "새 원소가 추가됨"을 의미하지는 않음
# 레지스터가 변경되었다는 것이며, 확률적 판단임
소규모에서의 정확도
실제 카디널리티가 매우 작을 때 (< 100) 오차가 상대적으로 클 수 있음
예: 실제 10개 → HLL 추정 9~11개 (10% 오차)
실제 1,000,000개 → HLL 추정 991,900~1,008,100 (0.81% 오차)
→ 소규모에서는 Set, 대규모에서는 HLL이 적합
PFMERGE의 비용
# PFMERGE는 O(N) — N은 합치는 HLL의 수
# 레지스터별로 max를 취하므로 12KB씩 읽어야 함
PFMERGE result hll1 hll2 hll3 ... hll365
# 365일치를 합치면 365 × 12KB ≈ 4.3MB 데이터 처리
# 주기적으로 미리 합쳐두는 것이 좋음
정리
HyperLogLog는 "정확하지 않아도 되는 대규모 카운팅"에 최적화된 자료구조입니다.
- 12KB 고정 메모리로 수십억 개의 고유 원소 수를 추정할 수 있습니다
- 표준 오차율은 **0.81%**이며, 대규모 카디널리티에서 특히 정확합니다
- 해시값의 leading zeros 패턴과 16384개의 레지스터 분할로 동작합니다
- PFADD/PFCOUNT/PFMERGE 세 가지 명령어만으로 사용할 수 있습니다
- DAU/WAU/MAU 카운트, 고유 페이지뷰, 고유 검색어 수 추정에 적합합니다
- 멤버십 확인이나 원소 열거가 필요하면 Set이나 Bitmap을 사용해야 합니다