Theme:

하루 방문자가 1억 명인 서비스에서, 고유 방문자 수를 정확히 세려면 메모리가 얼마나 필요할까요?

개념 정의

**HyperLogLog(HLL)**는 집합의 **카디널리티(고유 원소 수)**를 추정하는 확률적 자료구조입니다. 정확한 값이 아닌 근사값을 제공하지만, 12KB의 고정 메모리만으로 수십억 개의 고유 원소를 0.81% 오차로 추정할 수 있습니다.

왜 필요한가

고유 방문자 수를 정확히 세는 방법과 비교해봅니다.

PLAINTEXT
방법 1: Set 사용
  1억 개의 고유 ID를 Set에 저장
  → 메모리: ~6.4GB (ID당 ~64바이트)

방법 2: 비트맵 사용 (정수 ID일 때)
  → 메모리: ~12.5MB (1억 비트)

방법 3: HyperLogLog
  → 메모리: 12KB (고정)
  → 오차: ±0.81%

정확한 수가 필요한 게 아니라 "대략 몇 명인지"만 알면 되는 경우, HyperLogLog는 메모리 효율이 압도적입니다.

기본 명령어

BASH
# 원소 추가
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

동작 원리 — 확률적 카디널리티 추정

직관적 이해: 동전 던지기 비유

동전을 반복해서 던졌을 때 연속 앞면의 최대 길이를 관찰합니다.

PLAINTEXT
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의 수)를 관찰합니다.

PLAINTEXT
입력: "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를 관찰합니다.

PLAINTEXT
해시값 64비트:
[14비트: 레지스터 인덱스][50비트: leading zeros 관찰 대상]

레지스터 수: 2^14 = 16384개
각 레지스터: leading zeros의 최댓값을 저장 (6비트 = 최대 63)
총 메모리: 16384 × 6비트 = 12,288바이트 ≈ 12KB
PLAINTEXT
원소: "user:1"
hash = 00101100001110...
       ^^^^^^
       레지스터 인덱스 = 0b001011 = 11 (실제로는 14비트)
              ^^^^^^^^^
              나머지에서 leading zeros 관찰

register[11] = max(register[11], leading_zeros)

카디널리티 추정 공식

PLAINTEXT
E = α_m × m² × (Σ 2^(-register[j]))^(-1)

α_m: 보정 상수 (m=16384일 때 약 0.7213)
m: 레지스터 수 (16384)
register[j]: j번째 레지스터의 값

추가로 소규모 카디널리티에서는 Linear Counting, 대규모에서는 별도의 보정이 적용됩니다.

오차율

PLAINTEXT
표준 오차 = 1.04 / sqrt(m)
m = 16384일 때: 1.04 / sqrt(16384) = 1.04 / 128 ≈ 0.0081 = 0.81%

내부 표현

Sparse 표현 (소규모)

원소가 적을 때는 메모리를 절약하기 위해 sparse 인코딩을 사용합니다.

BASH
# 원소가 적으면 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비트 레지스터를 연속된 바이트 배열로 저장합니다.

PLAINTEXT
┌──────────────┬──────────────────────────┐
│ 헤더 (16B)   │ 레지스터 데이터 (12KB)    │
└──────────────┴──────────────────────────┘

실전 활용 패턴

패턴 1: 일일 활성 사용자 (DAU)

PYTHON
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: 고유 페이지 뷰

PYTHON
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: 고유 검색어 카운트

PYTHON
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

기준HyperLogLogSetBitmap
메모리 (1억 원소)12KB~6.4GB~12.5MB
정확도±0.81%100%100%
멤버십 확인불가O(1)O(1)
합집합 연산PFMERGESUNIONSTOREBITOP OR
교집합 연산불가SINTERSTOREBITOP AND
원소 열거불가SMEMBERS비트 순회

선택 기준

  • 정확한 수 + 멤버십 확인 필요: Set
  • 정수 ID + 멤버십 확인 필요: Bitmap
  • "대략 몇 개인지"만 필요: HyperLogLog

주의사항

PFADD의 반환값

BASH
PFADD hll "new_element"
# (integer) 1  → 내부 레지스터가 변경됨 (새 원소일 가능성 높음)
# (integer) 0  → 레지스터 변경 없음 (이미 본 원소이거나 영향 없음)

# 주의: 반환값 1이 "새 원소가 추가됨"을 의미하지는 않음
# 레지스터가 변경되었다는 것이며, 확률적 판단임

소규모에서의 정확도

PLAINTEXT
실제 카디널리티가 매우 작을 때 (< 100) 오차가 상대적으로 클 수 있음
예: 실제 10개 → HLL 추정 9~11개 (10% 오차)
    실제 1,000,000개 → HLL 추정 991,900~1,008,100 (0.81% 오차)

→ 소규모에서는 Set, 대규모에서는 HLL이 적합

PFMERGE의 비용

BASH
# 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을 사용해야 합니다
댓글 로딩 중...