Bitmap과 Bitfield — 비트 단위 데이터 처리
수백만 명의 사용자가 오늘 접속했는지를 1비트로 기록할 수 있다면, 얼마나 많은 메모리를 절약할 수 있을까요?
개념 정의
Bitmap 은 Redis String 타입을 비트 배열로 활용하는 방식입니다. 각 비트(0 또는 1)에 하나의 상태를 저장할 수 있어, 대규모 이진 상태 추적에 매우 효율적입니다. Bitfield 는 비트맵을 확장하여 임의 크기의 정수를 비트 오프셋으로 읽고 쓸 수 있게 합니다.
왜 필요한가
사용자 100만 명의 오늘 출석 여부를 저장하는 방법을 비교합니다.
방법 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
# 오프셋 위치의 비트를 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로 변경)
주의: 오프셋에 따른 메모리 할당
# 오프셋이 크면 중간 비트가 0으로 채워진 메모리가 할당됨
SETBIT sparse 100000000 1 # 100M번째 비트 → 약 12MB 할당
# 사용자 ID가 크고 희소한 경우 비효율적
# → 이 경우 Set이나 HyperLogLog가 나을 수 있음
BITCOUNT — 1의 개수 세기
# 전체 비트맵에서 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 찾기
# 첫 번째 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 — 비트 연산
여러 비트맵 간의 비트 연산을 수행합니다.
# 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의 시간 복잡도
O(N) — N은 가장 긴 비트맵의 바이트 수
100만 사용자 비트맵: ~125KB → 매우 빠름
1억 사용자 비트맵: ~12MB → 약간의 시간 소요
Bitfield — 비트 단위 정수 조작
Bitfield는 비트맵 위에서 ** 임의 크기의 정수 **를 읽고 쓸 수 있습니다.
기본 사용법
# 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]
비트 레이아웃
바이트: [ 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바이트
오버플로우 제어
# 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 활용: 컴팩트 카운터 배열
# 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: 출석 시스템
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}")
연속 출석 확인은 BITOP AND로 여러 날의 비트맵을 교집합하여 구합니다. 모든 날에 1인 비트만 결과에 남기 **때문에 **, 해당 사용자의 비트가 1이면 연속 출석이 확인됩니다.
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: 활성 사용자 분석
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
주간 활성 사용자(WAU)는 7일간의 비트맵을 BITOP OR로 합집합한 뒤 BITCOUNT로 셉니다.
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)
# 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로 컴팩트 게임 데이터
# 게임 캐릭터 스탯을 비트필드로 팩킹
# 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
| 기준 | Bitmap | HyperLogLog | Set |
|---|---|---|---|
| 멤버십 확인 | O(1) | 불가 | O(1) |
| 카디널리티 | BITCOUNT O(N) | PFCOUNT O(1) | SCARD O(1) |
| 교집합/합집합 | BITOP | PFMERGE(합집합만) | SINTER/SUNION |
| 메모리 (ID 1~100만) | 125KB | 12KB | ~32MB |
| ID 요구사항 | 정수, 연속적 | 없음 | 없음 |
| 정확도 | 100% | ±0.81% | 100% |
함정/Pitfall
1. 큰 오프셋의 SETBIT은 대규모 메모리를 즉시 할당한다
SETBIT sparse 100000000 1 # 약 12MB 즉시 할당
비트맵은 오프셋까지 0으로 채운 연속 메모리를 할당합니다. 사용자 ID가 1억 이상이면 한 번의 SETBIT으로 수십 MB가 할당될 수 있으므로, ID가 크고 희소한 경우 Set이나 HyperLogLog가 더 적합합니다.
2. BITOP은 가장 긴 비트맵 기준으로 결과가 생성된다
두 비트맵의 크기가 다르면, 짧은 쪽은 0으로 패딩됩니다. 하나의 비트맵이 비정상적으로 큰 경우, BITOP 결과도 그만큼 커져 메모리와 처리 시간이 낭비됩니다.
3. Bitmap은 개별 원소를 열거할 수 없다
BITCOUNT로 1의 개수는 셀 수 있지만, "어떤 사용자들이 출석했는지" 목록을 구하려면 전체 비트를 순회해야 합니다. 멤버 열거가 필요하면 Set을 사용해야 합니다.
정리
| 항목 | 핵심 내용 |
|---|---|
| 내부 구조 | String 타입의 바이트 배열, SETBIT/GETBIT로 비트 접근 |
| 비트 연산 | BITOP AND/OR/XOR/NOT로 교집합/합집합 분석 |
| BITCOUNT | CPU POPCNT 활용, 매우 빠른 1비트 카운팅 |
| Bitfield | 임의 크기 정수 읽기/쓰기, 컴팩트 카운터 배열 |
| 적합 조건 | 정수 ID + 연속적일 때 최적, 희소 ID에서는 비효율 |
| 활용 패턴 | 출석 시스템, 활성 사용자 분석, 기능 플래그 |