226 lines
8.5 KiB
Python
226 lines
8.5 KiB
Python
# app/utils/redis_client.py
|
|
"""
|
|
HealthSync AI Redis 캐시 클라이언트 (Azure Cache for Redis 지원)
|
|
"""
|
|
import redis.asyncio as redis
|
|
import json
|
|
import logging
|
|
from typing import Any, Optional, List
|
|
from app.config.settings import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RedisClient:
|
|
"""Redis 캐시 연동 클라이언트 (Azure Cache for Redis 지원)"""
|
|
|
|
def __init__(self):
|
|
self.redis_host = settings.redis_host
|
|
self.redis_port = settings.redis_port
|
|
self.redis_password = settings.redis_password
|
|
self.redis_db = settings.redis_db
|
|
self.default_ttl = settings.redis_cache_ttl
|
|
self.client = None
|
|
self._connected = False
|
|
|
|
async def connect(self):
|
|
"""Azure Cache for Redis 연결"""
|
|
if self._connected:
|
|
return
|
|
|
|
try:
|
|
# Azure Cache for Redis 연결 설정
|
|
if self.redis_password and self.redis_password != "":
|
|
# Azure Cache for Redis (SSL + 인증)
|
|
self.client = redis.Redis(
|
|
host=self.redis_host,
|
|
port=self.redis_port,
|
|
password=self.redis_password,
|
|
db=self.redis_db,
|
|
ssl=True, # Azure Cache는 SSL 필수
|
|
ssl_cert_reqs=None, # SSL 인증서 검증 비활성화
|
|
decode_responses=True,
|
|
socket_timeout=10,
|
|
socket_connect_timeout=10,
|
|
retry_on_timeout=True,
|
|
health_check_interval=30
|
|
)
|
|
logger.info(f"🔐 Azure Cache for Redis 연결 시도 - {self.redis_host}:{self.redis_port}")
|
|
else:
|
|
# 로컬 Redis (비SSL)
|
|
self.client = redis.Redis(
|
|
host=self.redis_host,
|
|
port=self.redis_port,
|
|
db=self.redis_db,
|
|
decode_responses=True,
|
|
socket_timeout=5,
|
|
socket_connect_timeout=5
|
|
)
|
|
logger.info(f"🔓 로컬 Redis 연결 시도 - {self.redis_host}:{self.redis_port}")
|
|
|
|
# 연결 테스트
|
|
await self.client.ping()
|
|
self._connected = True
|
|
logger.info(f"✅ Redis 클라이언트 연결 완료 - {self.redis_host}:{self.redis_port}")
|
|
|
|
except redis.AuthenticationError as e:
|
|
logger.error(f"❌ Redis 인증 실패 - 패스워드 확인 필요: {str(e)}")
|
|
raise Exception(f"Redis 인증 실패: {str(e)}")
|
|
except redis.ConnectionError as e:
|
|
logger.error(f"❌ Redis 연결 실패 - 호스트/포트 확인 필요: {str(e)}")
|
|
raise Exception(f"Redis 연결 실패: {str(e)}")
|
|
except redis.TimeoutError as e:
|
|
logger.error(f"❌ Redis 연결 타임아웃: {str(e)}")
|
|
raise Exception(f"Redis 연결 타임아웃: {str(e)}")
|
|
except Exception as e:
|
|
logger.error(f"❌ Redis 연결 실패: {str(e)}")
|
|
raise Exception(f"Redis 연결 실패: {str(e)}")
|
|
|
|
async def disconnect(self):
|
|
"""Redis 연결 해제"""
|
|
if self.client and self._connected:
|
|
try:
|
|
await self.client.aclose() # redis.asyncio의 올바른 종료 메소드
|
|
self._connected = False
|
|
logger.info("✅ Redis 연결 해제 완료")
|
|
except Exception as e:
|
|
logger.error(f"❌ Redis 연결 해제 실패: {str(e)}")
|
|
|
|
async def get(self, key: str) -> Optional[Any]:
|
|
"""캐시에서 값 조회"""
|
|
try:
|
|
if not self._connected:
|
|
await self.connect()
|
|
|
|
value = await self.client.get(key)
|
|
if value:
|
|
result = json.loads(value)
|
|
logger.info(f"✅ 캐시 조회 성공 - key: {key}")
|
|
return result
|
|
else:
|
|
logger.info(f"❌ 캐시 미스 - key: {key}")
|
|
return None
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"❌ JSON 파싱 실패 - key: {key}, error: {str(e)}")
|
|
return None
|
|
except redis.ConnectionError as e:
|
|
logger.error(f"❌ Redis 연결 오류 - key: {key}, error: {str(e)}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"❌ 캐시 조회 실패 - key: {key}, error: {str(e)}")
|
|
return None
|
|
|
|
async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
|
"""캐시에 값 저장"""
|
|
try:
|
|
if not self._connected:
|
|
await self.connect()
|
|
|
|
ttl = ttl or self.default_ttl
|
|
json_value = json.dumps(value, ensure_ascii=False, default=str)
|
|
|
|
await self.client.setex(key, ttl, json_value)
|
|
logger.info(f"✅ 캐시 저장 성공 - key: {key}, ttl: {ttl}s")
|
|
return True
|
|
|
|
except redis.ConnectionError as e:
|
|
logger.error(f"❌ Redis 연결 오류 - key: {key}, error: {str(e)}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"❌ 캐시 저장 실패 - key: {key}, error: {str(e)}")
|
|
return False
|
|
|
|
async def delete(self, key: str) -> bool:
|
|
"""캐시에서 값 삭제"""
|
|
try:
|
|
if not self._connected:
|
|
await self.connect()
|
|
|
|
result = await self.client.delete(key)
|
|
if result:
|
|
logger.info(f"✅ 캐시 삭제 성공 - key: {key}")
|
|
return True
|
|
else:
|
|
logger.info(f"❌ 캐시 삭제 실패 (키 없음) - key: {key}")
|
|
return False
|
|
|
|
except redis.ConnectionError as e:
|
|
logger.error(f"❌ Redis 연결 오류 - key: {key}, error: {str(e)}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"❌ 캐시 삭제 실패 - key: {key}, error: {str(e)}")
|
|
return False
|
|
|
|
async def get_or_set(self, key: str, fetch_func, ttl: Optional[int] = None) -> Any:
|
|
"""캐시 조회 후 없으면 fetch_func 실행하여 저장 (Cache Aside 패턴)"""
|
|
try:
|
|
# 1. 캐시에서 조회
|
|
cached_value = await self.get(key)
|
|
if cached_value is not None:
|
|
return cached_value
|
|
|
|
# 2. 캐시 미스 시 데이터 fetch
|
|
fresh_value = await fetch_func()
|
|
if fresh_value is not None:
|
|
# 3. 캐시에 저장
|
|
await self.set(key, fresh_value, ttl)
|
|
return fresh_value
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Cache Aside 패턴 실행 실패 - key: {key}, error: {str(e)}")
|
|
# 캐시 실패 시에도 fresh_value 반환 시도
|
|
try:
|
|
return await fetch_func()
|
|
except:
|
|
return None
|
|
|
|
def generate_similar_users_key(self, user_id: int) -> str:
|
|
"""유사 사용자 캐시 키 생성"""
|
|
return f"similar_users:{user_id}"
|
|
|
|
def generate_mission_news_key(self, user_id: int) -> str:
|
|
"""미션 소식 캐시 키 생성 (짧은 TTL용)"""
|
|
return f"mission_news:{user_id}"
|
|
|
|
async def test_connection(self) -> dict:
|
|
"""Redis 연결 테스트"""
|
|
try:
|
|
if not self._connected:
|
|
await self.connect()
|
|
|
|
# 기본 명령 테스트
|
|
test_key = "healthsync:connection_test"
|
|
test_value = "test_connection_success"
|
|
|
|
await self.client.set(test_key, test_value, ex=10) # 10초 TTL
|
|
retrieved_value = await self.client.get(test_key)
|
|
await self.client.delete(test_key)
|
|
|
|
info = await self.client.info()
|
|
|
|
return {
|
|
"status": "connected",
|
|
"host": self.redis_host,
|
|
"port": self.redis_port,
|
|
"ssl_enabled": bool(self.redis_password),
|
|
"test_result": retrieved_value == test_value,
|
|
"redis_version": info.get("redis_version", "unknown"),
|
|
"connected_clients": info.get("connected_clients", 0),
|
|
"used_memory_human": info.get("used_memory_human", "unknown")
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"status": "failed",
|
|
"error": str(e),
|
|
"error_type": type(e).__name__,
|
|
"host": self.redis_host,
|
|
"port": self.redis_port
|
|
}
|
|
|
|
|
|
# 전역 클라이언트 인스턴스
|
|
redis_client = RedisClient() |