왜 AI API 캐싱이 중요한가?

저는 최근 한 전자상거래 플랫폼의 AI 검색 시스템 구축 프로젝트를 진행했습니다. 매일 수십만 건의 사용자 질문이 AI API를 호출하는데, 상당수가 반복되거나 유사한 패턴을 보였습니다. 실제로 로그를 분석해보니 전체 요청의 약 35%가 완전히 동일하거나 의미상 유사한 질문이었습니다.

AI API 비용은 아직 저렴하지만, 트래픽이 증가하면 이야기가 달라집니다. GPT-4.1은 출력 토큰당 $8, Claude Sonnet 4.5는 $15가 청구됩니다. 월 1,000만 토큰 규모에서 이 비용은 빠르게 누적됩니다.

이 글에서는 Redis 기반 정확한 캐싱의미론적 유사도 매칭을 결합한 하이브리드 캐싱 전략을 구현해보겠습니다. 이를 통해 불필요한 API 호출을 제거하고 비용을 최적화할 수 있습니다.

월 1,000만 토큰 기준 비용 비교표

모델 가격 ($/MTok 출력) 월 1,000만 토큰 비용 캐싱 적용 시 예상 비용 절감율
GPT-4.1 $8.00 $80.00 $24.00 70%
Claude Sonnet 4.5 $15.00 $150.00 $45.00 70%
Gemini 2.5 Flash $2.50 $25.00 $7.50 70%
DeepSeek V3.2 $0.42 $4.20 $1.26 70%

* 캐싱 적용 시: 반복 요청 30%, 유사 요청 40% 캐시 히트 가정

HolySheep AI: 단일 API 키로 모든 모델 관리

지금 가입하여 HolySheep AI의 글로벌 AI API 게이트웨이 서비스를 경험하세요:

아키텍처 개요

우리의 하이브리드 캐싱 전략은 두 단계로 구성됩니다:

  1. 1단계: 정확한 캐시 조회 — 요청 해시를 키로 사용하여 정확한 일치 탐색
  2. 2단계: 의미론적 유사도 매칭 — 임베딩 벡터 기반 코사인 유사도로 유사 요청 탐색
요청 → [해시 키 캐시 조회] → 히트 → 응답 반환
         ↓ 미스
       [임베딩 생성] → [벡터 유사도 검색] → 임계값 초과 → 응답 반환
         ↓ 미스
       [AI API 호출] → [캐시 저장] → 응답 반환

Redis 설치 및 환경 설정

# Docker를 사용한 Redis 설치
docker run -d \
  --name redis-cache \
  -p 6379:6379 \
  -v redis_data:/data \
  redis:7-alpine \
  redis-server --appendonly yes

Redis CLI 연결 확인

docker exec -it redis-cache redis-cli ping

PONG 응답 확인

의존성 패키지 설치

# Python dependencies
pip install redis sentence-transformers numpy openai scipy

프로젝트 구조

ai_cache/

├── cache_manager.py # 메인 캐시 관리자

├── embedding_service.py # 임베딩 생성 서비스

├── config.py # 설정 파일

└── requirements.txt # 의존성

설정 파일 구성

# config.py
import os

class Config:
    # HolySheep AI 설정
    HOLYSHEEP_API_KEY = os.getenv("HOLYSHEEP_API_KEY", "YOUR_HOLYSHEEP_API_KEY")
    HOLYSHEEP_BASE_URL = "https://api.holysheep.ai/v1"
    
    # Redis 설정
    REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
    REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
    REDIS_DB = int(os.getenv("REDIS_DB", 0))
    REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
    
    # 캐시 TTL 설정 (초)
    CACHE_TTL = 60 * 60 * 24 * 7  # 7일
    
    # 의미론적 유사도 임계값 (0.0 ~ 1.0)
    SEMANTIC_SIMILARITY_THRESHOLD = 0.92
    
    # 사용할 임베딩 모델
    EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
    
    # 벡터 차원 (all-MiniLM-L6-v2 기준)
    EMBEDDING_DIMENSION = 384

임베딩 서비스 구현

# embedding_service.py
from sentence_transformers import SentenceTransformer
import numpy as np
from typing import List
import hashlib
import redis
from config import Config

