Redis 자료구조 선택이 캐싱 성능을 가르는 지점
캐시를 도입할 때 가장 흔한 출발점은 "직렬화한 객체를 문자열로 넣고 TTL을 건다"이다. 동작은 하지만, Redis가 제공하는 자료구조를 String 하나로만 쓰는 순간 부분 갱신·순위 조회·중복 제거 같은 작업이 전부 애플리케이션 쪽으로 밀려난다. 자료구조 선택은 편의의 문제가 아니라 명령 복잡도·메모리 인코딩·만료 단위가 함께 결정되는 문제다.
단일 스레드 실행 모델과 명령 복잡도
Redis는 명령 실행을 단일 스레드로 처리한다(I/O 스레드와 백그라운드 해제 작업은 별개다). 따라서 O(N) 명령 하나가 그 시간 동안 다른 모든 클라이언트를 대기시킨다. 공식 문서의 각 명령 페이지에는 시간 복잡도가 명시되어 있는데, 캐싱 설계에서 특히 주의할 것은 다음과 같다.
- HGETALL, SMEMBERS, LRANGE 0 -1 — 컬렉션 전체를 반환하므로 O(N). 요소 수가 커지면 그대로 지연으로 나타난다.
- KEYS — 전체 키스페이스를 훑는 O(N). 운영 환경에서는 커서 기반의 SCAN 계열(SCAN, HSCAN, SSCAN, ZSCAN)로 대체하는 것이 공식 권고다.
- DEL — 큰 컬렉션을 지울 때 해제 비용이 O(N)이다. Redis 4.0에서 추가된 UNLINK는 키를 키스페이스에서 먼저 떼어내고 실제 메모리 회수를 백그라운드로 넘긴다.
내부 인코딩과 메모리 임계값
같은 자료구조라도 크기에 따라 내부 표현이 달라진다. 최신 버전 기준으로 작은 Hash·List·Sorted Set은 listpack(과거 ziplist)으로, 정수만 담긴 작은 Set은 intset으로 압축 저장된다. 설정 기본값은 대체로 hash-max-listpack-entries 128 / hash-max-listpack-value 64, zset-max-listpack-entries 128 / zset-max-listpack-value 64, set-max-intset-entries 512 수준이다(버전별로 항목명이 ziplist에서 listpack으로 바뀌었으므로 사용 중인 버전의 redis.conf를 확인해야 한다).
중요한 성질은 변환이 단방향이라는 점이다. 임계값을 넘어 hashtable이나 skiplist로 승격된 키는 이후 요소가 줄어도 자동으로 압축 표현으로 되돌아가지 않는다. "작은 해시 여러 개"와 "큰 해시 하나"의 메모리 사용량이 크게 갈리는 이유가 여기 있다.
512MB 상한과 빅키
String 값의 최대 크기는 512MB이고, Bitmap은 String 위에 구현되므로 최대 2^32비트라는 한계를 공유한다. 상한 자체보다 실무적으로 먼저 문제가 되는 것은 빅키다. 수십 MB짜리 값 하나를 읽고 쓰는 동안 서버가 점유되고, 클러스터에서는 슬롯 마이그레이션과 복제 지연까지 영향을 받는다.
자료구조별 캐싱 패턴
String과 Hash — 객체 캐시의 두 갈래
String은 직렬화된 페이로드를 통째로 캐싱할 때 가장 단순하다. SET key value EX 300 NX처럼 만료와 조건부 쓰기를 한 명령으로 처리할 수 있고, GETEX(TTL 갱신 조회), GETDEL(조회 후 삭제)은 Redis 6.2에서 추가됐다. 카운터 용도로는 INCR·INCRBY가 원자적으로 동작해 애플리케이션 락이 필요 없다.
Hash는 객체의 필드를 개별 주소로 노출한다. 프로필 중 닉네임만 바뀌었을 때 String이라면 전체를 다시 직렬화해 덮어써야 하지만, Hash라면 HSET user:1 nickname ... 한 번이면 된다. 다만 TTL 단위가 오랫동안 약점이었다. 전통적으로 만료는 키 단위였고, 필드별 만료는 Redis 7.4에서 HEXPIRE·HPEXPIRE·HTTL·HPERSIST가 도입되면서 지원됐다. 7.4 미만을 쓴다면 "필드마다 수명이 다른 데이터"는 Hash가 아니라 개별 String 키로 쪼개는 편이 맞다.
List·Set·Sorted Set — 순서와 순위
List는 양끝 삽입·삭제가 O(1)이라 최근 항목 캐시에 적합하다. LPUSH 후 LTRIM key 0 99로 길이를 고정하는 것이 관용적인 상한 유지 방식이다. BLPOP·BRPOP은 블로킹 소비를 제공하지만, 메시지가 소비되면 사라지고 재처리 보장이 없으므로 큐로 쓸 때는 뒤에 나올 Stream과 비교해야 한다.
Set은 중복 제거와 소속 검사가 목적이다. SISMEMBER는 O(1)이고, Redis 6.2에서 여러 멤버를 한 번에 검사하는 SMISMEMBER가 추가됐다. 교집합 크기만 필요할 때는 결과 집합을 만들지 않는 SINTERCARD(7.0)가 SINTER보다 저렴하다.
Sorted Set은 점수 기반 정렬을 유지한다. 삽입·삭제·순위 조회가 O(log N)이며, 랭킹 캐시뿐 아니라 타임스탬프를 점수로 쓰는 구간 조회, 정렬 순서를 이용한 지연 큐, 슬라이딩 윈도우 레이트 리미터에도 쓰인다. ZADD의 GT·LT 옵션(6.2)은 "기존보다 큰 점수일 때만 갱신" 같은 조건을 서버에서 처리해 준다.
Bitmap·HyperLogLog·Stream — 집계와 이벤트
Bitmap은 사용자 ID를 비트 오프셋으로 매핑해 출석·플래그를 저장한다. 조밀한 ID 공간에서는 메모리 효율이 압도적이지만, ID가 희소하면 오프셋 최대값만큼의 공간이 잡히므로 오히려 낭비다.
HyperLogLog는 고유 개수 추정 전용이다. 공식 문서 기준 표준 오차는 0.81%, 키 하나의 크기는 최대 12KB로 고정된다. 정확한 개수나 개별 원소 조회가 필요하면 Set을, 오차를 감수하고 상수 메모리를 얻고 싶으면 HyperLogLog를 쓴다.
Stream은 추가 전용 로그에 소비자 그룹, 확인 응답(XACK), 미확인 목록(XPENDING), 소유권 이전(XAUTOCLAIM, 6.2)을 얹은 구조다. 재처리와 다중 소비자가 필요한 이벤트 파이프라인이라면 List 대신 Stream이 정답에 가깝다. 무한 증가를 막으려면 XADD ... MAXLEN ~ 10000 같은 트리밍을 함께 건다.
비교표와 선택 기준
자료구조별 비교
| 자료구조 | 대표 캐싱 용도 | 핵심 복잡도 | 주의할 제약 |
|---|---|---|---|
| String | 직렬화 객체, 렌더링 결과, 카운터 | GET/SET O(1) | 값 최대 512MB, 부분 갱신 불가 |
| Hash | 필드 단위 객체 캐시, 설정값 | HGET O(1), HGETALL O(N) | 필드별 TTL은 7.4 이상, listpack 임계값 초과 시 메모리 증가 |
| List | 최근 목록, 단순 작업 큐 | LPUSH/RPOP O(1), LRANGE O(N) | 소비 후 소실, 재처리 보장 없음 |
| Set | 중복 제거, 태그, 권한 검사 | SADD/SISMEMBER O(1) | SMEMBERS는 O(N), 순서 없음 |
| Sorted Set | 랭킹, 시간 구간 조회, 레이트 리밋 | ZADD/ZRANK O(log N) | 동일 점수 다수면 사전순 정렬에 의존 |
| Bitmap | 출석·플래그 집계 | SETBIT O(1), BITCOUNT O(N) | ID가 희소하면 공간 낭비, 2^32비트 상한 |
| HyperLogLog | 고유 방문자 추정 | PFADD O(1) | 표준 오차 0.81%, 개별 원소 조회 불가 |
| Stream | 이벤트 로그, 소비자 그룹 큐 | XADD O(1), 범위 조회 O(N) | 트리밍 없으면 무한 증가 |
반드시 확인할 제약 세 가지
- 만료 단위가 요구사항과 맞는가 — Redis의 만료는 원칙적으로 키 단위다. 컬렉션 안의 개별 요소는 만료되지 않으며, 예외적으로 Hash 필드 TTL이 7.4에서 추가됐다. Sorted Set으로 시간 윈도우를 다룬다면 점수 범위로
ZREMRANGEBYSCORE를 직접 호출해 정리해야 한다. - eviction 정책이 TTL 유무와 맞물린다 —
maxmemory-policy의 기본값은 noeviction으로, 메모리가 차면 쓰기 명령이 오류를 반환한다.volatile-lru·volatile-lfu·volatile-ttl은 만료 시각이 설정된 키만 후보로 삼기 때문에, TTL 없는 키만 있으면 축출할 대상이 없어 사실상 noeviction처럼 동작한다. 순수 캐시 용도라면 allkeys 계열이 전제다. 또한 Redis의 LRU/LFU는 정확한 전역 순위가 아니라 표본 추출 기반 근사이며 표본 수는maxmemory-samples로 조정한다(기본 5). - 클러스터의 다중 키 제약 — 클러스터 모드에서
MGET,SINTER,ZUNIONSTORE, 다중 키 Lua 스크립트, 트랜잭션은 대상 키가 같은 해시 슬롯에 있어야 한다. 같은 슬롯으로 모으려면user:{1000}:profile처럼 해시 태그를 쓰는데, 이는 곧 해당 슬롯에 부하가 집중된다는 뜻이기도 하다. - O(N) 명령의 요소 수 상한을 설계에 못박는가 — List는
LTRIM, Stream은MAXLEN, Sorted Set은ZREMRANGEBYRANK로 상한을 유지한다. 상한 없는 컬렉션은 시간이 지나면 반드시 빅키가 된다.
만료와 무효화, 그리고 캐싱 패턴의 조합
읽기·쓰기 경로 패턴
자료구조를 정했다면 다음은 원본 데이터 저장소와의 관계다. 널리 쓰이는 캐싱 패턴은 세 갈래로 정리된다. Cache-Aside(지연 로딩)는 애플리케이션이 캐시를 먼저 조회하고 미스일 때 DB에서 읽어 채운다. 구현이 단순하고 캐시 장애가 곧 서비스 장애가 되지 않는 대신, 첫 요청은 항상 미스이고 데이터가 낡을 수 있다. Write-Through는 쓰기 시점에 캐시와 DB를 함께 갱신해 신선도를 확보하지만 쓰기 지연이 늘고, 읽히지 않을 데이터까지 캐시에 채운다. Write-Behind는 캐시에 먼저 쓰고 비동기로 DB에 반영해 쓰기 처리량을 얻지만 유실 위험을 감수한다. 실제로는 Cache-Aside를 기본으로 두고, 신선도가 중요한 일부 키에만 Write-Through를 얹는 조합이 흔하다.
만료 처리 방식과 무효화
Redis의 키 만료는 두 방식이 함께 동작한다. 하나는 해당 키에 접근할 때 만료 여부를 확인해 제거하는 수동 방식이고, 다른 하나는 만료 시각이 설정된 키를 주기적으로 표본 추출해 정리하는 능동 방식이다. 즉 TTL이 지났다고 즉시 메모리가 반환되지는 않는다. 만료 시점에 후속 처리가 필요하면 키스페이스 알림(notify-keyspace-events)을 켜서 이벤트를 받을 수 있지만, 이 알림은 최선 노력 전달이므로 정확성이 필요한 로직의 유일한 근거로 삼으면 안 된다. 애플리케이션 로컬 캐시를 함께 쓴다면 Redis 6.0의 클라이언트 사이드 캐싱(RESP3의 CLIENT TRACKING)이 무효화 통지를 서버가 밀어 주는 방식으로 지원한다.
캐시 스탬피드 방어
같은 시각에 만료된 인기 키에 요청이 몰리면 전부 미스가 나면서 원본 DB로 동시에 쏟아진다. 완화 수단은 세 가지다. 첫째, TTL에 무작위 지터를 더해 만료 시점을 분산한다. 둘째, SET lock:key token NX EX 10 형태의 조건부 쓰기로 재계산 권한을 하나의 요청에만 부여하고 나머지는 이전 값을 반환하거나 짧게 대기한다. 셋째, 논리적 만료를 써서 값 안에 만료 시각을 함께 저장하고, 실제 키는 더 길게 유지하되 만료가 임박하면 백그라운드에서 갱신한다. 잠금을 쓸 때는 해제 시 자신이 건 잠금인지 토큰을 대조해야 하며, 이 비교와 삭제는 Lua 스크립트로 원자적으로 처리하는 것이 안전하다.
어떤 경우에 무엇이 적합한가
조건별 정리
- 객체 전체를 통째로 읽고 쓰며 부분 갱신이 없다면 — String. 압축·직렬화 포맷을 자유롭게 고를 수 있고 명령이 가장 단순하다.
- 필드 일부만 자주 바뀌고 전체 크기가 listpack 임계값 안에 들어온다면 — Hash. 필드마다 수명이 달라야 한다면 Redis 7.4 이상인지 먼저 확인하고, 아니라면 String 키로 분리한다.
- 최근 N건만 보여 주면 되고 소비 후 소실을 감수할 수 있다면 — List에
LTRIM. 재처리·다중 소비자·확인 응답이 필요하면 Stream. - 소속 여부만 판정하면 되고 순서가 무의미하다면 — Set. 정확한 고유 개수까지 필요하면 Set, 오차 0.81%를 감수하고 12KB 고정 메모리를 원하면 HyperLogLog.
- 순위·시간 구간·우선순위가 개입한다면 — Sorted Set. O(log N) 비용을 받아들이는 대신 정렬 유지를 서버에 위임한다.
- ID가 조밀한 정수이고 참/거짓 한 비트면 충분하다면 — Bitmap. ID가 희소하면 Set이 낫다.
도입 전 점검 목록
정리하면 Redis 캐싱 패턴 설계는 세 가지 질문으로 수렴한다. 갱신과 만료의 최소 단위는 무엇인가(키인가 필드인가), 이 컬렉션의 요소 수 상한을 무엇으로 강제하는가, 메모리가 한계에 닿았을 때 어떤 키가 어떤 정책으로 사라지는가. 여기에 클러스터를 쓴다면 다중 키 연산이 같은 슬롯에 놓이는지를 더한다. 이 네 가지가 정해지면 자료구조는 대체로 하나로 좁혀진다.
마지막으로, 각 명령의 시간 복잡도·도입 버전·설정 기본값은 Redis 버전에 따라 달라진다. listpack 관련 설정명이 ziplist에서 바뀐 것, Hash 필드 TTL이 7.4에서 추가된 것처럼 버전 경계가 실제 설계 선택을 바꾸는 사례가 적지 않으므로, 운영에 반영하기 전에 사용 중인 버전의 공식 명령 레퍼런스와 redis.conf 기본값을 직접 대조하는 편이 안전하다.