Skip to content
Published on

Redis 완전 가이드 2025: 캐싱 전략, 데이터 구조, Pub/Sub, 그리고 Redis Stack까지

Authors

들어가며

Redis는 2025년 현재 가장 널리 사용되는 인메모리 데이터 스토어입니다. 단순한 캐시를 넘어 메시지 브로커, 세션 스토어, 실시간 리더보드, Rate Limiter, 분산 잠금까지 다양한 역할을 수행합니다. Redis 7.4의 새 기능과 Redis Stack의 등장, 그리고 Valkey 포크 논쟁까지 — 이 글에서 Redis의 모든 것을 다룹니다.


1. Redis 개요

Redis란?

Redis(Remote Dictionary Server)는 인메모리 키-값 데이터 스토어입니다. 모든 데이터를 메모리에 저장하므로 마이크로초 단위의 응답 시간을 제공합니다.

Redis 7.4 주요 기능

  • Redis Functions — Lua 스크립트의 진화. 라이브러리 형태로 관리
  • ACL v2 — 세분화된 접근 제어
  • Client-side caching — 클라이언트 측 캐시 무효화 지원
  • Multi-part AOF — 지속성 향상

Valkey 포크 이야기

2024년 Redis가 라이선스를 변경하면서(SSPL + RSALv2), Linux Foundation은 Redis 7.2를 기반으로 Valkey를 포크했습니다. AWS, Google, Oracle 등이 Valkey를 지원합니다. 현재 두 프로젝트는 호환성을 유지하지만, 장기적으로 분기될 가능성이 있습니다.


2. 기본 데이터 구조 5가지

2.1 String

가장 기본적인 데이터 타입. 최대 512MB까지 저장 가능합니다.

# 기본 SET/GET
SET user:1:name "Alice"
GET user:1:name

# 숫자 증감 (원자적 연산)
SET page:views 0
INCR page:views          # 1
INCRBY page:views 10     # 11

# 만료 시간 설정
SET session:abc123 "user_data" EX 3600   # 1시간 후 만료
TTL session:abc123                        # 남은 시간 확인

# SET 옵션
SET lock:resource "owner1" NX EX 30      # NX: 키가 없을 때만 설정
SET user:1:name "Bob" XX                 # XX: 키가 있을 때만 업데이트

활용 사례: 세션 토큰, 카운터, 임시 데이터, 분산 잠금

2.2 List

이중 연결 리스트. 양쪽 끝에서 O(1) push/pop 가능합니다.

# 기본 연산
LPUSH queue:emails "email1" "email2" "email3"
RPOP queue:emails                        # "email1" (FIFO 큐)

# 범위 조회
LRANGE queue:emails 0 -1                 # 모든 요소

# 블로킹 팝 (메시지 큐로 활용)
BRPOP queue:emails 30                    # 30초 대기 후 팝

# 트리밍 (최근 N개 유지)
LPUSH notifications:user1 "new_msg"
LTRIM notifications:user1 0 99          # 최근 100개만 유지

활용 사례: 메시지 큐, 최근 활동 피드, 작업 큐

2.3 Set

순서 없는 고유 요소의 집합. 집합 연산(합집합, 교집합, 차집합) 지원합니다.

# 기본 연산
SADD tags:post:1 "python" "redis" "backend"
SADD tags:post:2 "python" "django" "orm"

# 멤버십 확인
SISMEMBER tags:post:1 "python"           # 1 (true)

# 집합 연산
SINTER tags:post:1 tags:post:2           # "python" (교집합)
SUNION tags:post:1 tags:post:2           # 합집합
SDIFF tags:post:1 tags:post:2            # tags:post:1에만 있는 요소

# 랜덤 추출
SRANDMEMBER tags:post:1 2               # 랜덤 2개

활용 사례: 태그 시스템, 고유 방문자 추적, 친구 관계, 추천 시스템

2.4 Sorted Set (ZSet)

점수(score)로 정렬된 고유 요소의 집합. 리더보드에 최적입니다.

# 리더보드 구현
ZADD leaderboard 1500 "player:alice"
ZADD leaderboard 2300 "player:bob"
ZADD leaderboard 1800 "player:charlie"

# 랭킹 조회 (높은 점수 순)
ZREVRANGE leaderboard 0 2 WITHSCORES
# 1) "player:bob"     2) "2300"
# 3) "player:charlie" 4) "1800"
# 5) "player:alice"   6) "1500"

