Redis 성능 최적화 — 파이프라이닝·연결 풀 한 번에

2026-05-02AWS SAA-C03 스터디

Redis 핵심 정리 시리즈 9편 — 시리즈 완결. Redis가 본래 빠른 도구라도 잘못 쓰면 성능이 무너지는 자리들을 한 편에 정리. 파이프라이닝·연결 풀·메모리 최적화·블로킹 명령 회피·SLOWLOG·redis-benchmark·Lua 스크립트·자료 구조 선택 가이드까지 — 시리즈 마지막 편을 따뜻한 마무리와 함께.

📚 Redis 핵심 정리 · 9편 / 14편 — 파이프라이닝·연결 풀 한 번에

이 글은 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.allmulti() 파이프라이닝은 다른 개념입니다. 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개ListO(1) 양쪽 추가/제거
태그/관계SetO(1) 추가/조회, 집합 연산
리더보드Sorted SetO(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

--bigkeysSCAN 기반이라 운영 환경에서도 안전하게 쓸 수 있어요(블로킹 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.all vs multi() — 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·HGETALLO(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편의 책상 위 메모리 사물함 비유에서 시작해 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와 함께하는 하루하루가 되시길 바랄게요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!