deep-dive2025-02-21·8 min·182/348

Redis HyperLogLog 활용: 효율적인 유니크 카운팅

Redis HyperLogLog 데이터 구조를 활용한 메모리 효율적인 유니크 카운팅 구현 방법을 설명합니다.

Redis HyperLogLog 활용

Introduction

Redis HyperLogLog는 확률적 데이터 구조를 사용하여 메모리 효율적으로 유니크 요소의 수(cardinality)를 추정하는 강력한 도구입니다. 전통적인 Set 기반 카운팅이 대용량 데이터에서 메모리 문제를 일으키는 반면, HyperLogLog는 고정된 12KB 메모리로 최대 2^64개의 유니크 요소를 추정할 수 있습니다. 이 글에서는 HyperLogLog의 활용 방법을 다루겠습니다.

Environment

# Redis HyperLogLog 명령어 테스트
redis-cli

# HyperLogLog 메모리 사용량 확인
redis-cli MEMORY USAGE myhyperloglog
// Node.js Redis 클라이언트
const Redis = require('ioredis');
const redis = new Redis();

Problem

전통적인 카운팅 방식의 메모리 문제:

// Set을 사용한 유니크 카운팅 - 메모리 낭비
await redis.sadd('unique_users', 'user1');
await redis.sadd('unique_users', 'user2');
await redis.sadd('unique_users', 'user3');
// ... 1000만 사용자 추가 시

const uniqueCount = await redis.scard('unique_users');
console.log('Unique users:', uniqueCount);

// 메모리 사용량 확인
const memoryUsage = await redis.memory('USAGE', 'unique_users');
console.log('Memory usage:', memoryUsage, 'bytes');
// Memory usage: 536870912 bytes (512MB!)
# Set 기반 카운팅의 메모리 문제
redis-cli INFO memory | grep used_memory_human
# used_memory_human:512.00M

# 대용량 데이터 처리 시 문제
# 1. 메모리 부족
# 2. 네트워크 대역폭 낭비
# 3. 성능 저하

Analysis

HyperLogLog의 동작 원리를 분석했습니다:

// HyperLogLog 메모리 사용량 확인
await redis.pfadd('test_hll', 'a', 'b', 'c');
const memoryUsage = await redis.memory('USAGE', 'test_hll');
console.log('HyperLogLog memory:', memoryUsage, 'bytes');
// HyperLogLog memory: 12440 bytes (약 12KB!)

// Set과 HyperLogLog 비교
// 100만 유니크 요소 시뮬레이션
console.log('Set vs HyperLogLog:');
console.log('Set: ~512MB');
console.log('HyperLogLog: ~12KB');
console.log('메모리 절감: 99.99%');
// HyperLogLog 정확도 테스트
async function testHyperLogLogAccuracy() {
  const testValues = [];
  for (let i = 0; i < 1000000; i++) {
    testValues.push(`user_${i}`);
  }
  
  // 실제 유니크 수
  const actualUnique = new Set(testValues).size;
  console.log('Actual unique count:', actualUnique);
  
  // HyperLogLog로 측정
  await redis.del('accuracy_test');
  for (let i = 0; i < testValues.length; i += 100) {
    await redis.pfadd('accuracy_test', ...testValues.slice(i, i + 100));
  }
  
  const estimatedCount = await redis.pfcount('accuracy_test');
  console.log('Estimated unique count:', estimatedCount);
  
  const accuracy = (1 - Math.abs(actualUnique - estimatedCount) / actualUnique) * 100;
  console.log('Accuracy:', accuracy.toFixed(2) + '%');
  // Accuracy: 98.5%
}

Solution

1단계: 기본 HyperLogLog 사용

// 유니크 방문자 카운팅
async function trackUniqueVisitors(pageUrl, visitorId) {
  const key = `visitors:${pageUrl}`;
  
  // 방문자 추가
  const added = await redis.pfadd(key, visitorId);
  
  // 유니크 방문자 수 조회
  const uniqueCount = await redis.pfcount(key);
  
  return {
    isNewVisitor: added === 1,
    uniqueCount: uniqueCount
  };
}