# 특정 멤버 랭킹 (0부터 시작)
ZREVRANK leaderboard "player:alice"      # 2

# 점수 증가
ZINCRBY leaderboard 500 "player:alice"   # 2000

# 범위 검색
ZRANGEBYSCORE leaderboard 1500 2000 WITHSCORES

활용 사례: 리더보드, 우선순위 큐, 시간순 이벤트, Rate Limiting

2.5 Hash

필드-값 쌍의 맵. 객체를 표현하기에 적합합니다.

# 사용자 프로필 저장
HSET user:1 name "Alice" email "alice@example.com" age "30" role "admin"

# 개별 필드 조회
HGET user:1 name                         # "Alice"

# 전체 조회
HGETALL user:1

# 필드 증감
HINCRBY user:1 age 1                     # 31

# 존재 확인
HEXISTS user:1 email                     # 1 (true)

# 여러 필드 한 번에
HMGET user:1 name email role

활용 사례: 사용자 프로필, 설정 값, 세션 데이터, 장바구니


3. 고급 데이터 구조

3.1 HyperLogLog

대량의 고유 요소 수를 추정하는 확률적 자료 구조. 메모리 12KB로 최대 2의 64승 개 카운트 가능(오차율 0.81%).

# 고유 방문자 수 추정
PFADD visitors:2025-03-23 "user1" "user2" "user3"
PFADD visitors:2025-03-23 "user1" "user4"          # user1은 중복

PFCOUNT visitors:2025-03-23                         # 4

# 여러 날 합산
PFMERGE visitors:week visitors:2025-03-23 visitors:2025-03-24
PFCOUNT visitors:week

3.2 Bitmap

비트 단위 연산. 불리언 상태 추적에 메모리 효율적입니다.

# 일일 출석 체크
SETBIT attendance:2025-03-23 1001 1      # 사용자 1001 출석
SETBIT attendance:2025-03-23 1002 1
SETBIT attendance:2025-03-23 1003 0      # 결석

# 출석 여부 확인
GETBIT attendance:2025-03-23 1001        # 1

# 출석 인원 수
BITCOUNT attendance:2025-03-23           # 2

# 연속 출석 (AND 연산)
BITOP AND consecutive attendance:2025-03-22 attendance:2025-03-23
BITCOUNT consecutive

3.3 Geospatial

위치 기반 데이터. 반경 검색, 거리 계산을 지원합니다.

# 위치 추가 (경도, 위도)
GEOADD stores 126.9784 37.5665 "gangnam-store"
GEOADD stores 127.0276 37.4979 "samsung-store"
GEOADD stores 126.9316 37.5563 "hongdae-store"

# 거리 계산
GEODIST stores "gangnam-store" "hongdae-store" km    # ~5.2 km

# 반경 검색 (Redis 6.2+)
GEOSEARCH stores FROMLONLAT 126.9784 37.5665 BYRADIUS 10 km ASC COUNT 5

3.4 Redis Streams

로그 기반 메시지 구조. Kafka와 유사하게 Consumer Group을 지원합니다.

# 메시지 추가
XADD events * type "order" user_id "123" amount "50000"
XADD events * type "payment" user_id "123" status "completed"

# 읽기
XRANGE events - + COUNT 10

# Consumer Group 생성
XGROUP CREATE events analytics-group $ MKSTREAM

# Consumer로 읽기
XREADGROUP GROUP analytics-group consumer-1 COUNT 5 BLOCK 2000 STREAMS events >

# ACK (처리 완료)
XACK events analytics-group "1679000000000-0"

# 미처리 메시지 확인
XPENDING events analytics-group

4. 캐싱 패턴

4.1 Cache-Aside (Lazy Loading)

가장 보편적인 패턴. 애플리케이션이 캐시를 직접 관리합니다.

import redis
import json

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"

    # 1. 캐시 확인
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. 캐시 미스 -> DB 조회
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        return None

    # 3. 캐시에 저장
    r.setex(cache_key, 3600, json.dumps(user.to_dict()))
    return user.to_dict()

def update_user(user_id: int, data: dict):
    # DB 업데이트
    db.query(User).filter(User.id == user_id).update(data)
    db.commit()

    # 캐시 무효화
    r.delete(f"user:{user_id}")

4.2 Write-Through

쓰기 시 캐시와 DB를 동시에 업데이트합니다.