class EmbeddingService:
    """의미론적 임베딩 생성 및 벡터 유사도 검색 서비스"""
    
    def __init__(self, config: Config):
        self.model = SentenceTransformer(config.EMBEDDING_MODEL)
        self.dimension = config.EMBEDDING_DIMENSION
        self.redis_client = redis.Redis(
            host=config.REDIS_HOST,
            port=config.REDIS_PORT,
            db=config.REDIS_DB,
            password=config.REDIS_PASSWORD,
            decode_responses=False
        )
        self.config = config
    
    def generate_embedding(self, text: str) -> List[float]:
        """텍스트에서 임베딩 벡터 생성"""
        embedding = self.model.encode(text, normalize_embeddings=True)
        return embedding.tolist()
    
    def store_vector(self, request_hash: str, embedding: List[float], 
                     response_data: dict) -> None:
        """임베딩 벡터와 응답 데이터를 Redis에 저장"""
        key = f"vec:{request_hash}"
        
        # 벡터를 바이트로 변환하여 저장
        vector_bytes = np.array(embedding, dtype=np.float32).tobytes()
        self.redis_client.set(key, vector_bytes, ex=self.config.CACHE_TTL)
        
        # 응답 데이터는 별도 키로 저장
        response_key = f"resp:{request_hash}"
        import json
        self.redis_client.set(response_key, json.dumps(response_data), 
                              ex=self.config.CACHE_TTL)
    
    def find_similar(self, query_embedding: List[float], 
                     exclude_hash: str = None, top_k: int = 5) -> List[tuple]:
        """코사인 유사도를 기반으로 유사 요청 탐색"""
        query_vector = np.array(query_embedding, dtype=np.float32)
        
        # 모든 벡터 키 스캔
        cursor = 0
        results = []
        
        while True:
            cursor, keys = self.redis_client.scan(
                cursor=cursor, 
                match="vec:*", 
                count=100
            )
            
            for key in keys:
                request_hash = key.decode('utf-8').replace('vec:', '')
                
                # 자기 자신 제외
                if exclude_hash and request_hash == exclude_hash:
                    continue
                
                # 벡터 조회 및 유사도 계산
                vector_bytes = self.redis_client.get(key)
                if vector_bytes:
                    stored_vector = np.frombuffer(vector_bytes, dtype=np.float32)
                    similarity = self._cosine_similarity(query_vector, stored_vector)
                    results.append((request_hash, similarity))
            
            if cursor == 0:
                break
        
        # 유사도 순으로 정렬
        results.sort(key=lambda x: x[1], reverse=True)
        return results[:top_k]
    
    @staticmethod
    def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
        """코사인 유사도 계산"""
        return float(np.dot(a, b))
    
    def get_cached_response(self, request_hash: str) -> dict:
        """캐시된 응답 데이터 조회"""
        import json
        response_key = f"resp:{request_hash}"
        data = self.redis_client.get(response_key)
        return json.loads(data) if data else None

메인 캐시 관리자 구현

# cache_manager.py
import hashlib
import json
import redis
from typing import Optional, Dict, Any
from openai import OpenAI
from embedding_service import EmbeddingService
from config import Config

