Redis를 캐시로만 쓰다 보면 SET key value 하나면 끝이다. 근데 서비스가 커질수록 뭔가 이상해진다. 메모리가 예상보다 훨씬 많이 나가거나, 특정 커맨드가 수초씩 블록되거나, 메시지가 조용히 사라지거나. 그때마다 자료구조 선택이 문제였다.
Q1. Redis가 다 String 아닌가? 왜 자료구조가 여러 개인가?
유저 프로필을 저장해야 한다. 이름, 이메일, 나이, 마지막 로그인.
String으로 저장하면:
SET user:1:name "김철수"
SET user:1:email "kim@example.com"
SET user:1:age "30"
SET user:1:last_login "2026-06-29T10:00:00"
이름 하나 바꾸려면 키 1개만 건드리면 된다. 괜찮아 보인다.
유저가 100만 명이면? 키가 400만 개다. Redis는 키마다 내부 메타데이터를 붙인다. 키 이름, 만료 시각, LRU 정보. 빈 키 하나당 약 50바이트 오버헤드. 400만 개면 오버헤드만 200MB다. 데이터 자체보다 메타데이터가 더 많아진다.
그럼 JSON으로 하나에 묶으면?
SET user:1 '{"name":"김철수","email":"kim@example.com","age":30,"last_login":"2026-06-29T10:00:00"}'
키는 100만 개로 줄었다. 근데 나이를 1 증가시키려면?
String json = redis.get("user:1");
UserDto dto = objectMapper.readValue(json, UserDto.class);
dto.setAge(dto.getAge() + 1);
redis.set("user:1", objectMapper.writeValueAsString(dto));
역직렬화 → 수정 → 직렬화 → 저장. 이 사이에 다른 스레드가 같은 유저를 수정하면 덮어써진다. 원자적으로 불가능하다.
세 가지 문제가 동시에 있다.
- 필드별 String: 키 폭발 (유저 100만 × 필드 4개 = 키 400만 개)
- JSON String: 부분 업데이트 불가, 역직렬화 비용, 동시성 문제
- 둘 다: 원자적 필드 증가 불가
Hash가 이 세 가지를 한 번에 해결한다.
HSET user:1 name "김철수" email "kim@example.com" age 30 last_login "2026-06-29T10:00:00"
HINCRBY user:1 age 1 -- 원자적 증가
HGET user:1 name -- 필드 하나만 조회
HDEL user:1 last_login -- 필드 하나만 삭제
키는 100만 개. 필드 단위 접근 가능. 원자적 증가 가능. 그래서 자료구조가 여러 개인 거다. 저장 구조가 다르면 가능한 연산이 달라진다.
Q2. Hash가 내부적으로 두 가지 형태로 저장된다는데?
“세션 저장소를 Hash로 바꿨다. 기존 대비 메모리 30% 절감 기대했는데 서버 배포 후 오히려 2배로 뛰었다.”
Redis Hash는 내부적으로 두 가지 인코딩 중 하나를 쓴다. listpack과 hashtable.
listpack (작은 Hash)
연속된 메모리 블록. 필드-값 쌍을 순서대로 나란히 저장한다.
[field1_len][field1][val1_len][val1][field2_len][field2][val2_len][val2]...
포인터가 없다. CPU 캐시에 통째로 올라간다. 메모리 오버헤드가 거의 없다. 탐색은 O(N)이지만 N이 작으면 캐시 히트 덕분에 실제로 빠르다.
hashtable (큰 Hash)
전통적인 해시 테이블. 각 버킷이 포인터를 들고 있다. 포인터 하나에 8바이트. 필드 수가 늘어날수록 포인터 오버헤드가 쌓인다.
전환 임계값
hash-max-listpack-entries 128 -- 필드 수
hash-max-listpack-value 64 -- 각 필드/값의 바이트 길이
둘 중 하나라도 초과하면 listpack → hashtable로 단방향 전환된다. 돌아오지 않는다.
세션 데이터가 130개 필드를 갖는다고 하자. 유저 100만 명.
- listpack 유지 시: 필드당 약 40바이트 (데이터만)
- hashtable 전환 후: 필드당 포인터 + 메타데이터 추가로 약 120바이트
100만 명 × 130개 필드 × (120 - 40)바이트 = 10.4GB 증가
“메모리 절감 기대했는데 오히려 2배로 뛰었다”는 바로 이 전환 때문이다.
현재 인코딩 확인:
OBJECT ENCODING user:1
# "listpack" 또는 "hashtable"
Spring Boot 세션 저장소에서 확인하는 패턴:
// Spring Data Redis로 인코딩 확인
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public String getEncoding(String key) {
return (String) redisTemplate.execute(
(RedisCallback<String>) conn -> conn.objectEncoding(key.getBytes())
);
}
Redis 7.4+에서는 HEXPIRE가 생겼다. 필드 단위 TTL. Hash를 세션 저장소로 쓸 때 만료 필드만 골라서 자동 삭제 가능하다.
HEXPIRE user:1 3600 FIELDS 1 last_login
Q3. List로 큐를 만들면 되는데, 왜 BLPOP이 있나?
작업 큐에서 메시지를 꺼내는 코드를 짰다.
while (true) {
String job = redis.rpop("job-queue");
if (job != null) {
process(job);
} else {
Thread.sleep(100); // 100ms 대기
}
}
돌아간다. 근데 이게 맞나?
큐가 비어 있으면 Redis를 초당 10번 때린다. 아무것도 없는데 연결을 열고, 커맨드를 보내고, 응답을 받는다. 워커가 100개라면 초당 1000번. Redis는 일하고 있지만 데이터는 없다.
폴링 주기를 바꿔도 트레이드오프다.
- 10ms 폴링: CPU와 연결 낭비, Redis가 쓸데없이 바쁨
- 1000ms 폴링: 처리 지연 최대 1초, 트래픽 몰리면 큐가 쌓임
BLPOP은 이 딜레마를 없앤다.
BLPOP job-queue 30 -- 큐가 빌 때 최대 30초 블록
큐가 비면 연결을 붙잡고 기다린다. Redis 내부에서 해당 키를 구독하는 클라이언트 목록을 관리한다. 메시지가 LPUSH되는 순간 즉시 해당 클라이언트에게 응답한다. 폴링 없음. Redis 부하 0.
// Lettuce (Spring Boot 기본 Redis 클라이언트)
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void startWorker() {
ListOperations<String, String> ops = redisTemplate.opsForList();
while (true) {
// timeout 30초, 큐 비면 블록
List<String> result = ops.rightPop("job-queue", Duration.ofSeconds(30));
if (result != null && !result.isEmpty()) {
process(result.get(0));
}
}
}
주의: BLPOP은 연결을 블록한다. Lettuce의 커넥션 풀에서 전용 커넥션을 써야 한다. 일반 커넥션 풀에서 쓰면 풀이 고갈된다.
LMOVE로 안전한 큐
RPOP은 꺼내는 순간 사라진다. 처리 도중 서버가 죽으면 메시지 유실.
LMOVE job-queue processing-queue RIGHT LEFT
job-queue에서 꺼내서 processing-queue에 원자적으로 넣는다. 처리 완료 후 processing-queue에서 삭제. 서버가 죽으면 processing-queue에 남아 있다. 재시작 후 거기서 복구하면 된다.
List 내부 구조: quicklist
List는 내부적으로 quicklist다. 여러 개의 listpack을 이중 연결 리스트로 연결한 구조.
[listpack1] <-> [listpack2] <-> [listpack3]
각 listpack 안에는 여러 항목이 연속 메모리에 저장된다. LPUSH/RPUSH는 O(1). LINDEX나 중간 삽입은 O(N).
Q4. Sorted Set의 내부가 skiplist라는데. 왜 B-tree 안 쓰나?
실시간 랭킹을 List로 구현했다. 점수 순으로 정렬 상태를 유지해야 하니까 삽입할 때마다 이진 탐색으로 위치를 찾고 끼워 넣었다.
// List로 정렬 삽입 — 절대 하지 말 것
List<UserScore> ranking = redis.getList("ranking");
int pos = Collections.binarySearch(ranking, newScore, comparator);
ranking.add(pos < 0 ? -pos - 1 : pos, newScore);
redis.setList("ranking", ranking);
사용자가 10만 명 넘어가니 삽입할 때마다 수초씩 걸린다.
이진 탐색으로 위치는 O(log N)에 찾아도, 중간 삽입이 O(N)이다. ArrayList는 삽입 지점 이후를 전부 밀어야 한다. 10만 명이면 최악 10만 번 이동.
Sorted Set은 skiplist로 구현됐다.
skiplist 구조
레이어 3: [ 1 ] ------------------------------------> [100]
레이어 2: [ 1 ] -----------> [ 50 ] -------------- > [100]
레이어 1: [ 1 ] --> [ 20 ] -> [ 50 ] -> [ 70 ] --> [100]
레이어 0: [ 1 ] -> [ 20 ] -> [ 50 ] -> [ 70 ] --> [100]
각 노드는 여러 레이어의 포인터를 갖는다. 탐색 시 높은 레이어에서 크게 건너뛰고, 내려오면서 좁힌다. 삽입/삭제도 같은 방식. 평균 O(log N).
왜 B-tree 아닌가
B-tree는 디스크 I/O를 최소화하기 위해 설계됐다. 노드 크기를 페이지 크기에 맞추고, 레벨을 최소화해서 디스크 읽기 횟수를 줄인다.
Redis는 인메모리다. 디스크 I/O가 없다. B-tree의 장점이 사라진다. 대신 B-tree는 삽입/삭제 시 리밸런싱이 필요하다. 레드-블랙 트리처럼 회전 연산이 복잡하다. skiplist는 확률적 레이어 결정이라 리밸런싱이 없다. 구현이 단순하고 동시성 제어도 쉽다.
Sorted Set = skiplist + hashtable
Sorted Set은 두 개의 자료구조를 동시에 유지한다.
- skiplist: 점수 순 정렬, 범위 조회에 사용
- hashtable: 멤버 → 점수 매핑, 점수 조회에 사용
ZADD leaderboard 1500 "user:1" # skiplist 삽입 + hashtable 삽입, O(log N)
ZRANK leaderboard "user:1" # skiplist 탐색, O(log N)
ZSCORE leaderboard "user:1" # hashtable 조회, O(1)
ZRANGE leaderboard 0 9 WITHSCORES # skiplist 범위 조회, O(log N + K)
Spring Boot:
ZSetOperations<String, String> zOps = redisTemplate.opsForZSet();
// 점수 업데이트 (없으면 추가, 있으면 갱신)
zOps.add("leaderboard", "user:1", 1500.0);
// 상위 10명 (내림차순)
Set<ZSetOperations.TypedTuple<String>> top10 =
zOps.reverseRangeWithScores("leaderboard", 0, 9);
// 내 순위
Long rank = zOps.reverseRank("leaderboard", "user:1");
Q5. Sorted Set에서 점수를 float으로 저장하면 정밀도 문제가 생긴다는데?
게임 랭킹을 Sorted Set으로 구현했다. user:A와 user:B의 점수가 분명히 다르다. 그런데 ZSCORE가 같은 값을 반환한다.
Redis Sorted Set의 점수는 IEEE 754 double (64비트 부동소수점)이다.
double의 가수부는 52비트. 정수로 표현 가능한 최대값은 2^53 = 9007199254740992. 이 값을 초과하는 정수는 double로 정확히 표현할 수 없다.
redis-cli에서 직접 확인:
redis-cli
127.0.0.1:6379> ZADD scores 9007199254740993 "user:A"
(integer) 1
127.0.0.1:6379> ZADD scores 9007199254740994 "user:B"
(integer) 1
127.0.0.1:6379> ZSCORE scores "user:A"
"9007199254740992" # 9007199254740993인데 다르게 나온다
127.0.0.1:6379> ZSCORE scores "user:B"
"9007199254740992" # 두 값이 같다
user:A와 user:B는 점수가 달랐지만 저장하면서 같아졌다.
어떤 값이 안전한가
- 타임스탬프(ms) ≈ 1.7 × 10^12 < 2^53: 안전
- 마이크로초 타임스탬프 ≈ 1.7 × 10^15 > 2^53: 위험
- 게임 포인트 수천~수백만: 보통 안전
해결책
점수가 2^53을 넘을 가능성이 있다면 설계를 바꾼다.
- 점수를 스케일 다운: 마이크로초 대신 밀리초 사용
- 동점 처리를 member 이름에 포함: “1500_2026062910:00:00_user:1”처럼 타이브레이커를 member 자체에 넣으면 ZRANGEBYLEX로 처리 가능
ZSetOperations<String, String> zOps = redisTemplate.opsForZSet();
// 동점일 때 더 빠른 달성자가 위
// member에 타임스탬프 포함: score 동점이면 lexicographic 순으로 앞선 사람이 위
String member = String.format("%020d_%s", timestamp, userId);
zOps.addIfAbsent("leaderboard", member, score); // 이미 있으면 갱신 안 함
// 점수와 함께 조회
Set<ZSetOperations.TypedTuple<String>> result =
zOps.reverseRangeWithScores("leaderboard", 0, 99);
Q6. Set은 언제 쓰나? 교집합/합집합이 실전에서 어디에 쓰이나?
사용자 A와 B의 공통 팔로워를 찾아야 한다.
DB로 구현하면:
SELECT user_id
FROM followers
WHERE followed_id IN (A, B)
GROUP BY user_id
HAVING COUNT(*) = 2;
인덱스를 탄다. 하지만 A의 팔로워 100만 명, B의 팔로워 50만 명이라면 조인 전 스캔 행이 150만 개다. 실시간 API에서 이걸 매번 실행하면 DB가 버티질 못한다.
Redis Set:
# 각 유저의 팔로워 Set
SADD followers:A user:1 user:2 user:3 ...
SADD followers:B user:2 user:3 user:4 ...
# 교집합: A와 B 모두 팔로워인 유저
SINTERCARD 2 followers:A followers:B LIMIT 100
# 합집합: A 또는 B를 팔로우하는 유저
SUNIONSTORE result:AB followers:A followers:B
# 차집합: A는 팔로우하지만 B는 팔로우 안 하는 유저
SDIFF followers:A followers:B
Set 교집합은 Redis 내부에서 O(N × M) (N = 작은 Set 크기, M = Set 수). 가장 작은 Set을 기준으로 각 멤버를 다른 Set들에서 멤버십 조회(hashtable lookup)하는 방식이다. 비트맵 연산이 아니라 메모리 내 해시 조회다. DB 쿼리보다 압도적으로 빠르다.
멤버십 확인: SISMEMBER vs LRANGE
“이 요청 ID를 이미 처리했나?” 확인이 필요하다.
List로 하면:
LRANGE processed 0 -1 # 전체 꺼내서
# 코드에서 contains 확인 → O(N)
Set으로 하면:
SISMEMBER processed "req:12345" # O(1)
요청 ID를 Set에 넣으면 중복 처리를 O(1)에 방어한다. 중복 제거가 자동이다.
intset 인코딩
Set에 정수만 들어오고 개수가 set-max-intset-entries(기본 512) 이하면 Redis는 intset으로 저장한다. 정렬된 정수 배열. 포인터 오버헤드 없음. 이진 탐색으로 O(log N) 탐색. 메모리 효율이 hashtable보다 훨씬 좋다.
정수가 아닌 원소가 들어오거나 한도를 넘으면 전환되는데, 전환 대상이 버전에 따라 다르다.
- Redis 7.2+: 작은 Set이면 listpack으로 전환된다.
set-max-listpack-entries(기본 128)와set-max-listpack-value(기본 64바이트)까지는 listpack을 유지하고, 이걸 넘으면 그때 hashtable로 간다. - Redis 7.2 미만: listpack 단계가 없다. 정수 외 값이 들어오면 바로 hashtable로 전환된다.
SADD int-set 1 2 3 4 5
OBJECT ENCODING int-set
# "intset"
SADD int-set "hello"
OBJECT ENCODING int-set
# Redis 7.2+: "listpack" (Set이 작으므로 listpack)
# 7.2 미만: "hashtable"
원소 수가 listpack 한도를 넘으면 7.2+에서도 hashtable로 전환된다.
비트 집계와 카디널리티: Bitmap, HyperLogLog
Set 외에 “집계” 용도로 자주 쓰는 두 가지가 더 있다.
- Bitmap: String 위에 비트 단위로 동작하는 연산(SETBIT/GETBIT/BITCOUNT). 유저 ID를 오프셋으로 매핑하면 출석 체크나 DAU 같은 불리언 집계를 매우 적은 메모리로 처리한다. 천만 유저의 하루 출석 여부도 약 1.2MB면 담긴다.
- HyperLogLog: 정확한 원소를 저장하지 않고 카디널리티(고유 개수)를 확률적으로 추정한다(PFADD/PFCOUNT). 키 하나당 약 12KB 고정 메모리로 수억 개 규모의 고유 카운트를 표준오차 약 0.81%로 추정한다. 정확한 멤버십이 필요 없고 “대략 몇 개인가”만 필요할 때(고유 방문자 수 등) Set보다 메모리가 압도적으로 적다.
Q7. Stream이 Kafka랑 비슷하다는데, 어떻게 다른가?
→ 여기서는 ‘자료구조로서의 Stream’(append-only log, PEL, Consumer Group의 기본 동작)만 본다. Pub/Sub과의 선택 기준, 메시지 유실 시나리오, PEL 재처리 운영 같은 메시징 상세는 Redis Pub/Sub vs Stream 참고.
RPOP으로 작업 큐를 구현했다. 서버가 죽었다. 꺼낸 메시지가 사라졌다.
String job = redis.rpop("job-queue"); // 여기서 꺼냄
// 이 사이에 서버가 죽으면?
process(job); // 처리 못 함, 메시지 유실
RPOP은 꺼내는 순간 큐에서 지워진다. 처리 실패해도 재처리 방법이 없다.
Redis Stream은 append-only log다.
XADD job-stream * task "send-email" to "user@example.com"
# 메시지를 추가. 큐에서 사라지지 않음.
XREADGROUP GROUP workers consumer1 COUNT 10 STREAMS job-stream >
# Consumer Group이 메시지를 가져감. 아직 삭제 안 됨.
XACK job-stream workers 1718600000000-0
# 처리 완료 후 ACK. 그때서야 PEL에서 제거.
ACK 전까지 메시지는 **PEL(Pending Entries List)**에 있다. 서버가 죽어도 PEL에 남는다. 재시작 후 XPENDING으로 확인하고 재처리할 수 있다.
Kafka vs Redis Stream
| Kafka | Redis Stream | |
|---|---|---|
| 저장 | 디스크 | 메모리 (+ AOF/RDB) |
| 보존 용량 | TB 단위 | maxmemory 제한 |
| 보존 기간 | 주~월 단위 설정 가능 | 시간 단위, MAXLEN으로 제한 |
| 처리량 | 초당 수백만 건 | 초당 수만~수십만 건 |
| 운영 복잡도 | 높음 (ZooKeeper/KRaft, 파티션 관리) | 낮음 (Redis 이미 씀) |
| 재처리 | 오프셋으로 어느 시점이든 | XCLAIM으로 미처리 분만 |
Redis Stream으로 충분한 케이스
- Redis를 이미 쓰고 있다
- 메시지 보존 기간이 수 시간~하루 이내
- 처리량이 초당 수천 건 이하
- Kafka 운영 인력이 없다
Spring Boot Consumer Group:
@Configuration
public class StreamConfig {
@Bean
public StreamMessageListenerContainer<String, ObjectRecord<String, JobDto>> listenerContainer(
RedisConnectionFactory factory) {
StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, ObjectRecord<String, JobDto>> options =
StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder()
.pollTimeout(Duration.ofSeconds(1))
.targetType(JobDto.class)
.build();
StreamMessageListenerContainer<String, ObjectRecord<String, JobDto>> container =
StreamMessageListenerContainer.create(factory, options);
container.receive(
Consumer.from("workers", "consumer-1"),
StreamOffset.create("job-stream", ReadOffset.lastConsumed()),
this::processMessage
);
container.start();
return container;
}
private void processMessage(ObjectRecord<String, JobDto> record) {
try {
// 실제 처리
process(record.getValue());
// ACK
redisTemplate.opsForStream().acknowledge("job-stream", "workers", record.getId());
} catch (Exception e) {
// ACK 안 함 → PEL에 남음 → 나중에 XCLAIM으로 재처리
log.error("처리 실패: {}", record.getId(), e);
}
}
}
Q8. Consumer Group에서 처리 중 서버가 죽으면 메시지는 어떻게 되나?
XREADGROUP으로 메시지 10개를 가져갔다. 3개 처리하고 서버가 죽었다. 7개는?
유실되지 않는다. 7개는 PEL에 있다.
# 미처리 메시지 확인
XPENDING job-stream workers - + 10
# 출력: 메시지 ID, 컨슈머 이름, idle 시간(ms), 전달 횟수
idle 시간이 길다는 건 처리 중 죽었다는 신호다.
# 다른 컨슈머가 재처리 담당
XCLAIM job-stream workers consumer-2 60000 1718600000000-0
# 60000ms(1분) 이상 idle인 메시지를 consumer-2가 가져감
Spring Boot에서 재처리 스케줄러:
@Scheduled(fixedDelay = 60000) // 1분마다 실행
public void reclaimPendingMessages() {
// 1분 이상 idle인 미처리 메시지 조회
PendingMessagesSummary summary = redisTemplate.opsForStream()
.pending("job-stream", "workers");
if (summary.getTotalPendingMessages() == 0) return;
PendingMessages pending = redisTemplate.opsForStream().pending(
"job-stream",
Consumer.from("workers", "consumer-1"),
Range.unbounded(),
10L
);
for (PendingMessage msg : pending) {
if (msg.getIdleTime().toMinutes() >= 1) {
// 내 컨슈머로 XCLAIM
List<ObjectRecord<String, JobDto>> claimed = redisTemplate.opsForStream()
.claim("job-stream", "workers", "consumer-recovery",
Duration.ofMinutes(1), msg.getId());
for (ObjectRecord<String, JobDto> record : claimed) {
// 전달 횟수 확인: 너무 많으면 DLQ로
if (msg.getTotalDeliveryCount() > 5) {
sendToDlq(record);
redisTemplate.opsForStream()
.acknowledge("job-stream", "workers", record.getId());
} else {
processMessage(record);
}
}
}
}
}
MAXLEN 설정 필수
Stream에 XADD만 하고 지우지 않으면 무한정 쌓인다.
XADD job-stream MAXLEN ~ 10000 * task "send-email" ...
# ^
# ~ 는 approximate trimming
# 정확히 10000이 아니라 "대략 10000 이하"
# 성능을 위해 ~ 필수
~ 없이 MAXLEN 10000으로 쓰면 XADD마다 O(N) 트리밍이 발생한다. 스트림이 클수록 XADD가 느려진다. 반드시 ~를 붙인다.
Q9. 자료구조 선택을 잘못하면 얼마나 느려지나?
코드는 맞는데 특정 메서드가 너무 느리다. 자료구조가 문제일 수 있다.
실수 1: 랭킹에 List 사용
// 잘못된 방법: List로 정렬 유지
List<String> ranking = redisTemplate.opsForList().range("ranking", 0, -1);
// 삽입 위치 찾기 O(N) + 삽입 O(N)
10만 명 기준:
- List 삽입: 최악 O(N) = 10만 번 비교 + 이동
- Sorted Set ZADD: O(log N) = 약 17번 비교
초당 1000건 랭킹 업데이트라면:
- List: 초당 10만 × 1000 = 1억 번 연산 (Redis 블록)
- Sorted Set: 초당 17 × 1000 = 1만 7천 번 연산
실수 2: 멤버십 확인에 List 사용
// 잘못된 방법
List<String> processed = redisTemplate.opsForList().range("processed", 0, -1);
boolean isDuplicate = processed.contains(requestId); // O(N)
// 올바른 방법
Boolean isDuplicate = redisTemplate.opsForSet().isMember("processed", requestId); // O(1)
처리된 항목이 100만 개면: List contains는 평균 50만 번 비교, Set SISMEMBER는 1번.
실수 3: Stream 대신 List로 at-least-once
// RPOP: 꺼내는 순간 사라짐
String job = redisTemplate.opsForList().rightPop("job-queue");
// 서버 죽으면 job 유실
// 올바른 방법: LMOVE로 processing 큐로 이동 후 처리
String job = redisTemplate.opsForList().move(
"job-queue", ListOperations.Direction.RIGHT,
"processing-queue", ListOperations.Direction.LEFT
);
// 처리 완료 후
redisTemplate.opsForList().remove("processing-queue", 1, job);
실수 4: KEYS * 사용
KEYS user:* # Redis 프로세스가 수초간 블록
Redis는 싱글 스레드다. KEYS는 전체 키 공간을 스캔한다. 키가 1000만 개면 수초간 다른 커맨드를 처리 못 한다.
SCAN 0 MATCH user:* COUNT 100 # 100개씩 나눠서 비차단 스캔
주요 연산 시간복잡도
| 자료구조 | 연산 | 복잡도 |
|---|---|---|
| String | GET/SET | O(1) |
| Hash | HGET/HSET | O(1) |
| Hash | HGETALL | O(N) 필드 수 |
| List | LPUSH/RPOP | O(1) |
| List | LINDEX | O(N) |
| List | LINSERT | O(N) |
| Set | SADD/SISMEMBER | O(1) |
| Set | SMEMBERS | O(N) |
| Set | SINTER | O(N × M) |
| Sorted Set | ZADD | O(log N) |
| Sorted Set | ZRANK | O(log N) |
| Sorted Set | ZSCORE | O(1) |
| Sorted Set | ZRANGE | O(log N + K) |
| Stream | XADD | O(1) |
| Stream | XREAD | O(N) |
Q10. OBJECT ENCODING으로 뭘 확인하나? 왜 신경 써야 하나?
세션 Hash를 도입했다. 메모리 절감 기대했는데 효과가 없다.
Redis는 자료구조의 크기에 따라 내부 인코딩을 자동 전환한다. 전환 후가 전환 전보다 메모리를 3배 이상 쓰는 경우도 있다.
OBJECT ENCODING session:user:1
# "listpack" -- 메모리 효율 좋음
# "hashtable" -- 메모리 3배 이상 증가
자료구조별 인코딩 전환
| 자료구조 | 인코딩 1 | 인코딩 2 | 전환 조건 |
|---|---|---|---|
| String | int | embstr | 정수가 아닌 문자열 |
| String | embstr | raw | 44바이트 초과 |
| Hash | listpack | hashtable | 필드 128개 초과 또는 값 64바이트 초과 |
| List | listpack | quicklist | 항목 128개 초과 또는 값 64바이트 초과 |
| Set | intset | listpack | 정수 외 값 추가 또는 512개 초과 |
| Set | listpack | hashtable | 128개 초과 또는 값 64바이트 초과 |
| Sorted Set | listpack | skiplist | 128개 초과 또는 값 64바이트 초과 |
전환은 단방향이다. 조건을 해제해도 되돌아가지 않는다. 필드를 128개에서 100개로 줄여도 hashtable은 listpack으로 돌아오지 않는다.
임계값 튜닝
# redis.conf 또는 CONFIG SET
CONFIG SET hash-max-listpack-entries 64 # 기본 128에서 낮춤
CONFIG SET hash-max-listpack-value 32 # 기본 64에서 낮춤
임계값을 높이면 listpack을 더 오래 유지할 수 있다. 하지만 listpack은 O(N) 탐색이다. 필드가 많을수록 HGET 하나에 더 많은 비교가 필요하다. 300개까지 올리면 listpack이지만 HGET이 느려진다. 트레이드오프를 측정하고 결정해야 한다.
메모리 핫스팟 찾기
# 특정 키의 메모리 사용량
MEMORY USAGE session:user:1
# 인코딩 확인
OBJECT ENCODING session:user:1
# redis-cli로 메모리 많이 쓰는 키 Top N
redis-cli --memkeys --memkeys-samples 128
# 느린 커맨드 확인
SLOWLOG GET 10
# 최근 10개 slow query. 기본 임계값 10ms.
Spring Boot에서 모니터링:
@Scheduled(fixedDelay = 300000) // 5분마다
public void checkRedisMemory() {
Properties info = redisTemplate.execute(
(RedisCallback<Properties>) conn -> conn.info("memory")
);
long usedMemory = Long.parseLong(info.getProperty("used_memory"));
long maxMemory = Long.parseLong(info.getProperty("maxmemory"));
if (maxMemory > 0 && (double) usedMemory / maxMemory > 0.85) {
// 85% 이상이면 경고
alertService.send("Redis 메모리 85% 도달: " + usedMemory + "/" + maxMemory);
}
}
최적화 요약
| 상황 | 잘못된 선택 | 올바른 선택 |
|---|---|---|
| 유저 프로필 필드 단위 업데이트 | String × 필드 수 (키 폭발) | Hash HSET/HGET/HINCRBY |
| 작업 큐 폴링 | while + Thread.sleep | BLPOP (블로킹) |
| 작업 큐 at-least-once | RPOP (꺼내는 순간 유실) | LMOVE + processing 큐 |
| 실시간 랭킹 삽입 | List (O(N) 삽입) | Sorted Set (O(log N) ZADD) |
| 랭킹 점수 조회 | ZRANK (O(log N)) | ZSCORE (O(1)) |
| 멤버십 확인 | List LRANGE + contains (O(N)) | Set SISMEMBER (O(1)) |
| 공통 팔로워 | DB JOIN | Set SINTERCARD |
| 메시지 유실 없는 큐 | List (RPOP 시 소멸) | Stream (ACK 전 PEL 보관) |
| 전체 키 탐색 | KEYS * (Redis 블록) | SCAN MATCH |
| 대용량 필드 목록 | HGETALL (O(N) 전체) | HSCAN (청크 단위) |
튜닝 체크리스트
# 1. 인코딩 확인 — 예상과 다른 인코딩이면 임계값 체크
OBJECT ENCODING <key>
# 2. 메모리 사용량 확인
MEMORY USAGE <key>
# 3. 느린 커맨드 확인 (기본 임계값 10ms)
SLOWLOG GET 20
SLOWLOG LEN
# 4. 메모리 핫스팟 Top 20
redis-cli --memkeys --memkeys-samples 256 -n 0
# 5. 현재 Hash 임계값 설정 확인
CONFIG GET hash-max-listpack-entries
CONFIG GET hash-max-listpack-value
# 6. Sorted Set 임계값
CONFIG GET zset-max-listpack-entries
CONFIG GET zset-max-listpack-value
# 7. Stream PEL 확인 (미처리 메시지)
XPENDING <stream> <group> - + 10
# 8. Stream 길이 확인
XLEN <stream>
# 9. Redis 메모리 전체 상태
INFO memory
# 10. 키 수 확인
INFO keyspace
DBSIZE
운영상 주의사항
KEYS * 절대 금지
Redis는 싱글 스레드다. KEYS는 전체 키를 스캔한다. 키 100만 개면 수초간 블록된다. 그 사이 들어오는 모든 커맨드가 대기한다. 운영 환경에서 단 한 번의 KEYS *가 서비스 장애로 이어질 수 있다.
# 금지
KEYS user:*
# 대체
SCAN 0 MATCH user:* COUNT 100
# cursor가 0을 반환할 때까지 반복
LRANGE 0 -1 주의
List 전체를 한 번에 가져온다. List에 항목이 100만 개면 100만 개를 네트워크로 전송한다. 페이지 단위로 나눠서 가져와야 한다.
# 주의
LRANGE big-list 0 -1
# 청크 단위
LRANGE big-list 0 999
LRANGE big-list 1000 1999
Sorted Set 대범위 조회 주의
# 전체 조회: O(N)
ZRANGE leaderboard 0 -1 WITHSCORES
# 상위 100명만
ZRANGE leaderboard 0 99 REV WITHSCORES
범위가 클수록 O(K) 결과를 네트워크로 전송하는 비용이 크다. LIMIT을 항상 명시한다.
Hash 필드 128개 이하 유지
128개를 넘으면 hashtable로 전환된다. 세션 데이터나 유저 프로필이 128개를 넘길 것 같다면 미리 분리해야 한다. user:1:profile, user:1:settings, user:1:stats처럼 Hash를 나누는 것도 방법이다.
maxmemory-policy 설정
maxmemory에 도달했을 때 어떻게 할지 설정하지 않으면 기본값인 noeviction이다. 새 키를 쓸 수 없게 된다. 캐시 용도라면 allkeys-lru, 중요 데이터가 섞여 있다면 volatile-lru를 검토해야 한다.
CONFIG GET maxmemory-policy
CONFIG SET maxmemory-policy allkeys-lru