Theme:

수백만 명의 사용자가 오늘 접속했는지를 1비트로 기록할 수 있다면, 얼마나 많은 메모리를 절약할 수 있을까요?

개념 정의

Bitmap은 Redis String 타입을 비트 배열로 활용하는 방식입니다. 각 비트(0 또는 1)에 하나의 상태를 저장할 수 있어, 대규모 이진 상태 추적에 매우 효율적입니다. Bitfield는 비트맵을 확장하여 임의 크기의 정수를 비트 오프셋으로 읽고 쓸 수 있게 합니다.

왜 필요한가

사용자 100만 명의 오늘 출석 여부를 저장하는 방법을 비교합니다.

PLAINTEXT
방법 1: Set에 출석한 사용자 ID 저장
  SADD attendance:2026-03-19 "user:1" "user:2" ...
  → 50만 명 출석 시: ~32MB

방법 2: Hash에 사용자별 필드로 저장
  HSET attendance:2026-03-19 user:1 1 user:2 1 ...
  → 50만 명 출석 시: ~25MB

방법 3: Bitmap (사용자 ID가 정수)
  SETBIT attendance:2026-03-19 1 1
  SETBIT attendance:2026-03-19 2 1
  → 100만 명 전체 비트맵: ~125KB (100만 / 8)

Bitmap은 200배 이상 메모리를 절약합니다.

Bitmap 기본 명령어

SETBIT / GETBIT

BASH
# 오프셋 위치의 비트를 1로 설정
SETBIT login:2026-03-19 1001 1   # 사용자 1001 로그인
SETBIT login:2026-03-19 1002 1   # 사용자 1002 로그인
SETBIT login:2026-03-19 1003 1   # 사용자 1003 로그인

# 특정 오프셋의 비트 조회
GETBIT login:2026-03-19 1001     # 1 (로그인함)
GETBIT login:2026-03-19 9999     # 0 (로그인 안 함)

# SETBIT 반환값: 이전 비트 값
SETBIT login:2026-03-19 1001 1   # 1 (이미 1이었음)
SETBIT login:2026-03-19 5000 1   # 0 (0이었음 → 1로 변경)

주의: 오프셋에 따른 메모리 할당

BASH
# 오프셋이 크면 중간 비트가 0으로 채워진 메모리가 할당됨
SETBIT sparse 100000000 1  # 100M번째 비트 → 약 12MB 할당

# 사용자 ID가 크고 희소한 경우 비효율적
# → 이 경우 Set이나 HyperLogLog가 나을 수 있음

BITCOUNT — 1의 개수 세기

BASH
# 전체 비트맵에서 1의 개수
BITCOUNT login:2026-03-19
# (integer) 3

# 바이트 범위 지정
BITCOUNT login:2026-03-19 0 10   # 0~10번째 바이트(0~87번째 비트)

# 비트 범위 지정 (7.0+)
BITCOUNT login:2026-03-19 0 100 BIT  # 0~100번째 비트

BITPOS — 첫 번째 0 또는 1 찾기

BASH
# 첫 번째 1 비트의 위치
BITPOS login:2026-03-19 1
# (integer) 1001

# 첫 번째 0 비트의 위치
BITPOS login:2026-03-19 0

# 범위 지정
BITPOS login:2026-03-19 1 100 200 BIT  # 100~200 비트 범위 (7.0+)

BITOP — 비트 연산

여러 비트맵 간의 비트 연산을 수행합니다.

BASH
# 3월 17일, 18일, 19일 로그인 기록
SETBIT login:0317 1 1
SETBIT login:0317 2 1
SETBIT login:0317 3 1

SETBIT login:0318 2 1
SETBIT login:0318 3 1
SETBIT login:0318 4 1

SETBIT login:0319 3 1
SETBIT login:0319 4 1
SETBIT login:0319 5 1

# AND: 3일 연속 로그인한 사용자
BITOP AND result:consecutive login:0317 login:0318 login:0319
BITCOUNT result:consecutive    # 1 (사용자 3만 3일 연속)

# OR: 3일 중 하나라도 로그인한 사용자
BITOP OR result:any login:0317 login:0318 login:0319
BITCOUNT result:any            # 5 (사용자 1,2,3,4,5)

