Redis 핵심 정리 시리즈 9편 — 시리즈 완결. Redis가 본래 빠른 도구라도 잘못 쓰면 성능이 무너지는 자리들을 한 편에 정리. 파이프라이닝·연결 풀·메모리 최적화·블로킹 명령 회피·SLOWLOG·redis-benchmark·Lua 스크립트·자료 구조 선택 가이드까지 — 시리즈 마지막 편을 따뜻한 마무리와 함께.
이 글은 Redis 핵심 정리 시리즈의 아홉 번째이자 마지막 편입니다. 1편의 책상 위 메모리 사물함부터 시작해 — 자료 구조 7종, 명령어, 영속성, 캐싱 패턴, Pub/Sub & Streams, 클러스터·고가용성, Spring Data Redis까지 — 이번에 9편으로 매듭을 짓습니다. 여기까지 와줘서 정말 고마워요. 시리즈 안내 한 줄로 글을 시작했던 게 엊그제 같은데, 마지막 편 도입부에 와 있다는 게 새삼 뿌듯합니다.
마지막 단원이 성능 최적화인 이유는 분명해요. 1편부터 8편까지 Redis가 어떤 도구인지·어떤 기능을 가졌는지를 쌓아 왔다면, 9편은 그 도구가 본래 속도로 일하도록 다듬는 자리예요. Redis는 본질적으로 빠르지만, 잘못된 사용 패턴은 그 빠름을 너무 쉽게 무너뜨립니다. 그 함정들을 한 편에 모아 두는 게 이번 글의 목적이에요.
본문 흐름은 책상 위 메모리 사물함 비유를 그대로 따라 갑니다. 1편에서 시작했던 그림 — 디스크 창고 대신 책상 옆 사물함 — 위에서, 이번에는 사물함 사용 습관을 다듬는 단계예요.
왜 성능 단원이 처음엔 어렵게 느껴질까요
이유는 세 가지예요.
첫째, "Redis는 어차피 빠르지 않나"라는 인식이 깔려 있습니다. Redis가 100,000 ops/sec를 넘기는 도구라고 1편에서 강조했는데, 갑자기 "잘못 쓰면 느려진다"는 이야기가 나오면 어색해요. "그럴 만큼 잘못 쓸 일이 있나?" 하는 의문이 자연스럽게 듭니다.
둘째, 병목 지점이 한 군데가 아닙니다. 네트워크 왕복(RTT), 직렬화/역직렬화, 메모리 할당, 블로킹 명령, 연결 오버헤드 — 다섯 군데가 한꺼번에 머리에 들어오면 혼란스러워요.
셋째, 숫자와 도구가 너무 많이 등장합니다. slowlog-log-slower-than 10000, tcp-backlog 511, hash-max-listpack-entries 128, io-threads 4 — 한 페이지에 줄지어 나오면 "이걸 다 외워야 하나" 싶죠.
해결법은 한 가지예요. 다섯 가지 병목을 분리해서 잡으면 갑자기 명확해집니다. 네트워크는 파이프라이닝으로 줄이고, 연결 오버헤드는 연결 풀로 없애고, 메모리는 자료 구조 인코딩으로 줄이고, 느린 쿼리는 SLOWLOG로 잡고, 블로킹 명령은 SCAN으로 우회 — 이 다섯 가지 자리만 머리에 두면 90%가 정리돼요. 이 글은 그 다섯 자리를 차근차근 풀어 갑니다.
성능 병목은 어디에 숨어 있나요
먼저 병목 지점부터 한 그림에 모아 두면 흐름이 잡혀요.
성능 병목 지점:
1. 네트워크 왕복 (RTT): 명령어 수 최소화
2. 직렬화/역직렬화: 간단한 직렬화 사용
3. 메모리 할당: 메모리 정책 최적화
4. 블로킹 명령어: KEYS *, HGETALL(대형) 등 회피
5. 연결 오버헤드: 연결 풀링으로 해결
회사 비유로 풀면 — Redis는 책상 옆 메모리 사물함이지만, 봉투를 한 번에 한 개씩만 꺼내면 책상까지 왕복하는 시간이 더 길어져요. 묶음으로 꺼내고(파이프라이닝), 케이블을 미리 깔아 두고(연결 풀), 사물함을 잘 정리해 두면(메모리 최적화), 본래 속도가 살아납니다.
핵심을 한 줄로 정리하면 — "Redis 자체가 느린 게 아니라 사용 패턴이 느리게 만든다" 입니다. 더 깊은 사양은 Redis 공식 최적화 가이드에서 확인할 수 있어요.
파이프라이닝 — 주문을 한꺼번에
가장 큰 효과를 내는 자리부터 시작합니다. 파이프라이닝은 여러 명령어를 하나의 요청에 묶어 보내는 기법이에요.
회사 비유로 풀면 — 1편에서 봤듯 Redis 명령은 요청-응답 왕복으로 일해요. 100개 명령어를 한 번에 한 개씩 보내면 100번 왕복합니다. 파이프라이닝은 그 100개를 한 봉투에 묶어 한 번에 주문하고, 답도 한 번에 받는 거예요. 점심 주문 받으러 한 사람씩 100번 왔다 갔다 하지 말고, 종이에 100명 주문 다 적어서 한 번에 가져오는 식이죠.
왕복 횟수가 곧 시간
순차 실행 (느림):
클라이언트 → SET key1 value1 → 서버
클라이언트 ← OK ← 서버
클라이언트 → SET key2 value2 → 서버
클라이언트 ← OK ← 서버
... (N번 왕복)
파이프라이닝 (빠름):
클라이언트 → SET key1 + SET key2 + ... → 서버
클라이언트 ← OK, OK, ... ← 서버
(1번 왕복으로 N개 처리)
로컬 환경에서 RTT가 1ms라면, 100개 명령어 순차 실행 시 100ms가 걸립니다. 같은 명령어를 파이프라이닝으로 묶으면 약 1ms에 끝나요. 약 100배 차이입니다. 클라우드 환경에서 RTT가 더 길수록 파이프라이닝 효과는 더 커져요.
node-redis에서 파이프라이닝
// 잘못된 방법: 순차 await (각각 네트워크 왕복)
async function slowBatchUpdate(items: Item[]) {
for (const item of items) {
await client.hSet(`items#${item.id}`, item); // 각 명령마다 RTT
}
}
// 올바른 방법 1: Promise.all (동시 실행)
async function fastBatchUpdate(items: Item[]) {
await Promise.all(
items.map(item => client.hSet(`items#${item.id}`, item))
);
}
// 올바른 방법 2: multi()로 파이프라이닝
async function pipelinedBatchUpdate(items: Item[]) {
const pipeline = client.multi();
for (const item of items) {
pipeline.hSet(`items#${item.id}`, item);
}
const results = await pipeline.exec();
return results;
}
여기서 시험 함정이 하나 있어요. Promise.all과 multi() 파이프라이닝은 다른 개념입니다. Promise.all은 여러 명령을 동시에 보내지만 서버에서는 여전히 N개 요청으로 처리돼요(연결 풀 또는 멀티플렉싱 사용). multi()는 클라이언트가 한 봉투에 묶어 보내 서버가 실제 1번에 처리합니다. RTT 측면에서 둘 다 빨라지지만, 진짜 1번 왕복은 multi()예요.
성능 비교 — 숫자로 보기
// 성능 벤치마크 예시
async function benchmark() {
const N = 1000;
// 순차 실행
console.time('sequential');
for (let i = 0; i < N; i++) {
await client.set(`key:${i}`, `value:${i}`);
}
console.timeEnd('sequential'); // ~500ms (RTT 0.5ms 가정)
// Promise.all (동시 실행)
console.time('parallel');
await Promise.all(
Array.from({ length: N }, (_, i) =>
client.set(`key:${i}`, `value:${i}`)
)
);
console.timeEnd('parallel'); // ~5ms (연결 풀 사용)
// multi() 파이프라이닝
console.time('pipeline');
const pipeline = client.multi();
for (let i = 0; i < N; i++) {
pipeline.set(`key:${i}`, `value:${i}`);
}
await pipeline.exec();
console.timeEnd('pipeline'); // ~2ms (단일 왕복)
}
순차 실행 대비 약 100~250배 차이가 납니다. 운영 환경에서 가장 큰 성능 개선을 가져오는 단일 변경이라고 할 수 있어요.
> 한 줄 정리 — 파이프라이닝 = 묶음 주문. 1000개 명령을 1번 왕복으로 처리. 가장 큰 효과를 내는 단일 최적화.
연결 풀링 — 케이블을 미리 깔아 두기
Redis 연결을 만드는 데는 TCP 핸드셰이크 + 인증 오버헤드가 있어요. 요청마다 새 연결을 만들면 그 오버헤드가 매번 누적됩니다.
회사 비유로 풀면 — 연결 풀은 사물함과 책상 사이에 케이블을 미리 깔아 두는 거예요. 요청이 들어올 때마다 케이블을 새로 까는 게 아니라, 미리 깔린 케이블 중 비어 있는 걸 빌려 쓰고 끝나면 반납합니다.
풀이 없을 때 vs 있을 때
연결 풀 없이:
요청1 → 연결 생성 → 명령어 → 연결 해제
요청2 → 연결 생성 → 명령어 → 연결 해제
(매번 TCP 핸드셰이크 ~20ms 소요)
연결 풀 사용:
초기화 → 연결 5개 미리 생성
요청1 → 연결1 사용 → 풀에 반환
요청2 → 연결2 사용 → 풀에 반환
(연결 재사용 ~0.1ms)
핸드셰이크 한 번이 약 20ms, 풀에서 빌리는 비용은 0.1ms — 약 200배 차이예요. 풀 설정 한 줄이 트래픽 많은 환경에서 결정적 차이를 만듭니다.
Lettuce 연결 풀 설정
# application.yml (Spring Boot)
spring:
data:
redis:
lettuce:
pool:
max-active: 20 # 최대 활성 연결 수 (동시 요청 수에 맞게)
max-idle: 10 # 최대 유휴 연결 수
min-idle: 5 # 최소 유휴 연결 수 (항상 대기 중인 연결)
max-wait: 100ms # 연결 대기 최대 시간
time-between-eviction-runs: 60s # 유휴 연결 검사 주기
여기서 시험 함정이 하나 있어요. max-active는 동시 요청 수에 맞게 잡아야 합니다. 너무 작으면(기본 8) 트래픽 많은 환경에서 빠르게 고갈되고, 너무 크면(100+) 메모리 낭비예요. 평균 동시 요청 수의 1.5~2배가 안전한 시작점이에요.
node-redis 연결 설정
// node-redis는 기본적으로 단일 연결 사용
// 멀티플렉싱으로 효율적 처리
const client = createClient({
socket: {
host: 'localhost',
port: 6379,
connectTimeout: 5000, // 연결 타임아웃 5초
keepAlive: 5000, // 5초 간격으로 keepalive
reconnectStrategy: (retries) => {
if (retries > 10) return new Error('Max retries exceeded');
return Math.min(retries * 100, 3000); // 지수 백오프
},
},
password: 'your_password',
});
// 연결 에러 처리
client.on('error', (err) => console.error('Redis Client Error:', err));
client.on('reconnecting', () => console.log('Redis reconnecting...'));
client.on('ready', () => console.log('Redis connected!'));
잠깐, 이 부분이 헷갈리는데 — node-redis는 풀이 아니라 단일 연결 + 멀티플렉싱으로 동작해요. 한 연결에서 여러 명령을 동시에 처리합니다. Java 측 Lettuce도 비슷해요(Netty 기반). Jedis만 풀이 필수입니다(동기 I/O).
메모리 최적화 — 사물함을 정리하기
Redis는 자료 크기에 따라 자동으로 내부 인코딩을 바꿉니다. 데이터가 작을 때는 압축된 형식(ziplist/listpack)으로 메모리를 아끼고, 커지면 성능 위주의 형식(hashtable/skiplist)으로 전환해요.
자료 구조별 인코딩 자동 전환
Hash:
데이터 적음 (기본: 128개 필드, 64바이트 이하) → ziplist (메모리 효율)
데이터 많음 → hashtable (성능 위주)
List:
데이터 적음 → listpack (Redis 7.0+) 또는 ziplist
데이터 많음 → quicklist (여러 ziplist의 연결 리스트)
Set:
정수만 있고 적음 (기본: 512개 이하) → intset (메모리 효율)
그 외 → hashtable
Sorted Set:
데이터 적음 (기본: 128개, 64바이트 이하) → listpack
데이터 많음 → skiplist + hashtable
여기서 정말 중요한 시험 함정 — 자료를 너무 크게 만들면 갑자기 메모리 사용량이 뛸 수 있어요. 예를 들어 Hash의 필드 수가 128을 살짝 넘는 순간, ziplist에서 hashtable로 전환되면서 메모리가 몇 배로 늘어납니다. 자료를 한 키에 다 몰아넣지 말고, 샤딩 키 패턴(users#1000~1999, users#2000~2999 같은 식)으로 쪼개는 게 표준이에요.
인코딩 임계치 튜닝
# redis.conf 설정
# Hash: ziplist/listpack 사용 조건
hash-max-listpack-entries 128 # 128개 이하 필드
hash-max-listpack-value 64 # 값 64바이트 이하
# List: listpack 사용 조건
list-max-listpack-size -2 # 최대 8KB
list-compress-depth 1 # 양쪽 1개 노드만 압축 해제
# Set: intset 사용 조건
set-max-intset-entries 512 # 512개 이하 정수
# ZSet: listpack 사용 조건
zset-max-listpack-entries 128
zset-max-listpack-value 64
# 현재 키의 인코딩 확인
OBJECT ENCODING some-key
# hash (hashtable), ziplist, listpack, intset, skiplist 등
키 설계로 메모리 절약
// 긴 키는 메모리를 더 사용
// 잘못된 방법: 긴 키 이름
await client.set('user:account:session:token:authentication:123', value);
// 올바른 방법: 짧고 의미 있는 키
await client.set('sess#123', value);
// 키 네이밍 가이드:
// - 설명적이면서 간결하게
// - 회사/팀의 표준 컨벤션 따르기
// - 너무 짧게 줄여서 의미를 잃지 않도록
키 자체도 메모리를 차지합니다. 100만 개 키에서 키 하나가 50바이트 길면 — 그것만으로 50MB가 사라져요. 한 번 정한 키 패턴은 운영 중 바꾸기 어려우니, 처음부터 짧고 의미 있게 짜 두는 게 표준이에요.
성능 측정 — redis-benchmark와 --latency
성능 최적화의 출발점은 측정이에요. 측정 없이 추측으로 바꾸면 거의 항상 다른 자리에 영향을 줘요.
redis-benchmark — 부하 측정 도구
# 기본 벤치마크 실행
redis-benchmark
# 커스텀 벤치마크
redis-benchmark -h localhost -p 6379 \
-c 50 \ # 동시 클라이언트 50개
-n 100000 \ # 총 요청 수 100,000
-d 100 \ # 데이터 크기 100 bytes
-t set,get \ # SET과 GET만 테스트
--csv # CSV 형식 출력
# 결과 예시
# SET: 85,000 ops/sec (초당 85,000 SET 처리)
# GET: 95,000 ops/sec
# 파이프라이닝 테스트
redis-benchmark -P 16 -n 1000000 set key:__rand_int__ value
# -P 16: 16개 명령어를 한 번에 파이프라이닝
-P 16 플래그를 켜고 안 켜고를 비교해 보면 — 파이프라이닝 효과가 숫자로 한눈에 들어와요. 환경에 따라 차이는 다르지만, 보통 2~10배 차이가 납니다. 자세한 사양은 Redis 벤치마크 공식 가이드에 정리돼 있어요.
--latency — 레이턴시 분포
# 레이턴시 측정
redis-cli --latency -h localhost -p 6379
# 레이턴시 히스토리 (실시간)
redis-cli --latency-history -i 1 # 1초 간격
# 레이턴시 통계 분포
redis-cli --latency-dist
p50, p99, max 같은 분포가 보입니다. 평균(mean)만 보면 안 돼요 — 운영에서 중요한 건 p99(99번째 백분위)입니다. 평균 1ms여도 p99가 100ms이면 사용자 1%는 100ms를 기다리고 있는 거예요.
INFO — 운영 지표
# 성능 관련 주요 지표
redis-cli INFO stats
# 주요 확인 항목:
instantaneous_ops_per_sec:12345 # 현재 초당 처리 명령어 수
total_commands_processed:1234567 # 총 처리 명령어 수
keyspace_hits:45678 # 캐시 히트 수
keyspace_misses:1234 # 캐시 미스 수
rejected_connections:0 # 거부된 연결 수 (0이어야 함)
blocked_clients:0 # 블로킹 명령어로 대기 중인 클라이언트
# 메모리 지표
redis-cli INFO memory
used_memory_human:128.5M # 실제 사용 메모리
mem_fragmentation_ratio:1.2 # 단편화 비율 (1.0-1.5 정상)
여기서 시험 함정이 하나 있어요. mem_fragmentation_ratio가 1.5를 넘으면 메모리 단편화 의심이에요. 한 번 MEMORY PURGE로 정리하거나, 그래도 줄지 않으면 Redis 재시작이 필요할 수 있습니다. rejected_connections가 0이 아니면 max-clients 설정이 부족한 신호예요.
> 한 줄 정리 — 측정은 redis-benchmark + --latency + INFO 세 도구. 평균 대신 p99를 보는 습관이 운영 차이를 만든다.
느린 쿼리 추적 — SLOWLOG
운영 중에 가끔 느린 명령이 섞일 때 — SLOWLOG가 답이에요. 일정 시간 이상 걸린 명령을 자동으로 기록합니다.
설정과 조회
# 10ms 이상 걸리는 명령어 기록
redis-cli CONFIG SET slowlog-log-slower-than 10000 # 마이크로초 단위 (10000μs = 10ms)
redis-cli CONFIG SET slowlog-max-len 128 # 최대 128개 기록
# redis.conf에서 영구 설정
slowlog-log-slower-than 10000
slowlog-max-len 128
# 느린 쿼리 조회
redis-cli SLOWLOG GET 10 # 최근 10개
# 결과 예시:
# 1) 1) (integer) 14 # 슬로우로그 ID
# 2) (integer) 1699900000 # Unix 타임스탬프
# 3) (integer) 25000 # 실행 시간 (마이크로초): 25ms
# 4) 1) "KEYS" # 실행된 명령어
# 2) "*"
# 5) "127.0.0.1:54321" # 클라이언트 IP:Port
# 6) "" # 클라이언트 이름
# 느린 쿼리 수 확인
redis-cli SLOWLOG LEN
# 초기화
redis-cli SLOWLOG RESET
운영 환경에서는 하루에 한 번 이상 SLOWLOG를 점검하는 습관이 표준이에요. KEYS * 같은 블로킹 명령어가 한 번만 들어가도 그 순간 다른 모든 요청이 막히니까요.
블로킹 명령 회피 — KEYS * 대신 SCAN
1편에서도 강조했지만, 한 번 더 짚고 갑니다 — **KEYS *는 운영 환경에서 절대 금지**예요.
KEYS * 대신 SCAN
# 절대 사용 금지 (운영 환경)
KEYS * # 전체 키 조회 → Redis 전체 블로킹!
KEYS user:* # 패턴 일치 → 여전히 블로킹!
# 올바른 방법: SCAN (커서 기반 반복, 논블로킹)
SCAN 0 COUNT 100 # 처음 100개
SCAN <cursor> COUNT 100 # 다음 페이지
SCAN 0 MATCH user:* COUNT 100 # 패턴 필터링
# SCAN은 완전한 결과를 보장하지 않음 (cursor가 0으로 돌아올 때까지 반복)
KEYS *는 단일 스레드 Redis를 통째로 멈춥니다. 100만 키가 있으면 그 동안 아무 요청도 못 받아요. SCAN은 커서 기반으로 페이지 단위로 잘라서 가져오니까 다른 요청을 막지 않습니다.
TypeScript에서 SCAN 사용
// 패턴으로 키 조회 (SCAN 사용)
async function scanByPattern(pattern: string): Promise<string[]> {
const keys: string[] = [];
let cursor = 0;
do {
const result = await client.scan(cursor, {
MATCH: pattern,
COUNT: 100, // 한 번에 약 100개씩 반환 요청
});
cursor = result.cursor;
keys.push(...result.keys);
} while (cursor !== 0); // cursor가 0으로 돌아오면 완료
return keys;
}
// 사용
const userKeys = await scanByPattern('users#*');
const sessionKeys = await scanByPattern('sessions#*');
do-while로 cursor가 0으로 돌아올 때까지 반복하는 게 표준 패턴이에요. SCAN은 결과 중복이 있을 수 있으니, 결과를 Set으로 받아 중복을 거르는 것도 좋은 습관입니다.
HGETALL 대형 Hash 주의
# 필드가 수천 개인 Hash에서 HGETALL 위험
HGETALL big-hash-with-10000-fields # 모든 필드 반환 → 네트워크/메모리 부담
# 올바른 방법: 필요한 필드만 조회
HGET hash-key specific-field # 특정 필드 하나
HMGET hash-key field1 field2 field3 # 여러 특정 필드
# 또는 HSCAN으로 페이지 단위 조회
HSCAN hash-key 0 COUNT 100
여기서 시험 함정이 하나 있어요. HGETALL은 작은 Hash에서는 빠르지만, 수천 필드 Hash에서는 블로킹입니다. 운영에서 Hash 크기를 모를 때는 HMGET으로 필요한 필드만 가져오는 게 안전해요. 큰 Hash라면 HSCAN으로 페이지 단위 처리.
직렬화 성능 — JSON vs 바이너리
저장하는 데이터 크기 자체가 네트워크와 메모리 사용량에 직접 영향을 줘요. 직렬화 방식 선택이 중요한 자리입니다.
// 벤치마크: JSON vs MessagePack vs Protocol Buffers
// JSON (간단, 사람이 읽을 수 있음)
const data = { id: '123', name: 'Alice', age: 30 };
const json = JSON.stringify(data); // {"id":"123","name":"Alice","age":30}
// 크기: 약 40 bytes
// MessagePack (더 작은 바이너리)
import { pack, unpack } from 'msgpackr';
const packed = pack(data);
// 크기: 약 25 bytes (약 37% 절약)
// Protocol Buffers (가장 효율적, 스키마 필요)
// 크기: 약 15 bytes
직렬화 전략
// 작은 데이터: String 직렬화 (가장 빠름)
await client.set('counter:items', '1234');
// 중간 데이터: JSON (균형)
await client.set('users#123', JSON.stringify(userData));
// 큰 데이터: 압축 + JSON
import { gzipSync, gunzipSync } from 'zlib';
async function setCompressed(key: string, data: object) {
const json = JSON.stringify(data);
const compressed = gzipSync(Buffer.from(json));
await client.set(key, compressed.toString('base64'), { EX: 3600 });
}
async function getCompressed(key: string): Promise<object | null> {
const compressed = await client.get(key);
if (!compressed) return null;
const decompressed = gunzipSync(Buffer.from(compressed, 'base64'));
return JSON.parse(decompressed.toString());
}
규칙은 단순해요 — 작은 데이터엔 String, 중간 데이터엔 JSON, 큰 데이터엔 압축. 데이터 1KB 이상이면 압축 효과가 분명해지고, 100바이트 이하면 압축 오버헤드가 더 클 수 있어요.
Lua 스크립트 — 원자성과 RTT 동시 해결
여러 명령을 묶어 원자적으로 처리하면서 RTT도 1번으로 줄이는 도구가 Lua 스크립트예요. WATCH/MULTI를 대체하는 우아한 방법입니다.
스크립트 캐싱 — SCRIPT LOAD + EVALSHA
// SCRIPT LOAD: 스크립트를 서버에 로드하고 SHA1 반환
const luaScript = `
local current = redis.call('INCR', KEYS[1])
if current > tonumber(ARGV[1]) then
redis.call('SET', KEYS[1], 0)
return 0
end
return current
`;
// 최초 한 번만 로드
const sha1 = await client.scriptLoad(luaScript);
// sha1: "abc123def456..."
// 이후 SHA1으로 실행 (스크립트 전송 불필요)
const result = await client.evalSha(sha1, {
keys: ['counter:daily'],
arguments: ['100'],
});
EVAL은 매번 스크립트 전체를 보내고, EVALSHA는 SHA1만 보내요. 같은 스크립트를 자주 쓴다면 SCRIPT LOAD 한 번 + EVALSHA 반복이 표준이에요.
WATCH/MULTI 대체
// WATCH/MULTI 방식 (재시도 필요할 수 있음)
async function incrementIfLess_WATCH(key: string, max: number) {
for (let i = 0; i < 3; i++) {
await client.watch(key);
const current = parseInt(await client.get(key) || '0');
if (current >= max) { await client.unwatch(); return current; }
const multi = client.multi();
multi.incr(key);
const result = await multi.exec();
if (result !== null) return result[0];
}
}
// Lua 스크립트 방식 (원자적, 재시도 불필요)
const sha1 = await client.scriptLoad(`
local val = tonumber(redis.call('GET', KEYS[1])) or 0
if val >= tonumber(ARGV[1]) then
return val
end
return redis.call('INCR', KEYS[1])
`);
async function incrementIfLess_Lua(key: string, max: number) {
return client.evalSha(sha1, { keys: [key], arguments: [max.toString()] });
}
여기서 정말 중요한 시험 함정 — WATCH/MULTI는 낙관적 락이라 재시도가 필요할 수 있지만, Lua 스크립트는 단일 스레드 Redis에서 원자적이라 재시도가 없습니다. 코드도 짧아지고 성능도 더 좋아요. 단, Lua 스크립트가 너무 길면(50ms 이상) 단일 스레드를 점유하니 짧고 빠르게 짜는 게 원칙이에요.
자료 구조 선택 가이드 — 시간 복잡도
성능은 결국 올바른 자료 구조 선택에서 시작해요. 사용 사례별 매핑은 외워 두면 두고두고 써요.
사용 사례별 최적 자료 구조
| 사용 사례 | 최적 자료구조 | 이유 |
|---|---|---|
| 카운터 | String (INCR) | O(1) 원자적 증감 |
| 사용자 프로필 | Hash | 필드별 독립 접근 |
| 최근 활동 N개 | List | O(1) 양쪽 추가/제거 |
| 태그/관계 | Set | O(1) 추가/조회, 집합 연산 |
| 리더보드 | Sorted Set | O(log N) 정렬 유지 |
| 실시간 통계 | HyperLogLog | 고유 방문자 추정, 12KB |
| 이벤트 스트림 | Stream | 순서 보장, Consumer Group |
| 분산 큐 | List (LPUSH/RPOP) | FIFO 큐 |
| 우선순위 큐 | Sorted Set | 스코어 기반 우선순위 |
| 캐시 | String 또는 Hash | 단순 캐싱 |
| 세션 | Hash | 여러 필드 한 번에 접근 |
시간 복잡도 한 그림
String:
GET/SET: O(1)
APPEND: O(1)
INCR/DECR: O(1)
Hash:
HGET/HSET: O(1)
HGETALL: O(N) - N: 필드 수
HMGET: O(N) - N: 요청 필드 수
List:
LPUSH/RPUSH/LPOP/RPOP: O(1)
LRANGE: O(S+N) - S: 시작 오프셋, N: 범위
LINDEX: O(N) - 중간 접근 비효율
Set:
SADD/SREM/SISMEMBER: O(1)
SMEMBERS: O(N) - 전체 반환
SINTER/SUNION: O(N×M) - 집합 연산
Sorted Set:
ZADD: O(log N)
ZSCORE/ZRANK: O(log N)
ZRANGE: O(log N + M) - M: 반환 요소 수
ZRANGEBYSCORE: O(log N + M)
여기서 시험 함정이 하나 있어요. SMEMBERS·HGETALL은 O(N)입니다. 작은 Set/Hash에는 괜찮지만, 큰 자료에는 위험해요. 큰 자료에는 SSCAN·HSCAN 으로 페이지 단위 처리. LINDEX 중간 인덱스도 List에서는 O(N)이라 비효율이에요. 큐 용도라면 양쪽 끝(LPUSH/RPOP)만 쓰는 게 정답입니다.
메모리 분석 — MEMORY USAGE와 --bigkeys
큰 키가 어디 숨어 있는지 모를 때 — Redis가 직접 알려 줘요.
MEMORY 명령
# 특정 키의 메모리 사용량 (bytes)
MEMORY USAGE users#123
# (integer) 256
# 샘플 수 조정 (중첩 구조에서 정확도 향상)
MEMORY USAGE users#123 SAMPLES 5
# 메모리 단편화 해소
MEMORY PURGE
# 메모리 정보 요약
MEMORY DOCTOR
# 메모리 관련 경고 및 권장 사항 제공
MEMORY STATS
# 상세 메모리 통계
MEMORY DOCTOR는 운영자에게 자동으로 권고를 줘요. 단편화·메모리 정책 누락·큰 키 등 — 정기적으로 확인하면 문제를 미리 잡을 수 있어요.
--bigkeys — 가장 큰 키 찾기
# 가장 큰 키 분석 (운영 환경 사용 주의: SCAN 기반이지만 시간 소요)
redis-cli --bigkeys
# 결과 예시:
# Biggest string found 'user:profile:12345' has 4567 bytes
# Biggest list found 'events:2023' has 100000 items
# Biggest hash found 'product:catalog' has 5000 fields
--bigkeys는 SCAN 기반이라 운영 환경에서도 안전하게 쓸 수 있어요(블로킹 X). 그래도 시간이 좀 걸리니, 트래픽이 한가한 시간대에 돌리는 게 좋아요.
설정 최적화 — redis.conf 핵심 항목
자주 만지는 설정 몇 가지를 정리합니다.
TCP 설정
# redis.conf
tcp-backlog 511 # TCP 연결 큐 크기 (고트래픽 시 증가)
tcp-keepalive 300 # 5분마다 keepalive 패킷
# 리눅스 커널 파라미터
sysctl vm.overcommit_memory=1 # 메모리 오버커밋 허용
sysctl net.core.somaxconn=65535 # 소켓 연결 큐 크기
sysctl net.ipv4.tcp_max_syn_backlog=65535
영속성 비활성화 — 순수 캐시 용도
# 순수 캐시 용도: 영속성 비활성화 (성능 향상)
# redis.conf
save "" # RDB 스냅샷 비활성화
appendonly no # AOF 비활성화
# 주의: 데이터 손실 허용하는 경우에만 사용
# 세션, 임시 캐시 등의 휘발성 데이터에 적합
영속성을 끄면 RDB·AOF 디스크 I/O가 사라져 성능이 한 단계 더 뛰어요. 단, 재시작 시 데이터가 모두 사라진다는 트레이드오프가 분명합니다. 캐시 전용 인스턴스에만 적용해요.
Redis I/O 멀티스레딩
# Redis 6.0+ I/O 멀티스레딩
io-threads 4 # I/O 처리 스레드 수 (CPU 코어 수의 절반 권장)
io-threads-do-reads yes # 읽기도 멀티스레드로 처리
# 주의: 명령어 실행은 여전히 단일 스레드
# I/O만 병렬화됨
여기서 시험 함정이 하나 있어요. Redis 6.0 멀티스레딩은 I/O 처리만 병렬화하고, 명령어 실행은 여전히 단일 스레드예요. CPU 다중 코어를 명령어 처리에 쓰려면 클러스터(7편)로 가야 합니다. 단일 노드에서 io-threads로 늘릴 수 있는 건 네트워크 I/O 부분이에요.
실전 사례 두 가지
이론을 정리했으니, 실제 운영에서 자주 만나는 두 가지 사례를 보고 갑시다.
사례 1 — N+1 Redis 호출
// 문제: 아이템 목록 조회 시 N번 추가 조회
async function getItemsWithDetails_SLOW(itemIds: string[]) {
const items = [];
for (const id of itemIds) {
const item = await client.hGetAll(`items#${id}`); // N번 호출
const views = await client.pfCount(`items:views#${id}`); // N번 호출
items.push({ ...item, views });
}
return items; // 2N번의 Redis 호출!
}
// 해결: 파이프라이닝으로 배치 처리
async function getItemsWithDetails_FAST(itemIds: string[]) {
const pipeline = client.multi();
// 모든 명령어를 파이프라인에 추가
for (const id of itemIds) {
pipeline.hGetAll(`items#${id}`);
pipeline.pfCount(`items:views#${id}`);
}
const results = await pipeline.exec(); // 1번의 Redis 통신
// 결과 파싱 (2개씩 묶음)
const items = [];
for (let i = 0; i < results.length; i += 2) {
const item = results[i] as Record<string, string>;
const views = results[i + 1] as number;
items.push({ ...item, views });
}
return items;
}
아이템 100개 조회가 200번 RTT에서 1번 RTT로 줄어요. 운영 환경에서 가장 자주 만나는 패턴이라 외워 두면 좋습니다.
사례 2 — SORT로 서버 사이드 정렬
# 문제: 아이템 ID 목록을 점수로 정렬하여 상세 정보 가져오기
# 방법 1: 애플리케이션에서 처리 (N+1 문제)
SMEMBERS items:all # 모든 아이템 ID 가져오기
# 각 ID별로 HGET items#ID score ... # N번 호출
# 방법 2: SORT 명령어 (서버 사이드 정렬 + 데이터 조회)
SORT items:all
BY items#*->score DESC # items#<ID>의 score 필드로 정렬
GET items#*->name # items#<ID>의 name 필드 가져오기
GET items#*->price # items#<ID>의 price 필드 가져오기
GET # # 원본 ID도 포함
LIMIT 0 10 # 상위 10개
# 단 1번의 명령어로 정렬 + 조회 완료!
SORT BY ... GET ... 패턴은 잘 알려져 있지 않은데, 이커머스 마켓플레이스 예시 같이 정렬+조회를 자주 하는 환경에서 결정적 차이를 만들어요. 한 번 익혀 두면 큰 무기가 됩니다.
흔한 실수 5가지
여기까지 따라오셨다면 한 가지 의문이 들 거예요 — "그럼 운영에서 자주 터지는 함정은?" 다섯 가지로 정리합니다.
1. 파이프라이닝으로 중간 결과 못 본다
// 주의: MULTI 내에서 중간 결과를 확인할 수 없음
const multi = client.multi();
multi.get('key'); // 실제 값이 아닌 queued 상태
multi.set('key', 'new-value');
const results = await multi.exec();
// results[0]: 실제 GET 결과 (MULTI 실행 전 상태)
// 중간 결과가 필요하면 WATCH + MULTI/EXEC 패턴 또는 Lua 스크립트 사용
2. 대형 값 통째 저장
// 잘못된 방법: 큰 JSON을 통째로 저장
const allProducts = await db.getAllProducts(); // 10,000개 상품
await client.set('all-products', JSON.stringify(allProducts));
// → Redis는 이 명령어를 처리하는 동안 다른 요청 처리 불가!
// 올바른 방법: 작은 단위로 분리
for (const product of products) {
await client.hSet(`products#${product.id}`, product);
}
// 또는 목록은 ID만 저장하고 상세는 별도
await client.sAdd('products:ids', products.map(p => p.id));
3. Sorted Set 스코어 타입 제한
// 잘못된 방법: 문자열 ID를 스코어로 직접 사용
await client.zAdd('users:byname', {
value: 'alice',
score: 'abc123' // 에러! 스코어는 숫자여야 함
});
// 올바른 방법: 16진수 ID를 정수로 변환
const hexId = 'abc123def456';
const score = parseInt(hexId.slice(0, 8), 16); // 32비트 정수로 변환
await client.zAdd('users:byname', {
value: 'alice',
score: score
});
4. 연결 누수
// 잘못된 방법: 연결 해제 안 함
const tempClient = createClient({ ... });
await tempClient.connect();
await tempClient.set('key', 'value');
// tempClient.disconnect() 누락 → 연결 누수
// 올바른 방법: try-finally로 반드시 해제
const tempClient = createClient({ ... });
await tempClient.connect();
try {
await tempClient.set('key', 'value');
} finally {
await tempClient.disconnect();
}
5. Pub/Sub 채널 정리 누락
// 잘못된 방법: 구독 후 해제 안 함
await subscriber.subscribe('channel', handler);
// 서비스 종료 시 unsubscribe 안 함 → 연결 유지, 메모리 누수
// 올바른 방법: 종료 시 정리
process.on('SIGTERM', async () => {
await subscriber.unsubscribe('channel');
await subscriber.disconnect();
await publisher.disconnect();
});
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 Redis 성능 최적화의 핵심입니다. 시험 직전·실무 실수 방지를 위한 압축 노트로 마무리할게요.
- 성능 병목 5가지 — RTT / 직렬화 / 메모리 / 블로킹 / 연결 오버헤드
- 가장 큰 효과 = 파이프라이닝 (
multi()+exec()로 N개 명령 1번 RTT) Promise.allvsmulti()— Promise.all은 동시 실행, multi()는 진짜 1번 왕복- 연결 풀 = 케이블 미리 깔아 두기 (TCP 핸드셰이크 ~20ms 절약)
- Lettuce는 단일 연결 + 멀티플렉싱, Jedis만 풀 필수
- 자료 인코딩 자동 전환 — Hash 128 필드 / Set 512 정수 / ZSet 128 요소가 임계점
- 임계점 넘으면 메모리가 갑자기 몇 배 됨 → 샤딩 키 패턴으로 분할
OBJECT ENCODING= 현재 인코딩 확인- 측정 = redis-benchmark + --latency + INFO 세 도구
- 평균 대신 p99를 보는 습관 (1% 사용자 경험)
mem_fragmentation_ratio> 1.5 = 단편화 의심 →MEMORY PURGE- SLOWLOG 10ms 이상 명령 자동 기록 → 하루 한 번 점검 표준
- **
KEYS *절대 금지** →SCAN 0 MATCH ... COUNT 100 HGETALL대형 Hash 위험 →HMGET또는HSCAN- Lua 스크립트 = WATCH/MULTI 대체 (원자성 + RTT 1번)
SCRIPT LOAD+EVALSHA= 스크립트 캐싱 표준- 자료 구조 선택 — 카운터=String / 객체=Hash / 큐=List / 태그=Set / 리더보드=Sorted Set / UV=HyperLogLog / 이벤트=Stream
SMEMBERS·HGETALL은 O(N) → 큰 자료에 위험LINDEX중간 인덱스는 O(N) → List는 양쪽 끝만MEMORY USAGE·MEMORY DOCTOR·--bigkeys가 메모리 분석 3종- 순수 캐시 용도라면
save ""+appendonly no로 영속성 끄기 - Redis 6.0 멀티스레딩 = I/O만 병렬화, 명령 실행은 단일 스레드
- N+1 Redis 호출 = 거의 항상 안티 패턴 → 파이프라인 또는 multiGet
SORT BY ... GET ...= 서버 사이드 정렬 + 조회 한 번에- 큰 값 통째 저장 금지, 작은 단위 분리가 표준
- 임시 클라이언트는 try-finally로 disconnect
- Pub/Sub 종료 시
unsubscribe+disconnect명시 정리
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Redis 입문
- 2편 — 데이터 구조 7종 심화
- 3편 — 명령어 (TTL · SCAN · 트랜잭션 · 파이프라이닝)
- 4편 — 영속성 (RDB · AOF · 하이브리드)
- 5편 — 캐싱 패턴
- 6편 — Pub/Sub & Streams
- 7편 — 클러스터 · 고가용성
- 8편 — Spring Data Redis
- 9편 — 성능 최적화 (완) (현재 글, 완결)
이 시리즈를 마치며
여기까지 와줘서 정말 고마워요. 1편의 책상 위 메모리 사물함 비유에서 시작해 9편의 파이프라이닝까지, 9개의 글을 통해 우리는 Redis의 큰 그림을 한 번 함께 그려 봤습니다.
처음 1편에서 "Redis는 책상 옆 메모리 사물함, 키-값은 이름표 적힌 봉투" 라고 풀었던 게 떠올라요. 그 두 비유에서 출발해 — 자료 구조 7종, TTL·SCAN·트랜잭션, RDB·AOF, 캐싱 패턴, Pub/Sub·Streams, 클러스터·Sentinel, Spring Data Redis, 그리고 오늘의 성능 최적화까지 — 한 단계씩 비유를 늘려 가며 풀어 왔습니다.
처음에는 명령어 50개와 자료 구조 7종이 머리를 어지럽혔을 거예요. 하지만 이 시리즈를 따라온 지금쯤이면, INCR을 보면 "사물함 안 카운터가 떠오르고", ZADD를 보면 "리더보드 점수표가 떠오르고", @Cacheable을 보면 "메서드 결과가 자동으로 사물함에 들어가는 그림이 떠오르고", 파이프라이닝을 보면 "묶음 주문이 떠오르는" 단계에 와 있을 거라 생각해요. 그게 이 시리즈가 노린 가장 중요한 변화입니다 — 추상 명령이 일상 비유로 자연스럽게 떠오르는 단계.
Redis도 살아 있는 도구예요. 7.0이 나오고, 7.4가 나오고, 8.0이 곧 다가올 거예요. 새 자료 구조나 새 모듈이 추가될 때마다 모든 걸 다시 외우려 하지 마세요. 이 시리즈에서 잡은 비유들이 변하지 않을 뼈대가 되어, 새 기능이 나올 때마다 그 위에 한 줄씩만 얹으면 됩니다. 인메모리 키-값의 본질, 단순 자료 구조의 명료함, 단일 스레드 모델의 예측 가능성 — 이 핵심 원칙들은 Redis가 8.0이 되든 9.0이 되든 그대로 남아 있을 거예요.
면접을 준비하는 분이라면 이 시리즈가 마지막 복습 자료가 됐으면 좋겠고, 실무에 적용하는 분이라면 매일 만지는 코드 한 줄 한 줄에 비유들이 떠올라 작업이 즐거워졌으면 좋겠어요. 캐시 미스가 한 번 나도 "사물함에 봉투가 비어 있구나" 하고 자연스럽게 받아들이고, 메모리 그래프가 튀어도 "ziplist에서 hashtable로 넘어갔구나" 하고 침착하게 진단할 수 있게 — 그게 이 시리즈를 쓴 가장 큰 보람이 될 거예요.
긴 시리즈를 함께 끝까지 따라와 주셔서 다시 한 번 진심으로 감사합니다. 좋은 코드, 빠른 응답, 그리고 든든한 Redis와 함께하는 하루하루가 되시길 바랄게요.