[Redis] 도입 진행 - 100-hours-a-week/5-yeosa-wiki GitHub Wiki

1. Redis

a. Redis 이용 방식 & 설정

가. Redis 비동기 이용

redis: 설치하여 비동기 방식으로 이용 (레디스에 await으로 요청을 보내서 cpu 제어권을 이벤트 루프에 반환. redis 요청 처리가 이벤트 루프를 블로킹하지 않도록 함)

나. 공간 절약(TTL 설정, 키 미설정)

TTL 설정: 캐시가 유지되는 시간 설정

키를 따로 두지 않고 하나의 해시로 묶으므로써 공간 절약하는 것도 고려할 수 있음

다. 커넥션풀 도입

  • 커넥션 풀 도입: redis는 단일 스레드지만 클라이언트는 동시 접속 요청을 처리해야 하기 때문에 커넥션 풀 필요.
  • 하나의 연결만 두면 해당 연결에 수신된 요청이 처리되어 응답될 때까지 다음 요청은 연결을 이용할 수 없음.
  • 코루틴을 통해 동시성을 구현하더라도 레디스 연결을 기다리는 과정이 직렬적으로 처리되어 동시성이 깨짐.
  • (처음에는 코루틴 이용해서 레디스에 요청 보낼 시, 병렬성이 확보되지 않으니 요청 자체는 하나씩 레디스 클라이언트에 들어가서 레디스 단일 스레드 구조니까 단일 연결이어도 괜찮지 않은가 싶었음)
  • (그러나 요청 처리 완료 후 연결을 반환해야 다음 요청이 해당 연결 이용 가능해짐. 이를 막기 위해 연결 여러 개 필요)

b. Redis 클라이언트 구성

가. 클라이언트 구성 코드

from redis.asyncio import Redis

redis = Redis(
    host="localhost",
    port=6379,
    decode_responses=True,       # str로 자동 디코딩
    max_connections=10,          # 커넥션 풀 크기
    socket_connect_timeout=3,    # 연결 시도 제한 시간 (초)
    socket_timeout=5,            # 명령 실행 후 응답 대기 시간
    retry_on_timeout=True,       # timeout 발생 시 재시도
)

나. 옵션 설명

옵션 설명
decode_responses=True Redis 값이 byte가 아닌 str로 반환되도록 함
max_connections=10 커넥션 풀 최대 크기 설정
socket_connect_timeout 연결 자체가 느릴 때 제한 (네트워크 문제 대비)
socket_timeout 명령 보내고 응답 기다리는 최대 시간
retry_on_timeout 타임아웃 시 내부적으로 재시도 (한 번)

c. Redis 서버의 Keep-Alive + 클라이언트 ping 동작 방식

가. 서버 측:

  • Redis는 기본적으로 TCP KeepAlive 설정을 통해 오래된 커넥션을 감지

  • 서버 설정 예시:

    tcp-keepalive 300  # 300초 동안 아무 요청 없으면 TCP 레벨에서 확인
    

나. 클라이언트 측:

  • redis-py (sync/async 모두)는 내부적으로 ping을 직접 보내지 않음
  • 대신, 커넥션이 idle 상태로 일정 시간 이상 지나면 다음 요청 시:
    • 사용 중인 커넥션이 죽었는지 확인
    • 죽었다면 자동 재연결

다. 그래서 개발자가 직접 ping() 안해도 되나?

  • 일반적으로는 필요 없음
  • 하지만 다음 경우엔 직접 ping()이 유용:
    • 서버 시작 시 Redis 상태 확인 (await redis.ping())
    • 헬스체크 API 구현할 때 (GET /health)

⚠️ 참고: retry_on_timeout=True 옵션이 없다면 타임아웃 시 실패로 끝남

d. 캐시 조회 코드

async def get_cached_embedding(key: str) -> Any | None:
    redis = get_redis()
    value = await redis.get(key)
    if value is None:
        return None
    try:
        return torch.tensor(json.loads(value))
    except Exception:
        return value

가. 동작 순서:

  1. redis.get(key)
    • Redis는 문자열 기반 저장소라서 우리가 넣은 값도 문자열로 반환됨 (예: "[0.1, 0.2, 0.3]").
  2. json.loads(value)
    • 문자열을 리스트로 파싱함 → 예: "[0.1, 0.2, 0.3]"[0.1, 0.2, 0.3] (Python 리스트)
  3. torch.tensor(...)
    • 리스트를 PyTorch 텐서로 변환함 → tensor([0.1, 0.2, 0.3])

나. 왜 이런 구조인가? (Json 변환 관련)

  • Redis에는 텐서를 그대로 저장할 수 없음 → JSON 문자열로 바꿔 저장.
  • 다시 사용할 때는 → JSON 디코드 → 텐서로 복원.

다. 기존 캐싱(파이썬 메모리 캐싱) 병렬화 get 병목 지점 식별

def get_cached_embeddings_parallel(
    keys: list[str], max_workers: int = 8
) -> tuple[list[Any | None], list[str]]:
    """
    병렬로 캐시에서 임베딩을 가져옵니다.

    Args:
        keys: 캐시 key 리스트
        max_workers: 병렬 스레드 수

    Returns:
        - List of cached values (index는 keys와 동일)
        - List of keys not found in cache

    """

    def check(key: str) -> tuple[str, Any | None]:
        return key, get_cached_embedding(key)

    results = [None] * len(keys)
    missing_keys = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        for i, (key, value) in enumerate(executor.map(check, keys)):
            results[i] = value
            if value is None:
                missing_keys.append(keys[i])
    return results, missing_keys

라. 기존 방식 병목 원인: cachetools에서 ThreadPoolExecutor 병렬화가 GIL 때문에 효율이 낮은 이유

  • 핵심: Python의 GIL(Global Interpreter Lock)
    • GIL은 동시에 하나의 스레드만 Python 바이트코드를 실행하게 제한합니다.
    • cachetools순수 Python 객체 기반 (딕셔너리 기반 캐시) 이므로, 접근 시 GIL의 영향을 받습니다.
    • 즉, ThreadPoolExecutor로 병렬 조회해도, 실제론 스레드들이 GIL을 경쟁하면서 순차적으로 실행됩니다.
  • 결과
    • ThreadPoolExecutor를 써도 병렬 처리 성능 개선이 거의 없음, 오히려 컨텍스트 스위칭 오버헤드만 생김.
    • 캐시 접근은 매우 빠른 연산인데 병렬화 비용이 더 커져버리는 경우도 발생합니다.