고급 자료구조는 '더 어려운 명령어'가 아니다
앞 장에서는 List, Set, Sorted Set으로 큐, 좋아요, 랭킹 같은 기능을 만들었다. 이번 장에서 다룰 Bitmap, HyperLogLog, Geospatial, Stream도 같은 관점으로 보면 쉽다. 핵심은 명령어를 외우는 것이 아니라 문제를 어떤 모양의 데이터로 표현할지 결정하는 것이다.
Redis의 고급 자료구조는 특히 실시간 서비스에서 자주 만나는 질문에 잘 맞는다.
오늘 어떤 사용자가 접속했는가?
이번 달에 사용자가 며칠 출석했는가?
오늘 고유 방문자는 대략 몇 명인가?
현재 위치에서 가까운 매장은 어디인가?
주문, 결제, 알림 이벤트를 순서대로 남기고 읽을 수 있는가?
이 질문들은 모두 관계형 데이터베이스로도 풀 수 있다. 하지만 매 요청마다 큰 테이블을 집계하거나, 위치 계산을 반복하거나, 이벤트 로그를 실시간으로 읽어야 한다면 비용이 커진다. Redis는 이런 문제를 메모리와 자료구조의 힘으로 빠르게 처리하게 해 준다. 다만 모든 기능이 정확도, 메모리, 처리량을 동시에 최대로 만족시키지는 않는다. Redis답게 설계한다는 것은 이 셋 사이의 균형을 고르는 일이다.
AI 생성 이미지
Bitmap: 예/아니오 상태를 작게 저장하기
Bitmap은 별도 자료구조처럼 보이지만, Redis에서는 String 값을 비트 단위로 다루는 방식이다. 하나의 비트는 0 또는 1만 저장한다. 그래서 "했다/안 했다", "켜짐/꺼짐", "접속함/접속 안 함"처럼 이진 상태를 저장하기 좋다.1
예를 들어 사용자별 일일 활성 여부를 저장해 보자. 날짜별로 키를 만들고, 사용자 번호를 비트 위치로 사용한다.
SETBIT active:2026-05-30 1001 1
GETBIT active:2026-05-30 1001
BITCOUNT active:2026-05-30SETBIT은 지정한 offset 위치의 비트를 0 또는 1로 바꾼다. GETBIT은 해당 위치의 값을 읽는다. BITCOUNT는 값이 1인 비트 수를 센다. 위 예제에서는 active:2026-05-30 키 하나만으로 그날 활성 사용자 수를 빠르게 구할 수 있다.
출석 체크는 반대로 설계할 수도 있다. 사용자별 월간 출석 여부를 저장하려면 날짜를 비트 위치로 사용한다.
SETBIT attendance:user:1001:2026-05 29 1
BITCOUNT attendance:user:1001:2026-05비트 offset은 0부터 시작한다. 따라서 5월 30일을 표시하려면 offset 29를 사용한다. 이 방식이면 한 사용자가 이번 달에 며칠 출석했는지 BITCOUNT 한 번으로 확인할 수 있다.
Bitmap의 장점은 메모리 효율이다. 사용자 백만 명의 하루 접속 여부도 순수 비트만 보면 약 125KB 정도면 표현할 수 있다. 반면 Set에 사용자 ID 문자열을 모두 넣으면 훨씬 많은 메모리가 필요하다.
하지만 주의할 점도 있다. Bitmap은 가장 큰 offset까지 공간을 확보한다. 사용자 ID가 999999999처럼 매우 크고 드문드문 존재한다면 비어 있는 공간이 많아져 메모리를 낭비한다. 이런 경우에는 내부에서 별도의 연속 번호를 부여하거나, 정확한 사용자 목록이 필요하다면 Set을 쓰는 편이 낫다.
Bitmap은 "누가 1인지 목록을 자주 꺼내야 하는 기능"에도 불편하다. BITCOUNT는 빠르지만, 모든 활성 사용자 ID를 가져오는 기능은 Set만큼 자연스럽지 않다. 즉, Bitmap은 개별 여부 확인과 개수 집계에는 강하지만, 멤버 목록 조회에는 약하다.
HyperLogLog: 고유 개수를 적은 메모리로 추정하기
서비스를 운영하다 보면 "오늘 방문자가 몇 명인가?"라는 질문을 자주 만난다. 여기서 중요한 것은 방문 횟수가 아니라 고유 방문자 수다. 같은 사용자가 열 번 방문해도 한 명으로 세야 한다.
정확한 고유 사용자 수가 필요하면 Set을 사용할 수 있다.
SADD uv:page:42:2026-05-30 user:1001
SCARD uv:page:42:2026-05-30이 방식은 정확하다. 하지만 방문자가 수천만 명으로 늘어나면 Set은 모든 사용자 ID를 기억해야 하므로 메모리 사용량도 함께 증가한다.
HyperLogLog는 이 문제를 다르게 푼다. 모든 사용자를 저장하지 않고, 고유 개수를 확률적으로 추정한다. Redis의 HyperLogLog는 최대 약 12KB의 메모리로 매우 큰 집합의 고유 원소 수(cardinality)를 추정하며, 표준 오차율은 약 0.81%다.2
PFADD uv:page:42:2026-05-30 user:1001
PFADD uv:page:42:2026-05-30 user:1002
PFADD uv:page:42:2026-05-30 user:1001
PFCOUNT uv:page:42:2026-05-30PFADD는 관측한 값을 HyperLogLog에 추가한다. 같은 사용자가 여러 번 들어와도 고유 개수 추정에서는 중복 관측으로 처리되므로, Set처럼 사용자 ID 전체를 보관하지 않아도 대략적인 고유 방문자 수를 얻을 수 있다. PFCOUNT는 추정된 고유 개수를 반환한다.
여러 날짜의 값을 합쳐 기간별 방문자 수를 보고 싶다면 PFMERGE를 사용할 수 있다.
PFMERGE uv:page:42:2026-W22 \
uv:page:42:2026-05-25 \
uv:page:42:2026-05-26 \
uv:page:42:2026-05-27
PFCOUNT uv:page:42:2026-W22HyperLogLog는 대시보드, 페이지별 고유 방문자, 영상 재생 사용자 수, 검색어 사용 사용자 수처럼 "대략적인 추세"가 중요한 지표에 잘 맞는다. 100만 명인지 101만 명인지보다 증가 추세가 중요한 상황에서는 메모리를 크게 아낄 수 있다.
반대로 결제, 정산, 쿠폰 지급처럼 정확한 숫자가 필요한 기능에는 적합하지 않다. 또한 HyperLogLog는 "이 사용자가 방문했는가?"를 확인할 수 없다. 멤버십 검사가 필요하면 Set이나 Bitmap을 선택해야 한다.
정리하면, HyperLogLog는 정확한 목록을 포기하는 대신 메모리를 거의 고정 비용으로 유지하는 선택이다. 이 선택이 가능한 지표와 불가능한 지표를 구분해야 한다.
Geospatial: 가까운 위치를 빠르게 찾기
위치 기반 서비스에서는 "현재 위치에서 반경 2km 안의 매장을 찾아라" 같은 요구가 자주 나온다. Redis의 Geospatial 명령은 경도(longitude), 위도(latitude), 멤버 이름을 저장하고 주변 위치를 검색할 수 있게 해 준다. Redis 문서에서는 위치를 반경 또는 박스 범위로 찾는 인덱스로 설명한다.1
매장 위치를 저장해 보자. GEOADD는 경도, 위도, 멤버 순서로 값을 받는다. 위도와 경도의 순서를 바꾸는 실수가 많으니 주의해야 한다.
GEOADD stores:seoul 127.0276 37.4979 store:gangnam
GEOADD stores:seoul 126.9780 37.5665 store:cityhall
GEOADD stores:seoul 127.0365 37.5007 store:yeoksam현재 위치 주변 매장을 찾을 때는 GEOSEARCH를 사용할 수 있다.
GEOSEARCH stores:seoul \
FROMLONLAT 127.0276 37.4979 \
BYRADIUS 2 km \
WITHDIST \
COUNT 10 \
ASC이 명령은 지정한 좌표를 기준으로 반경 2km 안의 매장을 찾고, 거리 정보를 함께 반환하며, 가까운 순서로 최대 10개를 가져온다. 예전에는 GEORADIUS 계열 명령이 자주 쓰였지만 현재는 GEOSEARCH, GEOSEARCHSTORE 사용이 권장된다.3
두 매장 사이의 거리는 GEODIST로 구할 수 있다.
GEODIST stores:seoul store:gangnam store:cityhall kmGeospatial 기능을 쓸 때는 위치 정보와 상세 정보를 분리하는 편이 좋다. 위치 검색 키에는 좌표와 매장 ID만 저장하고, 매장명, 영업 시간, 카테고리 같은 정보는 Hash에 둔다.
HSET store:gangnam name "강남점" category "coffee" open "09:00"이렇게 하면 위치 검색은 가볍게 처리하고, 선택된 매장의 상세 정보만 별도로 조회할 수 있다.
단, Redis Geospatial은 길찾기 엔진이 아니다. 도로 상황, 이동 시간, 지하철 경로, 행정구역 경계 같은 복잡한 공간 분석을 대신하지 않는다. "가까운 후보를 빠르게 좁히는 용도"로 이해하는 것이 좋다.
Stream: 이벤트를 순서대로 남기고 읽기
List로도 간단한 큐를 만들 수 있다. 하지만 앞 장에서 본 것처럼 단순 List 큐는 워커가 작업을 꺼낸 뒤 실패했을 때 처리가 애매해질 수 있다. 또한 이벤트를 로그처럼 남기고 나중에 다시 읽는 흐름에는 List보다 Stream이 더 잘 맞는다.
Redis Stream은 append-only log에 가깝다. 새 이벤트는 뒤에 추가되고, 각 이벤트에는 시간 기반 ID가 붙는다. Redis 문서도 Stream을 이벤트를 순서대로 기록하고 처리하기 위한 자료구조로 설명한다.1
주문 이벤트를 기록해 보자.
XADD stream:orders * type created orderId 5001 userId 1001 amount 39000
XADD stream:orders * type paid orderId 5001 userId 1001 amount 39000XADD의 *는 Redis가 자동으로 ID를 만들게 한다. 실제 ID는 보통 밀리초시간-순번 형태다. 같은 밀리초에 여러 이벤트가 들어와도 뒤쪽 순번으로 순서를 구분할 수 있다. 이 ID 덕분에 Stream은 이벤트의 순서를 유지하고, 특정 지점 이후의 이벤트를 읽을 수 있다.
전체 범위를 확인하려면 XRANGE를 사용한다.
XRANGE stream:orders - + COUNT 10-는 가장 오래된 이벤트, +는 가장 최신 이벤트를 의미한다. 운영에서는 전체를 무제한으로 읽기보다 COUNT로 범위를 제한하는 습관이 중요하다.
새 이벤트를 기다리며 읽을 수도 있다.
XREAD BLOCK 5000 COUNT 10 STREAMS stream:orders $BLOCK 5000은 최대 5초 동안 새 이벤트를 기다린다는 뜻이다. 마지막의 $는 "이미 들어와 있는 이벤트는 건너뛰고, 지금 이후에 들어오는 새 이벤트부터 읽겠다"는 의미다. 처음부터 읽어야 한다면 0을 사용하고, 이어서 처리해야 한다면 애플리케이션이 마지막으로 처리한 Stream ID를 저장해 두었다가 그 ID를 넘겨야 한다.
Stream은 다음과 같은 경우에 유용하다.
주문, 결제, 알림 같은 도메인 이벤트 기록
여러 워커가 순서 있는 이벤트를 처리해야 하는 경우
처리 실패 후 특정 ID부터 다시 읽어야 하는 경우
최근 이벤트 로그를 Redis 안에서 빠르게 조회해야 하는 경우
하지만 Stream도 무한 로그 저장소는 아니다. 이벤트가 계속 쌓이면 메모리를 사용한다. 따라서 보관 기간이나 최대 길이를 정해야 한다.
XTRIM stream:orders MAXLEN ~ 100000~는 Redis가 성능을 위해 근사적으로 길이를 정리할 수 있음을 의미한다. 정확히 100000개를 맞추는 것보다 빠른 정리가 중요한 상황에 적합하다.
Consumer Group, XACK, 재처리 전략은 메시징과 이벤트 처리 장에서 더 자세히 다룬다. 여기서는 Stream을 "순서가 있는 이벤트 로그"로 이해하면 충분하다.
어떤 자료구조를 선택할 것인가
고급 자료구조는 기능 이름으로 고르는 것이 아니라 질문의 성격으로 고른다.
| 문제 | 적합한 자료구조 | 이유 |
|---|---|---|
| 오늘 사용자가 접속했는지 확인 | Bitmap | 이진 상태를 매우 작게 저장 |
| 오늘 활성 사용자 수 집계 | Bitmap | BITCOUNT로 빠르게 개수 계산 |
| 페이지 고유 방문자 수 추정 | HyperLogLog | 정확도를 조금 포기하고 메모리 절약 |
| 정확한 참여자 목록 필요 | Set | 멤버 목록과 포함 여부를 정확히 관리 |
| 주변 매장 검색 | Geospatial | 좌표 기반 반경 검색과 거리 계산 |
| 주문 이벤트 순서 기록 | Stream | 이벤트를 append-only log로 저장 |
Bitmap은 정확하지만 표현할 수 있는 값이 단순하다. HyperLogLog는 메모리가 작지만 추정값만 제공한다. Geospatial은 주변 후보 검색에 좋지만 복잡한 지도 분석을 대신하지 않는다. Stream은 이벤트 흐름에 강하지만 보관 정책 없이 쓰면 메모리가 계속 증가한다.
Redis를 잘 쓰는 설계는 항상 같은 질문으로 시작한다.
"이 기능에서 정말 필요한 것은 정확한 목록인가, 빠른 개수인가, 가까운 후보인가, 순서 있는 이벤트인가?"
이 질문에 답할 수 있으면 자료구조 선택은 훨씬 쉬워진다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.