Moment Note

Redis 캐싱 전략으로 API 응답속도 10배 개선하기

서버 & 인프라 ·

API 응답속도가 느리다는 민원이 들어오면 가장 먼저 손이 가는 해결책이 Redis 캐시입니다. 잘 적용하면 응답 지연을 100ms에서 5ms 이하로 줄이는 것이 현실적으로 가능하지만, 잘못 설계하면 캐시 스탬피드, 데이터 불일치, 무효화 실패 같은 더 복잡한 문제를 만듭니다. 이 글에서는 캐시 패턴 선택부터 TTL 설계, 캐시 스탬피드 방지, 그리고 실제 운영 코드까지 체계적으로 살펴봅니다.

캐시 패턴 선택: Cache-Aside vs Write-Through

캐싱 전략의 출발점은 “언제 캐시에 쓰고 언제 읽느냐”입니다. 주요 패턴 두 가지를 비교합니다.

항목 Cache-Aside (Lazy Loading) Write-Through
읽기 흐름 캐시 미스 → DB 조회 → 캐시 저장 → 반환 캐시 히트 시 즉시 반환
쓰기 흐름 DB에만 쓰고 캐시는 무효화(또는 방치) DB 쓰기와 동시에 캐시도 업데이트
콜드 스타트 첫 요청은 반드시 DB 조회(캐시 미스) 쓰기 시 캐시 채워지므로 미스 적음
데이터 일관성 TTL 만료 전까지 stale 가능 항상 최신 데이터
구현 복잡도 낮음 중간 (쓰기 경로 두 곳 처리)
적합한 상황 읽기 편향, 데이터 변경 빈도 낮음 읽기/쓰기 균형, 일관성 중요

실무에서는 Cache-Aside가 가장 흔히 쓰입니다. 애플리케이션이 캐시를 완전히 제어하므로 장애 격리가 쉽고, Redis가 다운되어도 DB 직접 조회로 graceful fallback이 됩니다. Write-Through는 금융·재고처럼 데이터 정합성이 절대적인 도메인에서 선택합니다.

Cache-Aside 구현 예시

Python(FastAPI) + Redis 예시입니다. 실제 서비스에서 사용 가능한 수준의 코드를 보여줍니다.

import redis.asyncio as aioredis
import json
from functools import wraps

redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)

async def get_product(product_id: int) -> dict:
    cache_key = f"product:{product_id}"

    # 1. 캐시 조회
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. 캐시 미스: DB 조회
    product = await db.fetch_one(
        "SELECT * FROM products WHERE id = $1", product_id
    )
    if not product:
        return None

    # 3. 캐시 저장 (TTL 5분)
    await redis_client.setex(
        cache_key,
        300,
        json.dumps(dict(product))
    )
    return dict(product)

async def update_product(product_id: int, data: dict):
    # DB 업데이트
    await db.execute(
        "UPDATE products SET name=$1 WHERE id=$2",
        data["name"], product_id
    )
    # 캐시 무효화
    await redis_client.delete(f"product:{product_id}")
# Write-Through 패턴 예시 (Redis Pipeline 활용)
async def update_product_write_through(product_id: int, data: dict):
    async with redis_client.pipeline(transaction=True) as pipe:
        # DB 먼저 업데이트
        updated = await db.fetch_one(
            "UPDATE products SET name=$1 WHERE id=$2 RETURNING *",
            data["name"], product_id
        )
        # Redis 동시 업데이트
        cache_key = f"product:{product_id}"
        pipe.setex(cache_key, 300, json.dumps(dict(updated)))
        await pipe.execute()
    return dict(updated)

TTL 전략과 무효화 설계

