infra
Platform

모듈 맵

[Database] 캐시(Cache) 전략, 세션 스토어, Pub/Sub 실무 패턴

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 29 / 37

[Database] 캐시(Cache) 전략, 세션 스토어, Pub/Sub 실무 패턴

Redis의 자료구조와 TTL을 활용한 캐싱 전략, 세션 저장, 실시간 메시징 패턴을 실습합니다

🚨INCIDENT ALERT
HIGH

Redis는 빠르다는 이유만으로 붙이면 금방 운영 문제가 됩니다. 캐시, 세션, 락, 카운터는 각각 만료 정책과 데이터 손실 허용 범위가 다릅니다. Key-Value 저장소의 특성을 이해해야 빠른 기능을 안전하게 운영할 수 있습니다.

이번 챕터에서 배울 것

Redis는 String, List, Hash, Set, ZSet 5가지 자료구조를 제공하며, 각각 다른 문제를 해결합니다. 실무에서 가장 많이 쓰는 패턴은 Cache-Aside(읽기 캐시)와 세션 저장입니다. 캐시 설계의 핵심은 TTL 전략과 캐시 무효화입니다. Cache Stampede 같은 분산 시스템 문제도 함께 이해해야 합니다.

  • 1Redis 5가지 자료구조 — String, List, Hash, Set, ZSet
  • 2각 자료구조의 실무 사용 사례와 명령어
  • 3TTL — EXPIRE, TTL, PERSIST 명령으로 데이터 생명주기 관리
  • 4Cache-Aside 패턴 — 읽기/쓰기 순서와 캐시 무효화
  • 5세션을 Redis에 저장하는 이유와 구현 방법
  • 6Cache Stampede 문제와 대응 전략

Redis 실무 — 캐싱, 세션 관리, Pub/Sub 패턴

로그인 세션을 PostgreSQL에 저장했다. 사용자가 늘면서 매 요청마다 세션 테이블을 SELECT하는 게 DB 부하의 주범이 됐다. 배포할 때마다 세션이 초기화돼서 사용자들이 로그아웃됐다는 문의가 들어왔고, 새벽에 온 슬랙 알람에 서버 앞에 앉은 적이 있었다. 그때 팀장이 "Redis로 옮기면 된다"고 했다. TTL 설정 한 줄, 메모리에서 마이크로초 안에 읽고 — 세션 문제가 사라졌다. 처음엔 Redis가 그냥 "빠른 캐시 서버"인 줄 알았는데, 자료구조별로 완전히 다른 문제를 해결한다는 걸 나중에야 알았다. String으로 캐싱하고, ZSet으로 실시간 랭킹을 만들고, Pub/Sub으로 서비스 간 이벤트를 흘리는 — 이 패턴을 알면 DB 하나에 모든 걸 때려박던 설계에서 벗어날 수 있다.


OUTPUT
실행 결과를 확인할 수 있는 DB 콘솔 출력이 표시됩니다.
🔍실행 후 확인할 것
  • TTL 설정캐시나 세션 키에 만료 시간이 의도대로 걸렸는지 확인합니다.
  • 키 네이밍서비스·도메인·ID가 포함된 일관된 키 패턴인지 봅니다.
  • 영속성 요구사라져도 되는 데이터인지 RDB/AOF가 필요한 데이터인지 구분합니다.
💡개념

Redis 역할 분류 — 캐시·세션·큐·Pub-Sub

Redis를 "빠른 DB"라고 생각해서 중요한 비즈니스 데이터를 넣었는데, 서버 재시작 후 데이터가 사라집니다. Redis는 기본적으로 인메모리 저장소이고, 어떻게 설정하느냐에 따라 영속성이 달라집니다. 그리고 Redis는 캐시만 하는 도구가 아닙니다. 세션, 큐, Pub-Sub 등 역할마다 운용 방식이 완전히 다릅니다. 역할을 이해하고 써야 Redis를 올바르게 활용할 수 있습니다.

Redis 역할 분류 — 캐시·세션·큐·Pub-Sub

Redis를 역할별로 이해하기

Redis를 "빠른 데이터베이스"로 생각하면 잘못 사용하게 됩니다. Redis는 역할에 따라 전혀 다른 방식으로 운용됩니다.

역할사용 자료구조핵심 특성주의사항
캐시String, HashTTL 필수, 언제든 삭제 가능TTL 없으면 메모리 고갈
세션 저장소Hash, StringTTL로 자동 만료, 서버 무상태화민감 데이터 암호화
분산 큐ListLPUSH/BRPOP으로 작업 처리메시지 내구성 없음
Pub/Sub채널실시간 알림, Fire-and-Forget구독자 없으면 메시지 유실
랭킹/카운터ZSet, String원자적 증감영구 저장 필요 시 DB와 병행
Rate LimitingString + INCR원자적 카운터로 요청 수 제한Lua 스크립트로 원자성 보장