class AICacheManager:
    """AI API 응답 캐시 관리자 - 정확한 캐싱 + 의미론적 유사도 매칭"""
    
    def __init__(self, config: Config):
        self.config = config
        self.redis_client = redis.Redis(
            host=config.REDIS_HOST,
            port=config.REDIS_PORT,
            db=config.REDIS_DB,
            password=config.REDIS_PASSWORD
        )
        self.embedding_service = EmbeddingService(config)
        
        # HolySheep AI 클라이언트 초기화
        self.client = OpenAI(
            api_key=config.HOLYSHEEP_API_KEY,
            base_url=config.HOLYSHEEP_BASE_URL
        )
        
        # 캐시 통계
        self.stats = {
            "exact_hits": 0,
            "semantic_hits": 0,
            "cache_misses": 0,
            "total_tokens_saved": 0
        }
    
    def _hash_request(self, request_data: Dict[str, Any]) -> str:
        """요청 데이터를 해시로 변환"""
        normalized = json.dumps(request_data, sort_keys=True)
        return hashlib.sha256(normalized.encode()).hexdigest()[:16]
    
    def _exact_cache_lookup(self, request_hash: str) -> Optional[Dict]:
        """정확한 해시 기반 캐시 조회"""
        cache_key = f"exact:{request_hash}"
        cached = self.redis_client.get(cache_key)
        return json.loads(cached) if cached else None
    
    def _store_exact_cache(self, request_hash: str, response: Dict, 
                           ttl: int = None) -> None:
        """정확한 캐시 저장"""
        cache_key = f"exact:{request_hash}"
        self.redis_client.set(
            cache_key, 
            json.dumps(response), 
            ex=ttl or self.config.CACHE_TTL
        )
        
        # 의미론적 캐시에도 저장 (벡터 기반 검색용)
        request_text = response.get("request_text", "")
        if request_text:
            embedding = self.embedding_service.generate_embedding(request_text)
            self.embedding_service.store_vector(request_hash, embedding, response)
    
    def get_response(self, user_message: str, 
                     system_prompt: str = None,
                     model: str = "gpt-4.1") -> Dict[str, Any]:
        """
        AI 응답 가져오기 - 캐시 우선 전략
        
        1. 정확한 해시 매칭 시도
        2. 실패 시 의미론적 유사도 매칭
        3. 모두 실패 시 API 호출
        """
        # 요청 구성
        request_data = {
            "messages": [
                {"role": "system", "content": system_prompt or ""},
                {"role": "user", "content": user_message}
            ],
            "model": model
        }
        request_hash = self._hash_request(request_data)
        
        # 1단계: 정확한 캐시 조회
        cached_response = self._exact_cache_lookup(request_hash)
        if cached_response:
            self.stats["exact_hits"] += 1
            cached_response["cache_hit"] = "exact"
            print(f"✓ 정확한 캐시 히트: {request_hash}")
            return cached_response
        
        # 2단계: 의미론적 유사도 매칭
        query_embedding = self.embedding_service.generate_embedding(user_message)
        similar_requests = self.embedding_service.find_similar(query_embedding)
        
        for similar_hash, similarity in similar_requests:
            if similarity >= self.config.SEMANTIC_SIMILARITY_THRESHOLD:
                similar_response = self.embedding_service.get_cached_response(similar_hash)
                if similar_response:
                    self.stats["semantic_hits"] += 1
                    self.stats["total_tokens_saved"] += similar_response.get(
                        "usage", {}
                    ).get("total_tokens", 0)
                    similar_response["cache_hit"] = "semantic"
                    similar_response["similarity_score"] = similarity
                    similar_response["original_hash"] = similar_hash
                    print(f"✓ 의미론적 캐시 히트: {similar_hash} (유사도: {similarity:.2%})")
                    return similar_response
        
        # 3단계: API 호출
        print(f"✗ 캐시 미스: API 호출 진행")
        self.stats["cache_misses"] += 1
        
        response = self._call_api(request_data, model)
        response["request_text"] = user_message
        
        # 캐시 저장
        self._store_exact_cache(request_hash, response)
        
        return response
    
    def _call_api(self, request_data: Dict, model: str) -> Dict:
        """HolySheep AI API 호출"""
        try:
            response = self.client.chat.completions.create(
                model=model,
                messages=request_data["messages"],
                temperature=0.7,
                max_tokens=1000
            )
            
            return {
                "content": response.choices[0].message.content,
                "model": response.model,
                "usage": {
                    "prompt_tokens": response.usage.prompt_tokens,
                    "completion_tokens": response.usage.completion_tokens,
                    "total_tokens": response.usage.total_tokens
                },
                "cache_hit": "none"
            }
        except Exception as e:
            print(f"API 호출 오류: {e}")
            raise
    
    def get_stats(self) -> Dict:
        """캐시 통계 반환"""
        total_requests = sum(self.stats.values())
        hit_rate = (
            (self.stats["exact_hits"] + self.stats["semantic_hits"]) / total_requests * 100
            if total_requests > 0 else 0
        )
        
        return {
            **self.stats,
            "total_requests": total_requests,
            "hit_rate_percent": round(hit_rate, 2)
        }

사용 예제