def save_user(user_id: int, data: dict):
    cache_key = f"user:{user_id}"

    # DB와 캐시 동시 업데이트
    db.query(User).filter(User.id == user_id).update(data)
    db.commit()

    r.setex(cache_key, 3600, json.dumps(data))

4.3 Write-Behind (Write-Back)

쓰기를 캐시에 먼저 하고, 비동기로 DB에 반영합니다.

def save_user_async(user_id: int, data: dict):
    cache_key = f"user:{user_id}"

    # 캐시에 먼저 쓰기
    r.setex(cache_key, 3600, json.dumps(data))

    # 비동기로 DB에 반영 (Celery 등)
    sync_to_db_task.delay(user_id, data)

4.4 Read-Through

캐시 라이브러리가 DB 조회를 대신 처리합니다.

캐싱 패턴 비교

패턴읽기 성능쓰기 성능일관성복잡도
Cache-Aside높음보통최종 일관성낮음
Write-Through높음낮음강한 일관성중간
Write-Behind높음높음최종 일관성높음
Read-Through높음보통최종 일관성중간

5. 캐시 무효화

5.1 TTL 기반

가장 단순한 방법. 일정 시간 후 자동 삭제됩니다.

SET product:123 "data" EX 300           # 5분 후 만료

5.2 이벤트 기반 무효화

데이터 변경 시 명시적으로 캐시를 삭제합니다.

def update_product(product_id: int, data: dict):
    db.update(product_id, data)

    # 관련 캐시 모두 삭제
    r.delete(f"product:{product_id}")
    r.delete(f"product_list:category:{data['category_id']}")
    r.delete("product_list:featured")

5.3 버전 키

키에 버전을 포함하여 일괄 무효화합니다.

def get_product_list(category_id: int) -> list:
    version = r.get(f"product_version:{category_id}") or "1"
    cache_key = f"products:cat:{category_id}:v:{version}"

    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    products = db.query(Product).filter(Product.category_id == category_id).all()
    r.setex(cache_key, 3600, json.dumps([p.to_dict() for p in products]))
    return [p.to_dict() for p in products]

def invalidate_category(category_id: int):
    # 버전을 증가시켜 기존 캐시를 무효화
    r.incr(f"product_version:{category_id}")

5.4 Thundering Herd 방지

캐시 만료 시 동시에 많은 요청이 DB로 몰리는 문제를 방지합니다.

import random

def get_with_jitter(key: str, ttl: int = 3600) -> dict:
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    # 분산 잠금으로 하나의 요청만 DB 조회
    lock_key = f"lock:{key}"
    if r.set(lock_key, "1", nx=True, ex=10):
        try:
            data = fetch_from_db(key)
            # TTL에 랜덤 지터 추가
            jitter = random.randint(0, 300)
            r.setex(key, ttl + jitter, json.dumps(data))
            return data
        finally:
            r.delete(lock_key)
    else:
        # 다른 프로세스가 갱신 중 — 짧은 대기 후 재시도
        import time
        time.sleep(0.1)
        return get_with_jitter(key, ttl)

6. Pub/Sub와 Streams

6.1 Pub/Sub 기본

# 구독자
SUBSCRIBE notifications:user:123

# 발행자
PUBLISH notifications:user:123 "You have a new message!"

# 패턴 구독
PSUBSCRIBE notifications:*
# Python 구독자
import redis

r = redis.Redis()
pubsub = r.pubsub()
pubsub.subscribe("notifications:user:123")

for message in pubsub.listen():
    if message["type"] == "message":
        print(f"Received: {message['data']}")

6.2 Redis Streams vs Kafka

기능Redis StreamsKafka
메시지 지속성메모리 + AOF디스크
처리량수만/초수십만/초
Consumer Group지원지원
메시지 재생지원지원
파티셔닝미지원지원
운영 복잡도낮음높음
적합한 규모중소대규모

6.3 Streams를 활용한 이벤트 드리븐 패턴

import redis
import time

r = redis.Redis(decode_responses=True)

# 이벤트 발행
def publish_event(stream: str, event_type: str, data: dict):
    r.xadd(stream, {"type": event_type, **data}, maxlen=10000)

# Consumer Group 기반 처리
def consume_events(stream: str, group: str, consumer: str):
    # 그룹 생성 (이미 존재하면 무시)
    try:
        r.xgroup_create(stream, group, id="0", mkstream=True)
    except redis.ResponseError:
        pass

    while True:
        messages = r.xreadgroup(
            group, consumer,
            {stream: ">"},
            count=10,
            block=5000,
        )

        for stream_name, entries in messages:
            for msg_id, fields in entries:
                try:
                    process_event(fields)
                    r.xack(stream_name, group, msg_id)
                except Exception as e:
                    print(f"Error processing {msg_id}: {e}")

