[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
)
- 서버 시작 시 Redis 상태 확인 (
⚠️ 참고: 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
가. 동작 순서:
redis.get(key)
- Redis는 문자열 기반 저장소라서 우리가 넣은 값도 문자열로 반환됨 (예:
"[0.1, 0.2, 0.3]"
).
- Redis는 문자열 기반 저장소라서 우리가 넣은 값도 문자열로 반환됨 (예:
json.loads(value)
- 문자열을 리스트로 파싱함 → 예:
"[0.1, 0.2, 0.3]"
→[0.1, 0.2, 0.3]
(Python 리스트)
- 문자열을 리스트로 파싱함 → 예:
torch.tensor(...)
- 리스트를 PyTorch 텐서로 변환함 →
tensor([0.1, 0.2, 0.3])
- 리스트를 PyTorch 텐서로 변환함 →
나. 왜 이런 구조인가? (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를 써도 병렬 처리 성능 개선이 거의 없음, 오히려 컨텍스트 스위칭 오버헤드만 생김.
- 캐시 접근은 매우 빠른 연산인데 병렬화 비용이 더 커져버리는 경우도 발생합니다.