🗄️ 데이터베이스

Key-Value Store

키-값 저장소

고유한 키를 통해 값을 저장하고 조회하는 NoSQL 데이터베이스로, Redis, DynamoDB, etcd 등이 대표적이며 캐싱, 세션 관리, 설정 저장 등에 활용됩니다.

📖 상세 설명

Key-Value Store(키-값 저장소)는 데이터를 고유한 키와 해당 키에 연결된 값의 쌍으로 저장하는 가장 단순한 형태의 NoSQL 데이터베이스입니다. 해시 테이블과 유사한 구조로, O(1) 시간 복잡도로 데이터를 읽고 쓸 수 있어 극도로 빠른 성능을 제공합니다.

대표적인 Key-Value Store로는 Redis(인메모리, 다양한 자료구조 지원), Amazon DynamoDB(완전관리형, 서버리스), etcd(분산 시스템 설정 저장), Memcached(순수 캐시) 등이 있습니다. 각각 특화된 용도가 있어, 요구사항에 맞게 선택해야 합니다.

주요 사용 사례로는 캐싱(데이터베이스 쿼리 결과, API 응답 캐시), 세션 관리(웹 애플리케이션 사용자 세션 저장), 실시간 데이터(리더보드, 조회수, 좋아요 카운터), 분산 락(동시성 제어), 메시지 큐(Redis의 Pub/Sub, Streams) 등이 있습니다.

Key-Value Store의 한계도 있습니다. 키로만 조회 가능하여 복잡한 쿼리나 범위 검색이 어렵습니다(DynamoDB는 Sort Key로 일부 해결). 데이터 간 관계 표현이 불가하며, 트랜잭션 지원이 제한적입니다. 따라서 RDBMS와 함께 보조 저장소로 사용하는 경우가 많습니다.

💻 코드 예제

Python Redis 활용 예제

import redis
import json
from datetime import timedelta
from typing import Optional, Any

# Redis 클라이언트 초기화
redis_client = redis.Redis(
    host='localhost',
    port=6379,
    db=0,
    decode_responses=True  # 문자열 자동 디코딩
)

# ================================
# 1. 기본 Key-Value 연산
# ================================
def cache_user(user_id: int, user_data: dict, ttl_seconds: int = 3600):
    """사용자 정보를 캐시에 저장"""
    key = f"user:{user_id}"
    redis_client.setex(key, ttl_seconds, json.dumps(user_data))

def get_cached_user(user_id: int) -> Optional[dict]:
    """캐시에서 사용자 정보 조회"""
    key = f"user:{user_id}"
    data = redis_client.get(key)
    return json.loads(data) if data else None

# 사용
cache_user(123, {"name": "홍길동", "email": "hong@example.com"})
user = get_cached_user(123)
print(user)  # {'name': '홍길동', 'email': 'hong@example.com'}


# ================================
# 2. 캐시 패턴: Cache-Aside
# ================================
def get_user_with_cache(user_id: int, db_session) -> dict:
    """캐시 우선 조회, 없으면 DB에서 로드"""
    cache_key = f"user:{user_id}"

    # 1. 캐시 확인
    cached = redis_client.get(cache_key)
    if cached:
        print("Cache HIT")
        return json.loads(cached)

    # 2. 캐시 미스 -> DB 조회
    print("Cache MISS")
    user = db_session.query(User).get(user_id)
    if user:
        user_dict = {"id": user.id, "name": user.name, "email": user.email}
        # 3. 캐시에 저장 (1시간 TTL)
        redis_client.setex(cache_key, 3600, json.dumps(user_dict))
        return user_dict
    return None


# ================================
# 3. 분산 락 구현
# ================================
import uuid

def acquire_lock(lock_name: str, timeout: int = 10) -> Optional[str]:
    """분산 락 획득"""
    lock_key = f"lock:{lock_name}"
    lock_id = str(uuid.uuid4())

    # SET NX (Not eXists) - 키가 없을 때만 설정
    acquired = redis_client.set(lock_key, lock_id, nx=True, ex=timeout)
    return lock_id if acquired else None