Redis 5가지 자료구조 — String

String은 Redis의 가장 기본 자료구조입니다. 문자열, 숫자, 직렬화된 JSON 모두 저장 가능합니다. EX 옵션으로 TTL을 설정하면 자동 만료됩니다. INCRINCRBY는 원자적으로 동작하므로 여러 서버에서 동시에 호출해도 카운터 정확성이 보장됩니다.

로컬 터미널
SET product:123 '{"name":"노트북","price":1200000}' EX 300

GET product:123

TTL product:123

INCR page_view:article:42
INCRBY daily_login:2024-03-15 1
DECR stock:product:123

List — 큐와 최근 목록

List는 순서가 있는 문자열 목록으로, 양쪽 끝에서 O(1)으로 삽입/삭제가 가능합니다. LPUSHRPOP을 조합하면 FIFO 큐가 됩니다. LTRIM으로 목록 크기를 제한하면 최근 N개만 유지하는 슬라이딩 윈도우 패턴을 구현할 수 있습니다.

로컬 터미널
LPUSH job_queue '{"type":"email","to":"user@example.com"}'
RPOP job_queue

LPUSH recent:user:42 "product:100"
LTRIM recent:user:42 0 9

LRANGE recent:user:42 0 -1
LRANGE recent:user:42 0 4

BRPOP job_queue 0

Hash — 객체 필드별 저장

Hash는 필드-값 쌍의 맵입니다. 사용자 세션이나 객체를 String(JSON 직렬화)으로 저장하면 특정 필드 하나를 변경할 때도 전체를 역직렬화해야 합니다. Hash는 HSET으로 특정 필드만 직접 업데이트할 수 있어 불필요한 직렬화 비용이 없습니다.

로컬 터미널
HSET session:abc123 user_id 42
HSET session:abc123 username "kimdev"
HSET session:abc123 role "admin"
HSET session:abc123 last_active "2024-03-15T10:30:00"

HMSET user:42 name "김개발" email "kim@example.com" level 5

HGET session:abc123 user_id
HMGET user:42 name email

HGETALL session:abc123

HINCRBY user:42 login_count 1

HEXISTS session:abc123 role

EXPIRE session:abc123 1800

Set — 중복 없는 집합

Set은 중복을 허용하지 않는 집합으로, 교집합·합집합·차집합 연산을 지원합니다. 좋아요 기능에서 중복 클릭 방지, 온라인 사용자 추적, 공통 팔로워 찾기에 활용됩니다.

로컬 터미널
SADD likes:post:42 "user:7"
SADD likes:post:42 "user:15"
SADD likes:post:42 "user:7"

SCARD likes:post:42

SISMEMBER likes:post:42 "user:7"

SREM likes:post:42 "user:15"

SINTERSTORE common_followers followers:alice followers:bob

SADD online_users "user:42"
SCARD online_users
SMEMBERS online_users

ZSet (Sorted Set) — 순위와 점수

ZSet은 각 멤버에 score를 부여해 자동 정렬합니다. ZINCRBY는 원자적으로 점수를 변경합니다. ZREVRANGE는 점수 내림차순으로 상위 N명을 조회합니다.

로컬 터미널
ZADD leaderboard 15000 "user:alice"
ZADD leaderboard 23000 "user:bob"
ZADD leaderboard 8500  "user:carol"
ZADD leaderboard 23000 "user:dave"

ZREVRANGE leaderboard 0 2 WITHSCORES

ZREVRANK leaderboard "user:bob"

ZSCORE leaderboard "user:alice"

ZRANGEBYSCORE leaderboard 10000 30000 WITHSCORES

ZINCRBY leaderboard 5000 "user:alice"

TTL 관리 명령어

TTL은 Redis를 캐시로 사용할 때 가장 중요한 개념입니다. 모든 캐시 데이터에는 반드시 TTL을 설정하세요. PERSIST는 TTL을 제거해 영구 저장으로 전환합니다.

로컬 터미널
EXPIRE key 3600
EXPIREAT key 1711234567
PEXPIRE key 5000

TTL key
PTTL key

PERSIST key

SET product:123 '...' 처럼 EX 옵션 없이 저장하면 해당 키는 영구 저장됩니다. 상품, 사용자 프로필, API 응답 같은 캐시 데이터를 TTL 없이 저장하면 시간이 지나면서 Redis 메모리가 계속 증가합니다. maxmemory 제한에 도달하면 Redis는 maxmemory-policy 설정에 따라 기존 키를 삭제하거나(allkeys-lru) 새 쓰기를 거부합니다(noeviction).