# XOR: 두 날 중 하루만 로그인한 사용자
BITOP XOR result:xor login:0317 login:0318
# 사용자 1(17일만), 4(18일만)

# NOT: 비트 반전
BITOP NOT result:not login:0319

BITOP의 시간 복잡도

PLAINTEXT
O(N) — N은 가장 긴 비트맵의 바이트 수
100만 사용자 비트맵: ~125KB → 매우 빠름
1억 사용자 비트맵: ~12MB → 약간의 시간 소요

Bitfield — 비트 단위 정수 조작

Bitfield는 비트맵 위에서 임의 크기의 정수를 읽고 쓸 수 있습니다.

기본 사용법

BASH
# u8: 부호 없는 8비트 정수, i16: 부호 있는 16비트 정수
# SET: 값 설정
# GET: 값 조회
# INCRBY: 값 증가

# 오프셋 0에 u8(부호 없는 8비트) 값 200 저장
BITFIELD mykey SET u8 0 200

# 오프셋 8에 u8 값 100 저장
BITFIELD mykey SET u8 8 100

# 조회
BITFIELD mykey GET u8 0 GET u8 8
# [200, 100]

# 원자적 증가
BITFIELD mykey INCRBY u8 0 10
# [210]

비트 레이아웃

PLAINTEXT
바이트:  [  0번 바이트  ][  1번 바이트  ][  2번 바이트  ]
비트:   0 1 2 3 4 5 6 7 8 9 ...
        |── u8 @0 ──|  |── u8 @8 ──|

# 비트 단위로 정밀한 팩킹 가능
BITFIELD packed SET u4 0 15 SET u4 4 8 SET u8 8 255
# 4비트(15) + 4비트(8) + 8비트(255) = 16비트 = 2바이트

오버플로우 제어

BASH
# WRAP: 오버플로우 시 순환 (기본)
BITFIELD mykey OVERFLOW WRAP INCRBY u8 0 200
# u8 최대 255 → 200 + 200 = 400 → 400 % 256 = 144

# SAT: 오버플로우 시 최대/최소값에 고정
BITFIELD mykey OVERFLOW SAT INCRBY u8 0 300
# 255 (최대값에 고정)

# FAIL: 오버플로우 시 연산 실패 (nil 반환)
BITFIELD mykey OVERFLOW FAIL INCRBY u8 0 300
# (nil)

Bitfield 활용: 컴팩트 카운터 배열

BASH
# 24시간 시간대별 방문 카운터 (각 u16 = 16비트)
# 24개 × 16비트 = 384비트 = 48바이트에 24개 카운터 저장!

# 14시 방문 카운터 증가
BITFIELD hourly_visits INCRBY u16 #14 1
# #14는 "14번째 u16 오프셋" = 비트 오프셋 14*16 = 224

# 14시 방문 수 조회
BITFIELD hourly_visits GET u16 #14

# 모든 시간대 조회
BITFIELD hourly_visits GET u16 #0 GET u16 #1 ... GET u16 #23

실전 패턴

패턴 1: 출석 시스템

PYTHON
from datetime import date, timedelta

def check_in(user_id):
    """오늘 출석 체크"""
    today = date.today().isoformat()
    r.setbit(f"attendance:{today}", user_id, 1)

def is_checked_in(user_id, target_date=None):
    """출석 여부 확인"""
    if target_date is None:
        target_date = date.today().isoformat()
    return r.getbit(f"attendance:{target_date}", user_id)

def get_daily_count(target_date):
    """일별 출석자 수"""
    return r.bitcount(f"attendance:{target_date}")

def get_consecutive_days(user_id, days=7):
    """N일 연속 출석 확인"""
    keys = [f"attendance:{(date.today() - timedelta(days=i)).isoformat()}"
            for i in range(days)]

    # AND 연산으로 모든 날 출석한 사용자 비트맵 생성
    r.bitop('AND', 'temp:consecutive', *keys)
    result = r.getbit('temp:consecutive', user_id)
    r.delete('temp:consecutive')
    return bool(result)