// 사용 예
const result1 = await trackUniqueVisitors('/home', 'user123');
console.log(result1);
// { isNewVisitor: true, uniqueCount: 1 }

const result2 = await trackUniqueVisitors('/home', 'user123');
console.log(result2);
// { isNewVisitor: false, uniqueCount: 1 }

2단계: 복수 키 합치기

// 여러 페이지의 유니크 방문자 합산
async function getDailyUniqueVisitors(date) {
  const keys = [
    `visitors:/home:${date}`,
    `visitors:/about:${date}`,
    `visitors:/products:${date}`,
    `visitors:/contact:${date}`
  ];
  
  // 병합된 카운트 계산
  const uniqueCount = await redis.pfcount(...keys);
  
  return uniqueCount;
}

// 월간 유니크 방문자 계산
async function getMonthlyUniqueVisitors(year, month) {
  const daysInMonth = new Date(year, month, 0).getDate();
  const keys = [];
  
  for (let day = 1; day <= daysInMonth; day++) {
    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
    keys.push(`visitors:daily:${dateStr}`);
  }
  
  return await redis.pfcount(...keys);
}

// HyperLogLog 병합
async function mergeHyperLogLogs(sourceKeys, destinationKey) {
  await redis.pfmerge(destinationKey, ...sourceKeys);
  return await redis.pfcount(destinationKey);
}

3단계: 실시간 분석 시스템 구현

// 실시간 유니크 사용자 추적 시스템
class UniqueTracker {
  constructor(redis, prefix) {
    this.redis = redis;
    this.prefix = prefix;
  }
  
  async track(event, userId, timestamp) {
    const date = new Date(timestamp).toISOString().split('T')[0];
    const hour = new Date(timestamp).getHours();
    
    // 일별 유니크 카운트
    const dailyKey = `${this.prefix}:${event}:daily:${date}`;
    await this.redis.pfadd(dailyKey, userId);
    
    // 시간별 유니크 카운트
    const hourlyKey = `${this.prefix}:${event}:hourly:${date}:${hour}`;
    await this.redis.pfadd(hourlyKey, userId);
    
    // 실시간 카운트 조회
    const dailyCount = await this.redis.pfcount(dailyKey);
    const hourlyCount = await this.redis.pfcount(hourlyKey);
    
    return {
      dailyUnique: dailyCount,
      hourlyUnique: hourlyCount
    };
  }
  
  async getStats(event, date) {
    const dailyKey = `${this.prefix}:${event}:daily:${date}`;
    const hourlyCounts = [];
    
    for (let hour = 0; hour < 24; hour++) {
      const hourlyKey = `${this.prefix}:${event}:hourly:${date}:${hour}`;
      const count = await this.redis.pfcount(hourlyKey);
      hourlyCounts.push({ hour, count });
    }
    
    return {
      dailyUnique: await this.redis.pfcount(dailyKey),
      hourlyBreakdown: hourlyCounts
    };
  }
}

// 사용 예
const tracker = new UniqueTracker(redis, 'analytics');

await tracker.track('page_view', 'user123', new Date());
await tracker.track('page_view', 'user456', new Date());
await tracker.track('page_view', 'user123', new Date());  // 중복

const stats = await tracker.getStats('page_view', '2025-02-21');
console.log('Stats:', stats);

Lessons Learned

  1. 메모리 효율성: HyperLogLog는 12KB의 고정 메모리로 매우 정확한 유니크 카운팅을 제공합니다
  2. 정확도 이해: HyperLogLog는 확률적 데이터 구조이므로 오차 범위(약 0.81%)가 존재합니다
  3. 병합 기능: PFMERGE 명령으로 여러 HyperLogLog를 병합하여 복잡한 분석이 가능합니다
  4. 적합한 사용 사례: 방문자 수, 세션 수 등 대략적인 유니크 카운트에 적합합니다
  5. 불가능한 작업: 개별 요소의 확인이나 삭제는 불가능하므로 정확한 목록이 필요하면 다른 방법을 사용해야 합니다

이 블로그는 외부 스폰서십, 제휴 마케팅 또는 광고 수익을 받지 않습니다.