모든 캐시 데이터에는 SET key value EX 300 처럼 TTL을 필수로 설정하세요. 세션 데이터는 EXPIRE session:abc 1800으로 마지막 접근 시 TTL을 갱신해 활성 세션이 만료되지 않도록 합니다. 영구 저장이 필요한 데이터는 Redis가 아닌 데이터베이스에 저장하세요.

💡개념

캐싱 패턴과 세션 관리 — Cache-Aside와 TTL 전략

DB 캐싱을 Redis로 구현했는데, DB를 업데이트한 직후에도 캐시에서 이전 데이터를 돌려줍니다. 캐시 무효화를 빠뜨린 겁니다. TTL을 길게 잡으면 오래된 데이터가 노출되고, 너무 짧게 잡으면 캐시 히트율이 떨어져 DB 부하가 줄지 않습니다. 세션을 Redis에 넣었는데 TTL이 지나도 로그인이 유지되는 경우도 있습니다. 캐싱은 패턴 없이 구현하면 일관성 문제와 메모리 낭비가 동시에 발생합니다.

캐싱 패턴과 세션 관리 — Cache-Aside와 TTL 전략

Cache-Aside 패턴 (Lazy Loading)

Cache-Aside는 읽기 시에만 캐시를 채우는 가장 일반적인 캐싱 패턴입니다. 쓰기 시에는 캐시를 업데이트하지 않고 삭제합니다. 캐시를 업데이트하면 DB 쓰기와 캐시 쓰기를 원자적으로 처리할 수 없어 불일치가 발생할 수 있기 때문입니다.

Python
import redis
import json

r = redis.Redis(host='localhost', port=6379, db=0)

def get_product(product_id: int):
    cache_key = f"product:{product_id}"

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

    product = db.query("SELECT * FROM products WHERE id = %s", product_id)
    if not product:
        return None

    r.setex(cache_key, 300, json.dumps(product))
    return product

def update_product(product_id: int, data: dict):
    db.execute("UPDATE products SET price=%s WHERE id=%s",
               data['price'], product_id)

    r.delete(f"product:{product_id}")

Write-Through vs Cache-Aside 비교

쓰기 패턴에 따라 캐시 전략을 선택합니다. 읽기가 훨씬 많은 서비스에는 Cache-Aside가, 읽기와 쓰기가 균형 잡힌 서비스에는 Write-Through가 적합합니다.

항목Cache-AsideWrite-Through
쓰기 방식DB 쓰기 → 캐시 삭제DB 쓰기 + 캐시 쓰기 동시
캐시 일관성잠깐의 stale 가능항상 최신
쓰기 지연낮음높음 (캐시+DB 모두 대기)
사용 패턴읽기 위주읽기/쓰기 균형
복잡성낮음높음

세션을 Redis에 저장하는 이유

서버 메모리에 세션을 저장하면 특정 서버에서만 세션이 유효하므로 수평 확장이 불가합니다. Redis에 세션을 저장하면 어느 서버에서도 같은 세션을 읽을 수 있고, TTL로 자동 만료되며, 인메모리라 조회가 빠릅니다.

Python
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: 'redis://localhost:6379' });
await redisClient.connect();

app.use(session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
        secure: process.env.NODE_ENV === 'production',
        httpOnly: true,
        maxAge: 30 * 60 * 1000
    }
}));

Redis에 저장된 세션을 직접 확인하는 명령어입니다.

로컬 터미널
KEYS sess:*
TTL sess:abc123
HGETALL sess:abc123
💼
실무 맥락세션 토큰, API 응답 캐싱, Rate Limiting에서 Redis 선택 기준
현업 패턴

Redis는 각 역할마다 선택 기준이 다릅니다.

세션 토큰 저장은 Redis의 핵심 용도입니다. JWT를 사용하면 Redis가 불필요하지만, 강제 로그아웃이나 세션 무효화가 필요한 서비스(금융, 관리자 대시보드)라면 세션을 Redis에 저장하고 로그아웃 시 삭제하는 방식이 필요합니다.

API 응답 캐싱은 DB 쿼리가 느리거나 외부 API 호출이 빈번한 경우 효과적입니다. TTL은 데이터 신선도 요구사항에 따라 설정하세요. 실시간성이 중요한 재고 수량은 TTL을 10초, 변경이 드문 상품 설명은 1시간으로 설정할 수 있습니다.

Rate Limiting은 INCREXPIRE를 조합해 구현합니다. SET ratelimit:user:42:2024-03-15T10 0 EX 60으로 1분 단위 카운터를 만들고 INCR로 증가시켜 임계값을 초과하면 요청을 거부합니다. 원자성이 중요한 경우 Lua 스크립트나 Redis 모듈인 RedisBloom을 활용하세요.

Cache Stampede 문제와 대응