TTL(Time-To-Live)은 데이터 신선도와 캐시 히트율의 트레이드오프입니다. 단순히 “길면 좋다”가 아니라 도메인 특성에 맞게 계층적으로 설계해야 합니다.

  • 정적 데이터 (카테고리, 설정값): TTL 1~24시간. 변경 시 이벤트 기반 무효화 병행.
  • 세션성 데이터 (상품 목록, 검색 결과): TTL 5~30분. 사용자 경험 허용 범위 내 최대치.
  • 실시간 데이터 (재고, 가격): TTL 10~60초 또는 캐시 불사용 후 DB 직접 조회.
  • 사용자별 데이터 (장바구니, 개인화): TTL = 세션 수명. 로그아웃 시 명시적 삭제.

TTL만으로 무효화를 처리하면 변경 즉시 갱신이 안 됩니다. 이를 보완하는 방법이 이벤트 기반 무효화입니다. DB 변경 이벤트가 발생하면 관련 캐시 키를 즉시 삭제하거나, Pub/Sub로 여러 서버의 로컬 캐시도 함께 지웁니다.

# 태그 기반 그룹 무효화 (Redis Sets 활용)
async def cache_with_tag(key: str, value: str, tags: list, ttl: int):
    """태그로 관련 캐시를 묶어 일괄 무효화"""
    pipe = redis_client.pipeline()
    pipe.setex(key, ttl, value)
    for tag in tags:
        pipe.sadd(f"tag:{tag}", key)         # 태그 → 키 집합
        pipe.expire(f"tag:{tag}", ttl + 60)  # 태그 TTL은 약간 더 길게
    await pipe.execute()

async def invalidate_by_tag(tag: str):
    """특정 태그에 속한 모든 캐시 키 삭제"""
    keys = await redis_client.smembers(f"tag:{tag}")
    if keys:
        pipe = redis_client.pipeline()
        for key in keys:
            pipe.delete(key)
        pipe.delete(f"tag:{tag}")
        await pipe.execute()

# 사용 예: 특정 카테고리 관련 캐시 전체 무효화
await cache_with_tag(
    key="products:category:electronics",
    value=json.dumps(products),
    tags=["category:electronics", "product-list"],
    ttl=300
)
await invalidate_by_tag("category:electronics")  # 카테고리 업데이트 시

캐시 스탬피드 방지

캐시 스탬피드(Cache Stampede)는 TTL이 만료된 순간 수백~수천 개의 요청이 동시에 캐시 미스를 감지하고 DB로 몰리는 현상입니다. 이때 DB에 순간 폭발적인 부하가 걸리고, 응답 지연이 역설적으로 캐시 없을 때보다 더 심해질 수 있습니다.

방지 전략은 크게 세 가지입니다.

1. Mutex Lock (단순하지만 효과적)

import asyncio

_locks: dict[str, asyncio.Lock] = {}

async def get_with_lock(cache_key: str, fetch_fn) -> dict:
    """첫 번째 요청만 DB 조회, 나머지는 대기 후 캐시 히트"""
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    if cache_key not in _locks:
        _locks[cache_key] = asyncio.Lock()

    async with _locks[cache_key]:
        # 락 획득 후 다시 확인 (이미 다른 요청이 채웠을 수 있음)
        cached = await redis_client.get(cache_key)
        if cached:
            return json.loads(cached)

        result = await fetch_fn()
        await redis_client.setex(cache_key, 300, json.dumps(result))
        return result

2. Probabilistic Early Expiration (XFetch 알고리즘)

import math, random, time

async def get_with_xfetch(cache_key: str, fetch_fn, ttl: int = 300, beta: float = 1.0):
    """TTL 만료 전에 확률적으로 선제 갱신 (스탬피드 방지)"""
    data = await redis_client.get(cache_key)
    expiry = await redis_client.ttl(cache_key)

    if data:
        # XFetch: 남은 TTL이 짧을수록 갱신 확률 증가
        delta = time.time()  # 재계산 비용 근사
        if expiry - beta * delta * math.log(random.random()) > 0:
            return json.loads(data)

    # 캐시 미스 또는 선제 갱신 트리거
    result = await fetch_fn()
    await redis_client.setex(cache_key, ttl, json.dumps(result))
    return result