패턴 2: 활성 사용자 분석

PYTHON
def track_active(user_id, feature):
    """기능별 활성 사용자 추적"""
    today = date.today().isoformat()
    r.setbit(f"active:{feature}:{today}", user_id, 1)

def feature_overlap(feature_a, feature_b, target_date):
    """두 기능을 모두 사용한 사용자 수"""
    key_a = f"active:{feature_a}:{target_date}"
    key_b = f"active:{feature_b}:{target_date}"
    r.bitop('AND', 'temp:overlap', key_a, key_b)
    count = r.bitcount('temp:overlap')
    r.delete('temp:overlap')
    return count

def weekly_active_users(feature):
    """주간 활성 사용자 (7일 합집합)"""
    keys = [f"active:{feature}:{(date.today() - timedelta(days=i)).isoformat()}"
            for i in range(7)]
    r.bitop('OR', f'wau:{feature}', *keys)
    return r.bitcount(f'wau:{feature}')

패턴 3: 기능 플래그(Feature Flag)

PYTHON
# 32개의 기능 플래그를 u32 하나에 저장
FEATURE_DARK_MODE = 0
FEATURE_NEW_UI = 1
FEATURE_BETA = 2

def enable_feature(user_id, feature_bit):
    """기능 활성화"""
    r.bitfield(f"features:{user_id}",
               'SET', 'u1', f'#{feature_bit}', 1)

def is_feature_enabled(user_id, feature_bit):
    """기능 활성화 여부"""
    result = r.bitfield(f"features:{user_id}",
                        'GET', 'u1', f'#{feature_bit}')
    return bool(result[0])

패턴 4: Bitfield로 컴팩트 게임 데이터

PYTHON
# 게임 캐릭터 스탯을 비트필드로 팩킹
# HP(u16) + MP(u16) + ATK(u8) + DEF(u8) + LVL(u8) = 72비트 = 9바이트

def create_character(char_id, hp, mp, atk, defense, level):
    key = f"char:{char_id}"
    r.bitfield(key,
        'SET', 'u16', '#0', hp,      # HP: 0~65535
        'SET', 'u16', '#1', mp,      # MP: 0~65535
        'SET', 'u8', '#4', atk,      # ATK: 0~255 (바이트 오프셋 4)
        'SET', 'u8', '#5', defense,  # DEF: 0~255
        'SET', 'u8', '#6', level     # LVL: 0~255
    )

def take_damage(char_id, damage):
    """OVERFLOW SAT으로 HP가 0 이하로 내려가지 않게"""
    result = r.bitfield(f"char:{char_id}",
        'OVERFLOW', 'SAT',
        'INCRBY', 'u16', '#0', -damage
    )
    return result[0]  # 남은 HP

Bitmap vs HyperLogLog vs Set

기준BitmapHyperLogLogSet
멤버십 확인O(1)불가O(1)
카디널리티BITCOUNT O(N)PFCOUNT O(1)SCARD O(1)
교집합/합집합BITOPPFMERGE(합집합만)SINTER/SUNION
메모리 (ID 1~100만)125KB12KB~32MB
ID 요구사항정수, 연속적없음없음
정확도100%±0.81%100%

정리

Bitmap과 Bitfield는 비트 단위 데이터 처리에 최적화된 도구입니다.

  • Bitmap은 내부적으로 String 타입이며, SETBIT/GETBIT으로 비트 단위 접근을 제공합니다
  • BITOP으로 여러 비트맵 간 AND/OR/XOR/NOT 연산이 가능하여 교집합/합집합 분석에 유용합니다
  • BITCOUNT는 CPU의 POPCNT 명령어를 활용하여 매우 빠르게 1 비트를 셉니다
  • Bitfield는 비트맵 위에 임의 크기의 정수를 읽고 쓸 수 있어, 컴팩트 카운터 배열에 적합합니다
  • 사용자 ID가 정수이고 연속적일 때 가장 효율적이며, 희소한 ID에서는 메모리 낭비가 발생합니다
  • 출석 시스템, 활성 사용자 분석, 기능 플래그 등에 널리 활용됩니다
댓글 로딩 중...