인기 있는 캐시 항목의 TTL이 만료되는 순간, 동시에 수천 개 요청이 캐시 미스를 경험하고 모두 DB로 몰립니다. DB가 과부하되어 응답이 느려지고 연쇄 장애로 이어질 수 있습니다.

인기 상품 페이지의 캐시 TTL이 오전 9시 정각에 만료될 때, 수천 명의 사용자 요청이 동시에 캐시 미스를 경험합니다. 모든 요청이 DB 조회를 시작하고 DB는 순간적인 과부하로 응답 시간이 급증합니다. 캐시가 채워지기 전에 타임아웃이 발생하면 더 많은 재시도가 몰려 상황이 악화됩니다.

두 가지 대응 전략이 있습니다. Mutex Lock은 첫 번째 요청만 DB에 접근해 캐시를 채우고, 나머지는 락이 해제될 때까지 대기했다가 캐시에서 읽도록 합니다. Probabilistic Early Expiration은 TTL 만료 전에 확률적으로 일부 요청이 미리 캐시를 갱신해 만료 순간의 폭발적 동시 접근을 방지합니다. 트래픽이 매우 높은 서비스라면 두 전략을 함께 사용하세요.

Mutex Lock 구현입니다. NX=없을 때만 옵션으로 첫 번째 요청만 락을 획득하고 DB를 조회해 캐시를 채웁니다. 락 획득에 실패한 요청은 잠시 대기 후 캐시에서 읽습니다.

Python
import redis
import time

def get_product_with_lock(product_id: int):
    cache_key = f"product:{product_id}"
    lock_key = f"lock:product:{product_id}"

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

    acquired = r.set(lock_key, "1", nx=True, ex=5)

    if acquired:
        try:
            product = db.get_product(product_id)
            r.setex(cache_key, 300, json.dumps(product))
            return product
        finally:
            r.delete(lock_key)
    else:
        time.sleep(0.1)
        cached = r.get(cache_key)
        return json.loads(cached) if cached else None

Probabilistic Early Expiration 구현입니다. 남은 TTL이 적을수록 갱신 확률이 높아져 만료 직전에 일부 요청이 미리 캐시를 갱신합니다.

Python
import math
import random

def get_with_early_refresh(key: str, ttl: int, fetch_fn):
    result = r.get(key)
    remaining_ttl = r.ttl(key)

    if result is None or should_refresh(remaining_ttl, ttl):
        fresh_data = fetch_fn()
        r.setex(key, ttl, json.dumps(fresh_data))
        return fresh_data

    return json.loads(result)

def should_refresh(remaining: int, total: int) -> bool:
    if remaining <= 0:
        return True
    probability = math.exp(-remaining / (total * 0.1))
    return random.random() < probability

Redis Pub/Sub — 실시간 알림

Pub/Sub은 발행자가 채널에 메시지를 보내면 구독자가 실시간으로 수신하는 패턴입니다. 구독자가 연결되어 있지 않으면 메시지가 유실됩니다(Fire-and-Forget). 메시지 내구성이 필요하면 Redis Streams 또는 Kafka 사용을 검토하세요.

로컬 터미널
SUBSCRIBE notifications:user:42

PUBLISH notifications:user:42 '{"type":"comment","message":"새 댓글이 달렸습니다"}'
Python
def subscribe_notifications(user_id: int):
    pubsub = r.pubsub()
    pubsub.subscribe(f"notifications:user:{user_id}")

    for message in pubsub.listen():
        if message['type'] == 'message':
            data = json.loads(message['data'])
            send_websocket(user_id, data)

def publish_notification(user_id: int, notification: dict):
    r.publish(f"notifications:user:{user_id}", json.dumps(notification))

다음 모듈에서는 서비스 요구사항에 맞는 RDBMS vs NoSQL 선택 기준과 판단 프레임워크를 다룹니다.

지식 확인

퀴즈 — 5문제

Q1

게임 서버에서 플레이어 점수를 실시간으로 업데이트하고, 상위 100명의 랭킹을 즉시 보여줘야 한다. Redis의 어떤 자료구조가 가장 적합한가?

Q2

Cache-Aside 패턴에서 데이터 쓰기 시 올바른 순서는?

Q3

동일한 데이터를 PostgreSQL에서 조회하면 평균 5ms, Redis에서는 0.1ms이다. 50배 차이가 나는 가장 근본적인 이유는?

Q4

인기 상품 페이지 캐시를 TTL 60초로 설정했다. 10,000명이 동시에 페이지를 보고 있을 때 캐시가 만료되면 어떤 일이 벌어지는가?

Q5

사용자 객체의 특정 필드만 자주 업데이트할 때 Redis String보다 Hash가 유리한 이유는?

0 / 5 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Ubuntu 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

40📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

database중급 · 50
[Database] SQL Injection 예방과 최소 권한 접근 제어, 데이터 암호화
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점