def release_lock(lock_name: str, lock_id: str) -> bool:
    """분산 락 해제 (Lua 스크립트로 원자적 처리)"""
    lock_key = f"lock:{lock_name}"

    # Lua 스크립트: 본인이 획득한 락인지 확인 후 삭제
    script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    result = redis_client.eval(script, 1, lock_key, lock_id)
    return result == 1

# 사용
lock_id = acquire_lock("payment:order:123")
if lock_id:
    try:
        # 결제 처리 (동시에 하나만 실행됨)
        process_payment(order_id=123)
    finally:
        release_lock("payment:order:123", lock_id)


# ================================
# 4. 조회수/카운터 (Atomic Operations)
# ================================
def increment_view_count(article_id: int) -> int:
    """게시글 조회수 증가 (원자적 연산)"""
    key = f"article:{article_id}:views"
    return redis_client.incr(key)

def get_view_count(article_id: int) -> int:
    """조회수 조회"""
    key = f"article:{article_id}:views"
    count = redis_client.get(key)
    return int(count) if count else 0


# ================================
# 5. 리더보드 (Sorted Set 활용)
# ================================
def update_leaderboard(game_id: str, user_id: str, score: float):
    """리더보드 점수 업데이트"""
    key = f"leaderboard:{game_id}"
    redis_client.zadd(key, {user_id: score})

def get_top_players(game_id: str, count: int = 10) -> list:
    """상위 N명 조회 (점수 내림차순)"""
    key = f"leaderboard:{game_id}"
    # ZREVRANGE: 점수 높은 순으로 조회
    return redis_client.zrevrange(key, 0, count - 1, withscores=True)

def get_user_rank(game_id: str, user_id: str) -> Optional[int]:
    """특정 사용자의 순위 조회 (0부터 시작)"""
    key = f"leaderboard:{game_id}"
    rank = redis_client.zrevrank(key, user_id)
    return rank + 1 if rank is not None else None  # 1부터 시작하도록

# 사용
update_leaderboard("game1", "user:123", 1500)
update_leaderboard("game1", "user:456", 2200)
top_10 = get_top_players("game1", 10)
my_rank = get_user_rank("game1", "user:123")
print(f"내 순위: {my_rank}위")

🗣️ 실무에서 이렇게 말해요

백엔드 개발자 "상품 상세 API 응답이 200ms 넘어요. DB 쿼리가 무거운데, Redis 캐시 도입하면 어떨까요?"
아키텍트 "좋아요. Cache-Aside 패턴으로 구현하고, TTL은 5분 정도로 설정하세요. 상품 정보 업데이트 시 캐시 무효화도 잊지 마시고요."
인프라 엔지니어 "Redis Cluster로 구성해서 고가용성 확보하고, 메모리 모니터링도 설정해둘게요. maxmemory-policy는 allkeys-lru로 할게요."
면접관 "Redis와 Memcached의 차이점은 무엇인가요?"
지원자 "둘 다 인메모리 Key-Value Store이지만, Redis는 String 외에 List, Set, Hash, Sorted Set 등 다양한 자료구조를 지원하고 데이터 영속성(RDB, AOF)도 제공합니다. Memcached는 단순 캐시에 특화되어 메모리 효율이 좋고 멀티스레드를 지원해요. 단순 캐시는 Memcached, 복잡한 데이터 구조나 영속성이 필요하면 Redis를 선택합니다."
리뷰어 "여기서 get 하고 없으면 set 하는데, 이 사이에 race condition이 발생할 수 있어요."
작성자 "맞아요. SETNX(SET if Not eXists)나 Lua 스크립트로 원자적으로 처리하겠습니다. 분산 환경에서는 이런 부분이 중요하죠."

⚠️ 주의사항

  • 캐시 일관성: 원본 데이터 변경 시 캐시 무효화를 잊으면 stale data가 반환됩니다. Write-through, Cache-aside 등 일관성 전략을 명확히 정의하세요.
  • 메모리 관리: 인메모리 저장소는 메모리 한계가 있습니다. maxmemory 설정과 eviction 정책(LRU, LFU 등)을 적절히 구성하고 모니터링하세요.
  • 키 네이밍 컨벤션: 일관된 키 네이밍 규칙(예: `entity:id:field`)을 정해서 관리 용이성을 높이고, 키 충돌을 방지하세요.

📚 더 배우기