Django와 FastAPI에서 Redis를 이용한 세분화된 캐싱 구현
Ethan Miller
Product Engineer · Leapcell

서론: 성능의 중요성
빠르게 변화하는 웹 개발 세계에서 애플리케이션 성능은 단순한 기능이 아니라 기본적인 기대치입니다. 사용자들은 즉각적인 응답을 요구하며, 느린 로딩 시간은 빠르게 이탈과 사용자 경험 저하로 이어질 수 있습니다. 애플리케이션이 확장되고 데이터 볼륨이 증가함에 따라, 병목 현상은 종종 데이터베이스 계층이나 광범위한 연산 중에 나타납니다. 이곳에서 캐싱은 중요한 최적화 기술로 부상합니다. 자주 접근하는 데이터를 빠르고 임시적인 저장 계층에 저장함으로써, 주요 데이터 소스에 대한 부하를 크게 줄이고 콘텐츠 전달을 가속화할 수 있습니다. 이 문서는 인기 있는 Python 웹 프레임워크인 Django와 FastAPI 내에서 정교하고 세밀한 캐싱 전략을 구현하기 위해 강력한 인메모리 데이터 저장소인 Redis를 활용하는 방법을 탐구합니다. 궁극적으로 애플리케이션의 응답성과 확장성을 향상시킵니다.
캐싱의 핵심 요소 이해
구현 세부 사항을 자세히 알아보기 전에 캐싱 및 Redis와 관련된 몇 가지 핵심 개념을 이해하는 것이 중요합니다.
Redis: Redis (Remote Dictionary Server)는 데이터베이스, 캐시 및 메시지 브로커로 사용되는 오픈 소스 인메모리 데이터 구조 저장소입니다. 키-값 저장소의 특성과 다양한 데이터 구조(문자열, 해시, 리스트, 세트, 정렬된 세트)에 대한 지원은 캐싱에 매우 다재다능합니다. 인메모리 특성으로 인해 기존 디스크 기반 데이터베이스보다 훨씬 빠른 초저지연 액세스가 가능합니다.
캐싱 전략: 이는 캐시에서 데이터를 저장, 검색 및 무효화하는 방법을 나타냅니다. 일반적인 전략은 다음과 같습니다.
- Cache-aside: 애플리케이션이 먼저 캐시를 확인합니다. 데이터가 있는 경우( "캐시 히트") 직접 반환됩니다. 그렇지 않은 경우( "캐시 미스") 애플리케이션은 기본 소스에서 데이터를 가져와 캐시에 저장한 다음 반환합니다.
- Write-through: 데이터는 캐시와 기본 데이터 저장소에 동시에 기록됩니다.
- Write-back: 데이터는 캐시에 기록되고, 기본 데이터 저장소에 대한 쓰기는 연기되며, 종종 비동기적으로 발생합니다.
- Time-to-Live (TTL): 지정된 시간 후에 캐시 항목을 자동으로 만료시켜 데이터 신선도를 보장하는 메커니즘입니다.
- 캐시 무효화: 캐시에서 오래되거나 최신이 아닌 데이터를 제거하는 프로세스입니다. 특히 분산 시스템에서는 이를 올바르게 구현하기 어려울 수 있습니다.
세분화된 캐싱: 전체 페이지나 광범위한 데이터 세트를 캐싱하는 대신, 세분화된 캐싱은 더 작고 더 구체적인 데이터 조각을 캐싱하는 것을 포함합니다. 이를 통해 무효화에 대한 유연성이 향상되고 캐시 크기를 줄여 캐시 활용도를 높일 수 있습니다. 예를 들어, 전체 사용자 프로필을 캐싱하는 대신 사용자 이름, 이메일 또는 게시물 목록과 같은 개별 속성을 캐싱하고 변경된 부분만 무효화할 수 있습니다.
Redis 통합 및 세밀한 캐싱 구현
Django와 FastAPI 모두에서 Redis를 통합하고 세밀한 캐싱을 구현하는 방법을 살펴보겠습니다.
Redis 설정
먼저 실행 중인 Redis 인스턴스가 있는지 확인하세요. Docker를 사용하여 로컬에서 실행할 수 있습니다.
docker run --name my-redis -p 6379:6379 -d redis/redis-stack-server
Python Redis 클라이언트도 필요합니다. redis-py
가 가장 인기 있는 선택입니다.
pip install redis
Django: 캐시 프레임워크 활용
Django는 Redis를 포함한 다양한 백엔드를 사용하여 구성할 수 있는 강력한 캐싱 프레임워크를 제공합니다.
1. settings.py
에서의 설정:
# settings.py CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://127.0.0.1:6379/1", # 캐싱에 데이터베이스 1 사용 "OPTIONS": { "CLIENT_CLASS": "redis_py_cluster.cluster.RedisCluster" # Redis 클러스터를 사용하는 경우 # "CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool", # 또는 일반 연결 풀 }, "KEY_PREFIX": "my_app_cache", # 선택 사항: 캐시 키 네임스페이스 지정 "TIMEOUT": 300, # 캐시 항목의 기본 시간 제한 (5분) } }
django-redis
도 설치해야 합니다.
pip install django-redis
2. 기본 뷰 수준 캐싱 (덜 세분화됨):
Django는 전체 뷰를 캐싱하는 데코레이터를 제공합니다.
# myapp/views.py from django.views.decorators.cache import cache_page @cache_page(60 * 15) # 15분 동안 캐싱 def my_cached_view(request): # 이 뷰의 출력이 캐싱됩니다 return HttpResponse("This content is cached!")
3. 모델 데이터에 대한 세분화된 캐싱:
세밀한 제어를 위해 캐시 API와 직접 상호 작용합니다. 개별 제품 상세 정보를 캐싱한다고 가정해 봅시다.
# myapp/models.py from django.db import models class Product(models.Model): name = models.CharField(max_length=255) description = models.TextField() price = models.DecimalField(max_digits=10, decimal_places=2) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return self.name # myapp/services.py (또는 utils.py) from django.core.cache import cache from .models import Product def get_product_details(product_id: int): cache_key = f"product:{product_id}" product_data = cache.get(cache_key) if product_data is None: try: product = Product.objects.get(id=product_id) product_data = { "id": product.id, "name": product.name, "description": product.description, "price": str(product.price), # Decimal 객체는 직접 JSON 직렬화할 수 없습니다 "updated_at": product.updated_at.isoformat(), } # 60초 동안 캐싱합니다. settings.py에서 기본값을 재정의할 수 있습니다. cache.set(cache_key, product_data, timeout=60) print(f"Cache miss for product {product_id}, fetched from DB.") except Product.DoesNotExist: return None else: print(f"Cache hit for product {product_id}.") return product_data def invalidate_product_cache(product_id: int): cache_key = f"product:{product_id}" cache.delete(cache_key) print(f"Invalidated cache for product {product_id}.") # myapp/views.py from django.http import JsonResponse from .services import get_product_details, invalidate_product_cache def product_detail_view(request, product_id: int): product = get_product_details(product_id) if product: return JsonResponse(product) return JsonResponse({"error": "Product not found"}, status=404) def update_product_view(request, product_id: int): # DB에서 제품 업데이트 로직 # ... # 업데이트 후 캐시 무효화 invalidate_product_cache(product_id) return JsonResponse({"message": "Product updated and cache invalidated."})
이 예제는 다음을 보여줍니다:
- 개별 제품에 대한 고유 캐시 키 구성.
- Cache-aside 전략 수동 구현.
- 캐시 항목에 대한 특정
timeout
(TTL) 설정. - 기본 데이터가 변경될 때 수동으로 캐시 무효화하여 데이터 일관성을 보장합니다.
FastAPI: 직접 Redis 통합 및 의존성 주입
현대적이고 비동기적인 프레임워크인 FastAPI는 asyncio
를 활용하는 redis-py
를 사용한 Redis와의 직접 통합에서 종종 이점을 얻습니다.
1. Redis 클라이언트 설정:
# app/dependencies.py import redis.asyncio as redis from typing import AsyncGenerator # 일반 캐싱에는 데이터베이스 0, 특정 데이터에는 1 등 사용 REDIS_URL = "redis://localhost:6379/0" async def get_redis_client() -> AsyncGenerator[redis.Redis, None]: client = redis.from_url(REDIS_URL) try: yield client finally: await client.close()
2. 의존성 주입을 통한 세분화된 캐싱:
사용자 데이터를 검색하는 API 엔드포인트를 가정해 봅시다. 개별 사용자 프로필을 캐싱할 수 있습니다.
# app/main.py from fastapi import FastAPI, Depends, HTTPException import redis.asyncio as redis import json from datetime import datetime import asyncio from pydantic import BaseModel, Field from .dependencies import get_redis_client # from .models import User # User에 대한 Pydantic 모델 가정 # from .database import get_user_from_db, update_user_in_db # DB 상호 작용 함수 가정 # Pydantic 모델 시뮬레이션 class User(BaseModel): id: int name: str email: str created_at: datetime updated_at: datetime = Field(default_factory=datetime.now) # 시뮬레이션된 데이터베이스 함수 (실제 ORM/ODM 호출로 대체) async def get_user_from_db(user_id: int) -> User | None: # 실제 앱에서는 비동기 DB 쿼리가 될 것입니다. print(f"Fetching user {user_id} from DB...") await asyncio.sleep(0.1) # DB 지연 시간 시뮬레이션 if user_id == 1: return User(id=1, name="Alice", email="alice@example.com", created_at=datetime.now()) if user_id == 2: return User(id=2, name="Bob", email="bob@example.com", created_at=datetime.now()) return None async def update_user_in_db(user_id: int, new_data: dict) -> User | None: print(f"Updating user {user_id} in DB with {new_data}...") await asyncio.sleep(0.1) if user_id == 1: # 기존 사용자 가져오기, 업데이트 및 반환 시뮬레이션 existing_user_data = {"id":1, "name":"Alice", "email":"alice@example.com", "created_at":datetime.now().isoformat()} current_data = {**existing_user_data, **new_data, "updated_at": datetime.now().isoformat()} return User(**current_data) return None app = FastAPI() # 사용자 세부 정보 가져오기 API 엔드포인트 @app.get("/users/{user_id}", response_model=User) async def read_user(user_id: int, redis_client: redis.Redis = Depends(get_redis_client)): cache_key = f"user:{user_id}" cached_data = await redis_client.get(cache_key) if cached_data: print(f"Cache hit for user {user_id}.") return User.model_validate_json(cached_data) # Pydantic v2 # return User.parse_raw(cached_data) # Pydantic v1 print(f"Cache miss for user {user_id}, fetching from DB.") user = await get_user_from_db(user_id) if not user: raise HTTPException(status_code=404, detail="User not found") # TTL (예: 5분)로 사용자 데이터 캐싱 await redis_client.set(cache_key, user.model_dump_json(), ex=300) # Pydantic v2 # await redis_client.set(cache_key, user.json(), ex=300) # Pydantic v1 return user # 사용자 데이터 업데이트 및 캐시 무효화 API 엔드포인트 @app.put("/users/{user_id}", response_model=User) async def update_user(user_id: int, new_data: dict, redis_client: redis.Redis = Depends(get_redis_client)): # 데이터베이스에서 사용자 업데이트 updated_user = await update_user_in_db(user_id, new_data) if not updated_user: raise HTTPException(status_code=404, detail="User not found") # 이 특정 사용자의 캐시 무효화 cache_key = f"user:{user_id}" await redis_client.delete(cache_key) print(f"Invalidated cache for user {user_id}.") # 선택적으로 업데이트된 사용자 데이터 다시 캐싱 await redis_client.set(cache_key, updated_user.model_dump_json(), ex=300) return updated_user
FastAPI 예제에서는 다음과 같습니다.
- 비차단 Redis 작업을 위해
redis.asyncio
를 사용합니다. get_redis_client
는 올바른 연결 관리를 보장하는 Redis 클라이언트를 제공하는 비동기 의존성입니다.- 개별 사용자 (
user:{user_id}
)에 대한 캐시 키가 구성됩니다. - Pydantic의
model_dump_json()
(또는 Pydantic v1의json()
)을 사용하여 JSON 문자열로 데이터를 저장하고 검색합니다. ex=300
은 JSON 문자열로 데이터를 저장하고 검색합니다.ex=300
은 300초 (5분)의 TTL을 설정합니다.- 데이터 업데이트 후
redis_client.delete(cache_key)
를 통해 명시적으로 캐시를 무효화합니다.
고급 캐싱 전략 및 고려 사항
- 캐시 워밍: 애플리케이션 시작 또는 오프피크 시간에 자주 액세스하는 데이터로 캐시를 미리 채워 초기 사용자 요청이 캐시 히트를 보장합니다.
- 일괄 무효화를 위한 캐시 태그/그룹: 관련된 여러 항목을 동시에 무효화해야 하는 경우(예: 카테고리의 모든 제품), Redis 세트를 사용하여 관련 캐시 키를 그룹화할 수 있습니다. 카테고리가 변경되면 세트를 반복하여 모든 관련 제품 키를 삭제합니다.
- 분산 캐싱: 애플리케이션의 여러 인스턴스를 실행할 때 Redis는 모든 인스턴스 간의 일관성을 보장하는 공유 중앙 캐시 역할을 자연스럽게 수행합니다.
- 경쟁 조건: 특히 고동시성 환경에서 캐시 업데이트/무효화 중 경쟁 조건을 염두에 두어야 합니다. 낙관적 잠금 또는 분산 잠금 (Redis는 이를 위해
SET NX PX
를 제공)과 같은 솔루션이 도움이 될 수 있습니다. - 직렬화: 복잡한 객체를 Redis에 저장하기 위해 효율적인 직렬화 형식 (JSON, MessagePack, Protobuf)을 선택합니다. Pydantic의
json()
또는model_dump_json()
메서드는 FastAPI에 매우 유용합니다. - 모니터링: Redis 인스턴스를 (히트율, 메모리 사용량, 지연 시간) 모니터링하여 최적의 성능을 보장하고 잠재적인 문제를 식별합니다.
결론: 스마트 캐싱으로 성능 강화
Django 및 FastAPI에서 세밀한 캐싱을 위해 Redis를 통합하는 것은 애플리케이션 성능과 확장성을 향상시키는 강력한 전략입니다. 캐싱의 핵심 원칙을 이해하고 Redis의 강점을 활용함으로써, 개발자는 데이터베이스 부하를 크게 줄이고 응답 시간을 단축하며 더 나은 사용자 경험을 제공할 수 있습니다. 간단한 뷰 캐싱부터 복잡한 데이터 수준 제어 및 명시적 무효화에 이르기까지, 논의된 기술은 콘텐츠를 효율적으로 관리하고 제공하는 고성능 웹 애플리케이션을 구축하기 위한 강력한 기반을 제공합니다. 스마트 캐싱은 단순한 최적화가 아니라 탄력적이고 확장 가능한 아키텍처의 중요한 구성 요소입니다.