if __name__ == "__main__": config = Config() cache_manager = AICacheManager(config) # 테스트 요청들 test_messages = [ "파이썬에서 리스트를 정렬하는 방법을 알려주세요", "파이썬 리스트 정렬 방법", "JavaScript에서 배열을 정렬하는 방법은?", "파이썬에서 리스트를 정렬하는 방법을 알려주세요", # 중복 ] for msg in test_messages: result = cache_manager.get_response( user_message=msg, system_prompt="당신은 친절한 프로그래밍 도우미입니다.", model="gpt-4.1" ) print(f"응답: {result['content'][:50]}...") print(f"캐시 상태: {result['cache_hit']}\n") # 통계 출력 print("=" * 50) print("캐시 통계:", cache_manager.get_stats())

고급 기능: TTL 및 조건부 캐시 무효화

# advanced_cache_manager.py
from datetime import datetime, timedelta
import re

class AdvancedCacheManager(AICacheManager):
    """고급 캐시 관리 기능 추가"""
    
    def _store_with_custom_ttl(self, request_hash: str, response: Dict, 
                                custom_ttl: int) -> None:
        """사용자 정의 TTL로 캐시 저장"""
        cache_key = f"exact:{request_hash}"
        self.redis_client.set(
            cache_key, 
            json.dumps(response), 
            ex=custom_ttl
        )
    
    def invalidate_pattern(self, pattern: str) -> int:
        """특정 패턴을 가진 캐시 무효화"""
        count = 0
        cursor = 0
        
        while True:
            cursor, keys = self.redis_client.scan(
                cursor=cursor, 
                match=f"resp:*{pattern}*", 
                count=100
            )
            
            if keys:
                self.redis_client.delete(*keys)
                count += len(keys)
            
            if cursor == 0:
                break
        
        return count
    
    def update_ttl(self, request_hash: str, new_ttl: int) -> bool:
        """특정 캐시의 TTL 갱신"""
        exact_key = f"exact:{request_hash}"
        vector_key = f"vec:{request_hash}"
        response_key = f"resp:{request_hash}"
        
        for key in [exact_key, vector_key, response_key]:
            if self.redis_client.exists(key):
                self.redis_client.expire(key, new_ttl)
                return True
        return False
    
    def get_cache_info(self, request_hash: str) -> Dict:
        """캐시 상세 정보 조회"""
        exact_key = f"exact:{request_hash}"
        ttl = self.redis_client.ttl(exact_key)
        
        return {
            "request_hash": request_hash,
            "ttl_seconds": ttl if ttl > 0 else None,
            "ttl_formatted": str(timedelta(seconds=ttl)) if ttl > 0 else None,
            "exists": self.redis_client.exists(exact_key) > 0
        }

성능 벤치마크: 캐시 히트율 측정

# benchmark.py
import time
from cache_manager import AICacheManager
from config import Config

def run_benchmark():
    """캐시 성능 벤치마크"""
    config = Config()
    cache_manager = AICacheManager(config)
    
    # 테스트 시나리오
    test_scenarios = [
        # 50% 반복, 30% 유사, 20% 신규
        ["안녕하세요", "안녕하세요", "날씨 어때요?", "날씨가 어떤가요?", 
         "새로운 질문입니다", "안녕하세요"] * 10,
    ]
    
    messages = [msg for scenario in test_scenarios for msg in scenario]
    
    start_time = time.time()
    results = []
    
    for msg in messages:
        result = cache_manager.get_response(msg, model="gpt-4.1")
        results.append(result)
    
    total_time = time.time() - start_time
    stats = cache_manager.get_stats()
    
    # 비용 절감 계산
    if stats["total_tokens_saved"] > 0:
        # GPT-4.1 기준 ($8/MTok)
        cost_saved = (stats["total_tokens_saved"] / 1_000_000) * 8
        print(f"💰 예상 비용 절감: ${cost_saved:.2f}")
    
    print(f"\n📊 벤치마크 결과")
    print(f"총 요청 수: {stats['total_requests']}")
    print(f"정확한 캐시 히트: {stats['exact_hits']}")
    print(f"의미론적 캐시 히트: {stats['semantic_hits']}")
    print(f"캐시 미스: {stats['cache_misses']}")
    print(f"전체 캐시 히트율: {stats['hit_rate_percent']}%")
    print(f"총 실행 시간: {total_time:.2f}초")

if __name__ == "__main__":