def process_event(fields: dict):
    event_type = fields.get("type")
    if event_type == "order_created":
        handle_order(fields)
    elif event_type == "payment_completed":
        handle_payment(fields)

7. Redis Stack

Redis Stack은 Redis에 JSON, Search, TimeSeries, Bloom Filter 모듈을 추가한 확장 버전입니다.

7.1 RedisJSON

# JSON 문서 저장
JSON.SET user:1 $ '{"name":"Alice","age":30,"address":{"city":"Seoul","zip":"06000"},"tags":["python","redis"]}'

# 경로 기반 조회
JSON.GET user:1 $.name                   # "Alice"
JSON.GET user:1 $.address.city           # "Seoul"

# 부분 업데이트
JSON.SET user:1 $.age 31
JSON.ARRAPPEND user:1 $.tags '"fastapi"'

# 숫자 증감
JSON.NUMINCRBY user:1 $.age 1

7.2 RediSearch (전문 검색)

# 인덱스 생성
FT.CREATE idx:products
  ON JSON
  PREFIX 1 product:
  SCHEMA
    $.name AS name TEXT WEIGHT 5.0
    $.description AS description TEXT
    $.price AS price NUMERIC SORTABLE
    $.category AS category TAG

# 문서 추가
JSON.SET product:1 $ '{"name":"Redis in Action","description":"Complete guide to Redis","price":45000,"category":"book"}'
JSON.SET product:2 $ '{"name":"Python Cookbook","description":"Python recipes and patterns","price":38000,"category":"book"}'

# 검색
FT.SEARCH idx:products "Redis guide"
FT.SEARCH idx:products "@category:{book} @price:[30000 50000]"
FT.SEARCH idx:products "@name:(Python)" SORTBY price ASC

7.3 RedisTimeSeries

# 시계열 데이터 생성
TS.CREATE sensor:temperature:1 RETENTION 86400000 LABELS sensor_id 1 type temperature

# 데이터 추가
TS.ADD sensor:temperature:1 * 23.5
TS.ADD sensor:temperature:1 * 24.1
TS.ADD sensor:temperature:1 * 22.8

# 범위 조회
TS.RANGE sensor:temperature:1 - + COUNT 10

# 집계 (5분 평균)
TS.RANGE sensor:temperature:1 - + AGGREGATION avg 300000

# 다운샘플링 규칙
TS.CREATERULE sensor:temperature:1 sensor:temperature:1:avg AGGREGATION avg 300000

8. Lua 스크립팅

8.1 기본 Lua 스크립트

# 원자적 읽기-수정-쓰기
EVAL "
  local current = redis.call('GET', KEYS[1])
  if current then
    local new_val = tonumber(current) + tonumber(ARGV[1])
    redis.call('SET', KEYS[1], new_val)
    return new_val
  end
  return nil
" 1 counter 5

8.2 Rate Limiter (Sliding Window)

# Python + Lua Rate Limiter
RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 윈도우 밖의 오래된 요청 제거
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 현재 요청 수 확인
local count = redis.call('ZCARD', key)

if count < limit then
    -- 허용: 새 요청 추가
    redis.call('ZADD', key, now, now .. ':' .. math.random())
    redis.call('EXPIRE', key, window)
    return 1
else
    -- 거부
    return 0
end
"""

import redis
import time

r = redis.Redis()
rate_limit_sha = r.script_load(RATE_LIMIT_SCRIPT)

def is_allowed(user_id: str, limit: int = 100, window: int = 60) -> bool:
    key = f"ratelimit:{user_id}"
    now = int(time.time() * 1000)
    result = r.evalsha(rate_limit_sha, 1, key, limit, window * 1000, now)
    return bool(result)

8.3 분산 잠금 (Redlock 알고리즘)

import redis
import time
import uuid

class DistributedLock:
    def __init__(self, redis_client: redis.Redis, resource: str, ttl: int = 10):
        self.redis = redis_client
        self.resource = f"lock:{resource}"
        self.ttl = ttl
        self.token = str(uuid.uuid4())

    def acquire(self) -> bool:
        return bool(self.redis.set(
            self.resource, self.token,
            nx=True, ex=self.ttl,
        ))

    def release(self) -> bool:
        # Lua 스크립트로 원자적 확인 + 삭제
        script = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        end
        return 0
        """
        return bool(self.redis.eval(script, 1, self.resource, self.token))

    def __enter__(self):
        if not self.acquire():
            raise Exception("Could not acquire lock")
        return self

    def __exit__(self, *args):
        self.release()