3. Stale-While-Revalidate: 만료된 캐시를 즉시 반환하면서 백그라운드에서 갱신합니다. 응답 지연은 없지만 일시적으로 stale 데이터를 서빙합니다. 뉴스피드, 랭킹 같이 약간의 지연이 허용되는 도메인에 적합합니다.

실전 팁: 계층 캐시와 운영 주의사항

Redis 앞에 로컬 인메모리 캐시(예: Python의 cachetools, Java의 Caffeine)를 두는 계층 캐시(L1: 로컬, L2: Redis)를 쓰면 Redis 네트워크 왕복도 줄일 수 있습니다. L1 TTL을 1~5초로 짧게 잡으면 정합성 손실은 최소화하면서 초당 수천 요청의 Redis 부하를 크게 줄입니다.

  • 캐시 키 네이밍: 서비스:엔티티:ID:버전 형태로 표준화. 예) api:product:12345:v2
  • 직렬화 포맷: JSON은 가독성이 좋지만 MessagePack/CBOR이 크기와 속도에서 20~40% 유리.
  • 메모리 정책: Redis maxmemory-policy allkeys-lru로 메모리 초과 시 자동 퇴거 설정. 기본값 noeviction은 OOM 위험.
  • 핫키 분산: 특정 키에 트래픽이 집중되면 Redis 단일 노드 병목. 키에 샤드 접미사(:0~:N)를 붙여 분산.
  • 캐시 워밍: 서비스 재시작 후 콜드 스타트 방지를 위해 배포 직후 주요 키를 미리 채우는 워밍 스크립트 실행.

마무리

Redis 캐싱으로 API 응답속도를 극적으로 개선하는 것은 충분히 달성 가능한 목표이지만, 패턴 선택과 무효화 전략이 핵심입니다. Cache-Aside로 시작해서 도메인별 TTL을 세밀하게 조정하고, 스탬피드 방지 메커니즘을 심어두면 트래픽 폭증에도 안정적인 캐시 레이어를 운영할 수 있습니다. 캐싱은 “일단 다 캐시하자”가 아니라 “어떤 데이터를 얼마나 신뢰하고 얼마나 오래 서빙할 것인가”에 대한 도메인 판단입니다.

자주 묻는 질문

Q. Redis가 다운되었을 때 서비스 전체가 멈추지 않으려면 어떻게 해야 하나요?

A. Cache-Aside 패턴에서는 Redis 접근 실패 시 예외를 잡고 DB 직접 조회로 fallback하는 코드를 짜면 됩니다. try/except로 Redis 호출을 감싸고 캐시 미스 상황과 동일하게 처리하세요. Redis Sentinel이나 Cluster 구성으로 고가용성을 확보하면 단일 장애점을 제거할 수 있습니다. 읽기 전용 Replica를 두어 Primary 장애 시 자동 승격하는 구성도 권장됩니다.

Q. TTL 없이 캐시를 운영하면 어떤 문제가 생기나요?

A. 메모리 사용량이 무한히 늘어나고, 데이터 변경이 캐시에 반영되지 않아 영구적으로 stale 데이터가 서빙됩니다. Redis maxmemory 설정과 allkeys-lru 퇴거 정책을 쓰면 자동 정리가 되지만, 어떤 키가 언제 삭제될지 예측 불가능해집니다. 최소한 도메인별로 의미 있는 TTL을 설정하고, 명시적 무효화를 병행하는 것이 올바른 설계입니다.

Q. 캐시 히트율이 낮으면 오히려 Redis가 성능에 해가 되지 않나요?

A. 맞습니다. 캐시 히트율이 50% 미만이면 캐시 미스마다 Redis 왕복 + DB 조회라는 이중 비용이 발생해 캐시 없는 것보다 느려질 수 있습니다. Prometheus의 cache_hit_ratio 지표를 모니터링하고 히트율이 70% 아래라면 TTL을 늘리거나, 캐시 키 설계를 재검토해야 합니다. 캐시 키가 너무 세분화되어 있거나(예: 요청 파라미터 전체를 키에 포함) 데이터 변경 빈도가 지나치게 높은 경우가 주요 원인입니다.