# 사용
r = redis.Redis()
with DistributedLock(r, "order:process:123"):
    # 임계 영역 — 하나의 프로세스만 실행
    process_order(123)

9. Redis Cluster

9.1 해시 슬롯

Redis Cluster는 16384개의 해시 슬롯으로 데이터를 분산합니다. 키의 CRC16 해시값을 16384로 나눈 나머지가 슬롯 번호입니다.

노드 구성 예시:
  Node A: 슬롯 0-5460
  Node B: 슬롯 5461-10922
  Node C: 슬롯 10923-16383

각 노드에 1개의 복제본(Replica)을 추가하면:
  Node A -> Replica A'
  Node B -> Replica B'
  Node C -> Replica C'

9.2 클러스터 설정

# 클러스터 생성 (6노드: 3 마스터 + 3 레플리카)
redis-cli --cluster create \
  127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \
  127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 \
  --cluster-replicas 1

# 클러스터 상태 확인
redis-cli -c -p 7001 cluster info
redis-cli -c -p 7001 cluster nodes

9.3 Failover와 Resharding

# 수동 페일오버
redis-cli -c -p 7004 cluster failover

# 리샤딩 (슬롯 이동)
redis-cli --cluster reshard 127.0.0.1:7001

# 노드 추가
redis-cli --cluster add-node 127.0.0.1:7007 127.0.0.1:7001

# 노드 제거
redis-cli --cluster del-node 127.0.0.1:7001 <node-id>

9.4 해시 태그

같은 슬롯에 키를 배치하여 다중 키 연산을 가능하게 합니다.

# 해시 태그 사용 — 중괄호 안의 부분으로 슬롯 결정
SET {user:1}:profile "data"
SET {user:1}:settings "data"
SET {user:1}:sessions "data"

# 이 세 키는 같은 슬롯에 배치됨 -> MULTI 키 연산 가능

10. Redis 실전 활용

10.1 세션 스토어

import redis
import json
import uuid

r = redis.Redis(decode_responses=True)

def create_session(user_id: int, data: dict) -> str:
    session_id = str(uuid.uuid4())
    session_data = {
        "user_id": str(user_id),
        "created_at": str(time.time()),
        **data,
    }
    r.hset(f"session:{session_id}", mapping=session_data)
    r.expire(f"session:{session_id}", 86400)  # 24시간
    return session_id

def get_session(session_id: str) -> dict | None:
    data = r.hgetall(f"session:{session_id}")
    if not data:
        return None
    # 접근 시 TTL 갱신
    r.expire(f"session:{session_id}", 86400)
    return data

def destroy_session(session_id: str):
    r.delete(f"session:{session_id}")

10.2 Rate Limiter (Fixed Window)

def check_rate_limit(user_id: str, limit: int = 100, window: int = 60) -> bool:
    key = f"rate:{user_id}:{int(time.time()) // window}"

    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, window)
    count, _ = pipe.execute()

    return count <= limit

10.3 리더보드

class Leaderboard:
    def __init__(self, name: str):
        self.key = f"leaderboard:{name}"

    def add_score(self, player_id: str, score: float):
        r.zadd(self.key, {player_id: score})

    def increment_score(self, player_id: str, delta: float):
        r.zincrby(self.key, delta, player_id)

    def get_rank(self, player_id: str) -> int | None:
        rank = r.zrevrank(self.key, player_id)
        return rank + 1 if rank is not None else None

    def get_top(self, count: int = 10) -> list[tuple[str, float]]:
        return r.zrevrange(self.key, 0, count - 1, withscores=True)

    def get_around(self, player_id: str, count: int = 5) -> list:
        rank = r.zrevrank(self.key, player_id)
        if rank is None:
            return []
        start = max(0, rank - count)
        end = rank + count
        return r.zrevrange(self.key, start, end, withscores=True)

# 사용
lb = Leaderboard("weekly")
lb.add_score("player:alice", 1500)
lb.increment_score("player:alice", 200)
print(lb.get_top(10))
print(lb.get_rank("player:alice"))

10.4 작업 큐 (Simple Job Queue)

import json
import time

def enqueue_job(queue: str, job_data: dict):
    job = {
        "id": str(uuid.uuid4()),
        "data": job_data,
        "created_at": time.time(),
    }
    r.lpush(f"queue:{queue}", json.dumps(job))

def dequeue_job(queue: str, timeout: int = 30) -> dict | None:
    result = r.brpop(f"queue:{queue}", timeout=timeout)
    if result:
        _, job_json = result
        return json.loads(job_json)
    return None

def worker(queue: str):
    while True:
        job = dequeue_job(queue)
        if job:
            try:
                process_job(job["data"])
            except Exception as e:
                # 실패 시 재시도 큐에 추가
                r.lpush(f"queue:{queue}:failed", json.dumps(job))

11. Redis vs Memcached vs DragonflyDB

기능RedisMemcachedDragonflyDB
데이터 구조다양 (String, List, Set 등)String만Redis 호환
지속성RDB + AOF없음스냅샷
클러스터링Redis Cluster클라이언트 측단일 노드 (멀티스레드)
멀티스레드단일 스레드 (I/O 스레드)멀티스레드멀티스레드
메모리 효율보통높음높음
Pub/Sub지원미지원지원
Lua 스크립트지원미지원지원
처리량~100K ops/s~100K ops/s~400K ops/s
최대 값 크기512MB1MB512MB
적합한 사례범용단순 캐시고성능 단일 노드

12. 클라이언트 라이브러리 코드 예시

12.1 Spring Data Redis (Java)

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

@Service
public class UserCacheService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final Duration TTL = Duration.ofHours(1);

    public UserCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheUser(String userId, UserDto user) {
        String key = "user:" + userId;
        redisTemplate.opsForValue().set(key, user, TTL);
    }

    public UserDto getCachedUser(String userId) {
        String key = "user:" + userId;
        return (UserDto) redisTemplate.opsForValue().get(key);
    }

    // Sorted Set 리더보드
    public void addScore(String leaderboard, String playerId, double score) {
        redisTemplate.opsForZSet().add("lb:" + leaderboard, playerId, score);
    }

    public Set<ZSetOperations.TypedTuple<Object>> getTopPlayers(String leaderboard, int count) {
        return redisTemplate.opsForZSet().reverseRangeWithScores("lb:" + leaderboard, 0, count - 1);
    }
}

12.2 ioredis (Node.js)

import Redis from 'ioredis';

const redis = new Redis({
  host: 'localhost',
  port: 6379,
  retryStrategy: (times) => Math.min(times * 50, 2000),
  maxRetriesPerRequest: 3,
});

// 기본 캐싱
async function getUser(userId) {
  const cacheKey = `user:${userId}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await db.findUser(userId);
  if (user) {
    await redis.setex(cacheKey, 3600, JSON.stringify(user));
  }
  return user;
}

// Pipeline (배치 처리)
async function getMultipleUsers(userIds) {
  const pipeline = redis.pipeline();
  userIds.forEach(id => pipeline.get(`user:${id}`));
  const results = await pipeline.exec();
  return results.map(([err, val]) => val ? JSON.parse(val) : null);
}

// Pub/Sub
const subscriber = new Redis();
subscriber.subscribe('notifications', (err, count) => {
  console.log(`Subscribed to ${count} channels`);
});

subscriber.on('message', (channel, message) => {
  console.log(`Received on ${channel}: ${message}`);
});

// 발행
await redis.publish('notifications', JSON.stringify({ type: 'alert', message: 'Server update' }));

12.3 redis-py (Python)

import redis.asyncio as aioredis
import json

# 비동기 Redis 클라이언트
pool = aioredis.ConnectionPool.from_url(
    "redis://localhost:6379",
    max_connections=20,
    decode_responses=True,
)
r = aioredis.Redis(connection_pool=pool)

# 파이프라인
async def batch_operations():
    async with r.pipeline(transaction=True) as pipe:
        pipe.set("key1", "value1")
        pipe.set("key2", "value2")
        pipe.get("key1")
        results = await pipe.execute()
        return results

# Pub/Sub (비동기)
async def subscribe_handler():
    pubsub = r.pubsub()
    await pubsub.subscribe("events")

    async for message in pubsub.listen():
        if message["type"] == "message":
            data = json.loads(message["data"])
            await process_event(data)

# 캐시 데코레이터
import functools

def redis_cache(ttl: int = 3600):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            cache_key = f"cache:{func.__name__}:{args}:{kwargs}"
            cached = await r.get(cache_key)
            if cached:
                return json.loads(cached)
            result = await func(*args, **kwargs)
            await r.setex(cache_key, ttl, json.dumps(result))
            return result
        return wrapper
    return decorator

@redis_cache(ttl=600)
async def get_product_list(category_id: int):
    return await db.get_products(category_id)

13. 면접 질문 15선

Q1. Redis가 싱글 스레드인데 어떻게 빠른가요?

Redis는 이벤트 루프 기반의 싱글 스레드로 동작하며, 모든 데이터가 메모리에 있어 디스크 I/O가 없습니다. 또한 epoll/kqueue 기반의 I/O 멀티플렉싱으로 수천 개의 연결을 효율적으로 처리합니다. Redis 6.0부터 I/O 스레드를 지원하여 네트워크 처리를 병렬화합니다.

Q2. Cache-Aside와 Write-Through의 차이는?

Cache-Aside는 애플리케이션이 캐시를 직접 관리합니다(읽기 시 캐시 미스면 DB 조회 후 캐시 저장). Write-Through는 쓰기 시 캐시와 DB를 동시에 업데이트합니다. Cache-Aside는 구현이 간단하고, Write-Through는 데이터 일관성이 더 강합니다.

Q3. Redis의 지속성 방식(RDB, AOF)을 설명하세요.

RDB(Redis Database)는 특정 시점의 스냅샷을 디스크에 저장합니다. AOF(Append Only File)는 모든 쓰기 연산을 로그로 기록합니다. RDB는 빠른 복구에 적합하고, AOF는 데이터 손실을 최소화합니다. 둘 다 사용하는 것이 권장됩니다.

Q4. Redis Cluster에서 MGET이 실패하는 이유는?

Redis Cluster에서 키는 해시 슬롯에 분산됩니다. MGET의 키들이 서로 다른 슬롯에 있으면 단일 명령으로 처리할 수 없습니다. 해시 태그(중괄호)를 사용하여 같은 슬롯에 키를 배치하거나, 클라이언트에서 여러 요청으로 분할해야 합니다.

Q5. Thundering Herd 문제란?

캐시 키가 만료되는 순간 수많은 요청이 동시에 DB로 몰리는 현상입니다. 해결 방법: 분산 잠금으로 하나의 요청만 DB 접근 허용, TTL에 랜덤 지터 추가, 백그라운드에서 캐시 사전 갱신(proactive refresh).

Q6. Redis Pub/Sub과 Streams의 차이는?

Pub/Sub은 Fire-and-Forget 방식으로, 구독자가 없으면 메시지가 소실됩니다. Streams는 메시지를 영구 저장하고, Consumer Group으로 안정적인 메시지 처리를 보장합니다. Streams는 ACK, 재처리, 이력 조회가 가능합니다.

Q7. Redis에서 큰 키(Big Key)가 문제가 되는 이유는?

큰 키는 삭제 시 Redis를 블로킹하고, 네트워크 대역폭을 소모하며, 클러스터에서 데이터 편중을 일으킵니다. UNLINK 명령으로 비동기 삭제하고, 큰 해시를 여러 작은 해시로 분할하는 것이 좋습니다.

Q8. Redis의 메모리 정책(eviction policy)을 설명하세요.

maxmemory에 도달하면 eviction 정책에 따라 키를 제거합니다. noeviction(새 쓰기 거부), allkeys-lru(LRU), allkeys-lfu(LFU), volatile-lru(TTL 있는 키 중 LRU), volatile-ttl(만료 시간 가까운 키 우선) 등이 있습니다.

Q9. Lua 스크립트를 사용하는 이유는?

Redis에서 여러 명령을 원자적으로 실행하기 위해 사용합니다. 네트워크 왕복을 줄이고, 서버 측에서 복잡한 로직을 원자적으로 실행합니다. 대표적 사례: Rate Limiter, 분산 잠금 해제, 조건부 업데이트.

Q10. Redis Sentinel과 Redis Cluster의 차이는?

Sentinel은 마스터-슬레이브 구조의 고가용성 솔루션입니다(자동 페일오버). Cluster는 데이터를 여러 노드에 분산하는 수평 확장 솔루션입니다. 소규모에는 Sentinel, 대규모 데이터에는 Cluster가 적합합니다.

Q11. Redis의 파이프라인이란?

여러 명령을 한 번에 서버로 보내고, 응답을 일괄적으로 받는 기법입니다. 네트워크 왕복 횟수를 줄여 성능을 크게 향상시킵니다. 100개의 명령을 개별로 보내면 100번 왕복하지만, 파이프라인은 1번입니다.

Q12. Redis에서 키 만료 처리는 어떻게 동작하나요?

두 가지 방식을 조합합니다. Lazy expiration은 키에 접근할 때 만료 여부를 확인합니다. Active expiration은 100ms마다 임의의 만료 키를 샘플링하여 삭제합니다. 이 조합으로 만료된 키가 메모리를 과도하게 차지하는 것을 방지합니다.

Q13. Redlock 알고리즘이란?

Martin Kleppmann이 비판하고 Salvatore Sanfilippo가 제안한 분산 잠금 알고리즘입니다. N개의 독립적 Redis 인스턴스에서 과반수(N/2+1)에 성공적으로 잠금을 획득하면 잠금이 유효합니다. 단일 인스턴스 잠금보다 안전하지만, 완벽하지는 않습니다.

Q14. Redis의 slow log란?

실행 시간이 특정 임계값을 초과하는 명령을 기록합니다. slowlog-log-slower-than으로 임계값(마이크로초)을 설정합니다. SLOWLOG GET으로 느린 명령을 확인하고 최적화할 수 있습니다.

Q15. Redis를 세션 스토어로 사용할 때의 장단점은?

장점: 빠른 읽기/쓰기, TTL 자동 만료, 수평 확장 가능, 서버 간 세션 공유. 단점: 메모리 비용, Redis 장애 시 세션 손실 위험, 네트워크 의존성. 지속성(AOF)과 복제를 활성화하여 리스크를 줄일 수 있습니다.


14. 퀴즈

Q1. Redis SET 명령의 NX와 XX 옵션의 차이는?

NX(Not eXists)는 키가 존재하지 않을 때만 설정합니다. 분산 잠금 획득에 사용됩니다. XX(eXists)는 키가 이미 존재할 때만 업데이트합니다. 기존 값을 안전하게 갱신할 때 사용합니다.

Q2. ZADD의 시간 복잡도는 무엇이며 왜 그런가요?

ZADD의 시간 복잡도는 O(log N)입니다. Sorted Set은 내부적으로 Skip List를 사용하여 정렬 상태를 유지하며, Skip List의 삽입 연산이 O(log N)이기 때문입니다.

Q3. Redis에서 KEYS 명령을 프로덕션에서 사용하면 안 되는 이유는?

KEYS는 모든 키를 순회하는 O(N) 연산으로, 키가 많으면 Redis를 장시간 블로킹합니다. 대신 SCAN 명령을 사용해야 합니다. SCAN은 커서 기반으로 점진적으로 순회하여 블로킹을 방지합니다.

Q4. Redis의 WATCH/MULTI/EXEC는 어떤 문제를 해결하나요?

낙관적 잠금(Optimistic Locking)을 구현합니다. WATCH로 키를 감시하고, MULTI로 트랜잭션을 시작하고, EXEC로 실행합니다. WATCH한 키가 다른 클라이언트에 의해 변경되면 트랜잭션이 실패합니다. 충돌이 적은 상황에서 효율적입니다.

Q5. HyperLogLog의 오차율은 약 0.81%입니다. 왜 정확하지 않은 데이터 구조를 사용하나요?

정확한 고유 카운트는 O(N) 메모리가 필요합니다(모든 요소 저장). HyperLogLog는 어떤 크기의 집합이든 12KB 고정 메모리만 사용합니다. 1억 명의 고유 방문자를 추적할 때, Set은 수 GB가 필요하지만 HyperLogLog는 12KB로 충분합니다. 대부분의 분석 사례에서 0.81% 오차는 허용 가능합니다.


참고 자료

  1. Redis 공식 문서
  2. Redis University
  3. Redis Stack 문서
  4. Valkey 프로젝트
  5. Redis in Action (Manning)
  6. ioredis GitHub
  7. redis-py 문서
  8. Spring Data Redis
  9. Redis Best Practices
  10. Redlock 알고리즘
  11. Martin Kleppmann의 Redlock 분석
  12. Redis Cluster 튜토리얼
  13. DragonflyDB
  14. Redis Streams 가이드
  15. RediSearch 문서