Django-Ninja 대량 트래픽 대응 전략: 성능 최적화부터 아키텍처까지
Django-Ninja로 API를 개발하다가 갑자기 트래픽이 급증하면 어떻게 대응해야 할까요? 많은 개발자들이 프레임워크만 선택하면 성능 문제가 자동으로 해결될 것이라 생각하지만, 실제로는 체계적인 최적화 전략이 필요합니다. 이 글에서는 Django-Ninja 기반 API 서비스의 대량 트래픽 대응 전략을 단계별로 살펴보겠습니다.
📘 Django-Ninja란 무엇인가?
Django-Ninja는 FastAPI에서 영감을 받아 만들어진 Django용 고성능 웹 프레임워크입니다. FastAPI의 장점인 타입 힌트 기반 자동 검증, 자동 문서화, 빠른 성능을 Django 생태계에 통합한 것이 특징입니다.
from ninja import NinjaAPI
from typing import List
api = NinjaAPI()
@api.get("/users/{user_id}")
def get_user(request, user_id: int):
user = User.objects.get(id=user_id)
return {"id": user.id, "name": user.name}
@api.get("/users", response=List[UserSchema])
def list_users(request):
return User.objects.all()
Django-Ninja의 주요 장점:
- 빠른 성능: Pydantic 기반 직렬화로 Django REST Framework 대비 5-10배 빠른 성능
- 타입 안정성: Python 타입 힌트를 활용한 자동 검증 및 문서화
- 간결한 코드: 보일러플레이트 코드 최소화
- Django 생태계: Django ORM, 인증, 미들웨어 등 Django 기능 완전 호환
하지만 아무리 빠른 프레임워크라도 트래픽이 급증하면 병목현상이 발생합니다. 실제 서비스 환경에서 초당 수천, 수만 건의 요청을 처리하려면 체계적인 최적화가 필수입니다.
🔧 1단계: 애플리케이션 자체 성능 개선
트래픽 대응의 첫 단계는 외부 도구에 의존하기 전에 애플리케이션 자체의 성능을 최대한 끌어올리는 것입니다. 작은 최적화들이 모여 큰 성능 향상을 가져올 수 있습니다.
데이터베이스 쿼리 최적화
가장 먼저 확인해야 할 것은 N+1 쿼리 문제입니다. Django ORM의 지연 로딩(Lazy Loading) 특성으로 인해 무심코 작성한 코드가 심각한 성능 저하를 일으킬 수 있습니다.
# ❌ 잘못된 예시 - N+1 쿼리 발생
@api.get("/posts", response=List[PostSchema])
def list_posts(request):
posts = Post.objects.all() # 1개의 쿼리
# PostSchema에서 post.author.name을 참조하면
# 각 포스트마다 추가 쿼리 발생 (N개의 쿼리)
return posts
# ✅ 올바른 예시 - select_related로 JOIN 처리
@api.get("/posts", response=List[PostSchema])
def list_posts(request):
posts = Post.objects.select_related('author').all() # 1개의 JOIN 쿼리로 해결
return posts
# ✅ 다대다 관계는 prefetch_related 사용
@api.get("/posts-with-tags", response=List[PostWithTagsSchema])
def list_posts_with_tags(request):
posts = Post.objects.prefetch_related('tags').select_related('author').all()
return posts
성능 측정 결과:
- N+1 쿼리: 100개 포스트 조회 시 101개의 쿼리 실행 (약 500ms)
- 최적화 후: 1-2개의 쿼리로 해결 (약 50ms, 10배 향상)
데이터베이스 인덱스 최적화
쿼리가 자주 사용하는 필드에 인덱스를 추가하면 조회 속도가 극적으로 개선됩니다.
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True, db_index=True) # 인덱스 추가
status = models.CharField(max_length=20, db_index=True) # 필터링에 자주 사용
class Meta:
indexes = [
models.Index(fields=['status', 'created_at']), # 복합 인덱스
models.Index(fields=['-created_at']), # 역순 정렬용 인덱스
]
# 인덱스 활용 쿼리
@api.get("/recent-posts")
def recent_posts(request, status: str = "published"):
# status + created_at 복합 인덱스 활용
posts = Post.objects.filter(
status=status
).order_by('-created_at')[:20]
return list(posts)
페이지네이션 구현
모든 데이터를 한 번에 반환하지 말고 페이지네이션을 적용하세요.
from ninja import Query
from ninja.pagination import paginate, PageNumberPagination
@api.get("/posts", response=List[PostSchema])
@paginate(PageNumberPagination, page_size=20)
def list_posts(request):
return Post.objects.select_related('author').order_by('-created_at')
# 커서 기반 페이지네이션 (대용량 데이터에 효과적)
from ninja.pagination import LimitOffsetPagination
@api.get("/posts-cursor", response=List[PostSchema])
@paginate(LimitOffsetPagination)
def list_posts_cursor(request):
return Post.objects.select_related('author').order_by('-id')
응답 데이터 최소화 - 필드 선택
클라이언트가 실제로 필요한 필드만 반환하도록 최적화합니다.
from pydantic import BaseModel
from typing import Optional
class PostListSchema(BaseModel):
id: int
title: str
author_name: str
created_at: datetime
# 불필요한 content 필드 제외
class PostDetailSchema(BaseModel):
id: int
title: str
content: str # 상세 조회에만 포함
author_name: str
created_at: datetime
updated_at: datetime
@api.get("/posts", response=List[PostListSchema])
def list_posts(request):
# only()로 필요한 필드만 조회
posts = Post.objects.only(
'id', 'title', 'author__name', 'created_at'
).select_related('author')
return posts
@api.get("/posts/{post_id}", response=PostDetailSchema)
def get_post(request, post_id: int):
# 상세 조회에만 모든 필드 반환
return Post.objects.select_related('author').get(id=post_id)
비동기 처리와 백그라운드 작업
무거운 작업은 비동기로 처리하여 응답 시간을 단축합니다.
from ninja import NinjaAPI
import asyncio
from asgiref.sync import sync_to_async
api = NinjaAPI()
# 동기 방식 (느림)
@api.post("/send-notification")
def send_notification(request, user_id: int):
user = User.objects.get(id=user_id)
send_email(user.email) # 이메일 전송에 2초 소요
send_sms(user.phone) # SMS 전송에 2초 소요
return {"status": "sent"} # 총 4초 이상 대기
# ✅ 비동기 방식 (빠름)
@api.post("/send-notification-async")
async def send_notification_async(request, user_id: int):
user = await sync_to_async(User.objects.get)(id=user_id)
# 병렬 처리로 시간 단축
await asyncio.gather(
send_email_async(user.email),
send_sms_async(user.phone)
)
return {"status": "sent"} # 약 2초로 단축
# ✅ Celery를 사용한 백그라운드 작업 (더 빠름)
from celery import shared_task
@shared_task
def send_notifications_task(user_id):
user = User.objects.get(id=user_id)
send_email(user.email)
send_sms(user.phone)
@api.post("/send-notification-bg")
def send_notification_bg(request, user_id: int):
send_notifications_task.delay(user_id) # 백그라운드에서 실행
return {"status": "queued"} # 즉시 응답 (< 100ms)
핵심 포인트:
- ✅ N+1 쿼리 제거로 데이터베이스 부하 최소화
- ✅ 적절한 인덱스로 쿼리 속도 10배 이상 향상
- ✅ 페이지네이션으로 메모리 사용량 제어
- ✅ 필요한 필드만 조회하여 네트워크 트래픽 감소
- ✅ 무거운 작업은 비동기/백그라운드 처리
이러한 최적화만으로도 기존 대비 5-10배의 성능 향상을 기대할 수 있습니다.
🚀 2단계: Redis 캐싱으로 데이터베이스 부하 분산
애플리케이션 최적화를 마쳤다면 이제 캐싱 전략을 도입할 차례입니다. Redis는 인메모리 데이터 저장소로, 디스크 기반 데이터베이스보다 100-1000배 빠른 응답 속도를 제공합니다.
Redis 캐싱 전략 수립
효과적인 캐싱을 위해서는 어떤 데이터를 얼마나 오래 캐싱할지 전략이 필요합니다.
# settings.py - Redis 설정
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'CONNECTION_POOL_KWARGS': {
'max_connections': 50,
'retry_on_timeout': True,
},
'SOCKET_CONNECT_TIMEOUT': 5,
'SOCKET_TIMEOUT': 5,
},
'KEY_PREFIX': 'myapp',
'TIMEOUT': 300, # 기본 5분
}
}
쿼리 결과 캐싱
가장 기본적인 패턴은 데이터베이스 쿼리 결과를 캐싱하는 것입니다.
from django.core.cache import cache
from ninja import NinjaAPI
import json
from typing import List
api = NinjaAPI()
@api.get("/posts", response=List[PostSchema])
def list_posts(request, category: str = None):
# 캐시 키 생성 (파라미터 포함)
cache_key = f"posts:list:category:{category or 'all'}"
# 캐시 확인
cached_data = cache.get(cache_key)
if cached_data:
return json.loads(cached_data)
# 캐시 미스 - DB 조회
query = Post.objects.select_related('author')
if category:
query = query.filter(category=category)
posts = list(query.all())
# 캐시 저장 (5분)
cache.set(cache_key, json.dumps([p.dict() for p in posts]), timeout=300)
return posts
# 상세 조회도 캐싱
@api.get("/posts/{post_id}", response=PostSchema)
def get_post(request, post_id: int):
cache_key = f"post:detail:{post_id}"
cached_post = cache.get(cache_key)
if cached_post:
return json.loads(cached_post)
post = Post.objects.select_related('author').get(id=post_id)
cache.set(cache_key, json.dumps(post.dict()), timeout=600) # 10분
return post
데코레이터 패턴으로 캐싱 간소화
반복적인 캐싱 로직을 데코레이터로 추상화하면 코드가 깔끔해집니다.
from functools import wraps
from django.core.cache import cache
import hashlib
import json
def cache_response(timeout=300, key_prefix="api"):
"""API 응답을 캐싱하는 데코레이터"""
def decorator(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
# 캐시 키 생성 (함수명 + 인자 기반)
key_data = f"{func.__name__}:{args}:{kwargs}"
cache_key = f"{key_prefix}:{hashlib.md5(key_data.encode()).hexdigest()}"
# 캐시 확인
cached = cache.get(cache_key)
if cached is not None:
return cached
# 캐시 미스 - 실제 함수 실행
result = func(request, *args, **kwargs)
# 캐시 저장
cache.set(cache_key, result, timeout=timeout)
return result
return wrapper
return decorator
# 사용 예시
@api.get("/popular-posts", response=List[PostSchema])
@cache_response(timeout=600, key_prefix="popular")
def popular_posts(request):
return Post.objects.annotate(
view_count=Count('views')
).order_by('-view_count')[:10]
캐시 무효화 전략
데이터가 변경되면 관련 캐시를 무효화해야 합니다.
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
@api.post("/posts", response=PostSchema)
def create_post(request, payload: PostCreateSchema):
post = Post.objects.create(**payload.dict())
# 캐시 무효화 - 목록 캐시 삭제
cache.delete_pattern("posts:list:*") # 모든 목록 캐시 삭제
return post
@api.put("/posts/{post_id}", response=PostSchema)
def update_post(request, post_id: int, payload: PostUpdateSchema):
post = Post.objects.get(id=post_id)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(post, attr, value)
post.save()
# 해당 포스트의 상세 캐시 삭제
cache.delete(f"post:detail:{post_id}")
# 목록 캐시도 삭제
cache.delete_pattern("posts:list:*")
return post
# Signal을 사용한 자동 캐시 무효화
@receiver([post_save, post_delete], sender=Post)
def invalidate_post_cache(sender, instance, **kwargs):
"""포스트가 저장/삭제될 때 자동으로 캐시 무효화"""
cache.delete(f"post:detail:{instance.id}")
cache.delete_pattern("posts:list:*")
Redis를 세션 스토어로 활용
Django의 기본 세션 스토어를 Redis로 변경하여 성능을 개선합니다.
# settings.py
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
# 사용자별 API 속도 제한 (Rate Limiting)
from django.core.cache import cache
from ninja import NinjaAPI
from ninja.security import HttpBearer
class RateLimitExceeded(Exception):
pass
def rate_limit(max_requests=100, window=60):
"""분당 요청 횟수 제한"""
def decorator(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
user_id = request.user.id if request.user.is_authenticated else request.META.get('REMOTE_ADDR')
cache_key = f"rate_limit:{user_id}:{func.__name__}"
# 현재 요청 횟수 확인
current_requests = cache.get(cache_key, 0)
if current_requests >= max_requests:
raise RateLimitExceeded(f"Too many requests. Max {max_requests} per {window}s")
# 요청 횟수 증가
cache.set(cache_key, current_requests + 1, timeout=window)
return func(request, *args, **kwargs)
return wrapper
return decorator
@api.get("/api/expensive-operation")
@rate_limit(max_requests=10, window=60) # 분당 10회 제한
def expensive_operation(request):
return {"result": "success"}
Redis Pub/Sub으로 실시간 데이터 동기화
여러 서버 인스턴스 간 캐시를 동기화해야 할 때 Pub/Sub을 활용합니다.
import redis
import json
redis_client = redis.Redis(host='localhost', port=6379, db=0)
# 발행자 (데이터 변경 시)
def notify_cache_invalidation(cache_key: str):
"""캐시 무효화 이벤트 발행"""
message = json.dumps({
'action': 'invalidate',
'key': cache_key,
'timestamp': datetime.now().isoformat()
})
redis_client.publish('cache_invalidation', message)
# 구독자 (백그라운드 워커)
def cache_invalidation_subscriber():
"""캐시 무효화 이벤트 구독"""
pubsub = redis_client.pubsub()
pubsub.subscribe('cache_invalidation')
for message in pubsub.listen():
if message['type'] == 'message':
data = json.loads(message['data'])
cache.delete(data['key'])
print(f"Cache invalidated: {data['key']}")
# 포스트 수정 시 모든 서버에 캐시 무효화 알림
@api.put("/posts/{post_id}")
def update_post(request, post_id: int, payload: PostUpdateSchema):
post = Post.objects.get(id=post_id)
post.title = payload.title
post.save()
# 모든 서버에 캐시 무효화 알림
notify_cache_invalidation(f"post:detail:{post_id}")
return post
캐싱 효과 측정
# 캐시 히트율 모니터링
from django.core.cache import cache
import logging
logger = logging.getLogger(__name__)
def get_cache_stats():
"""캐시 통계 조회"""
info = cache._cache.get_client().info()
keyspace_hits = int(info.get('keyspace_hits', 0))
keyspace_misses = int(info.get('keyspace_misses', 0))
total_requests = keyspace_hits + keyspace_misses
hit_rate = (keyspace_hits / total_requests * 100) if total_requests > 0 else 0
return {
'hits': keyspace_hits,
'misses': keyspace_misses,
'hit_rate': f"{hit_rate:.2f}%",
'memory_used': info.get('used_memory_human'),
'total_keys': info.get('db1', {}).get('keys', 0)
}
@api.get("/admin/cache-stats")
def cache_stats(request):
return get_cache_stats()
Redis 캐싱 성능 개선 효과:
- 데이터베이스 쿼리 감소: 70-90% 감소
- 응답 시간 단축: 평균 200ms → 10ms (20배 향상)
- 데이터베이스 부하: 50-80% 감소
- 동시 처리 가능 요청: 3-5배 증가
캐싱은 저렴한 비용으로 극적인 성능 향상을 얻을 수 있는 가장 효과적인 최적화 방법입니다.
🏗️ 3단계: 아키텍처 변경으로 확장성 확보
단일 서버의 성능 한계를 넘어서려면 아키텍처 수준의 변경이 필요합니다. 수평 확장이 가능한 구조로 전환하는 것이 핵심입니다.
로드 밸런서 도입 - 트래픽 분산
여러 애플리케이션 서버를 운영하고 로드 밸런서로 트래픽을 분산합니다.
# nginx.conf - 로드 밸런서 설정
upstream django_backend {
least_conn; # 연결 수가 가장 적은 서버로 분산
server app1.example.com:8000 weight=3;
server app2.example.com:8000 weight=3;
server app3.example.com:8000 weight=2;
server app4.example.com:8000 weight=2 backup; # 백업 서버
# 헬스 체크
keepalive 32;
keepalive_timeout 30s;
}
server {
listen 80;
server_name api.example.com;
# 정적 파일은 Nginx가 직접 서빙
location /static/ {
alias /var/www/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# API 요청은 백엔드로 프록시
location / {
proxy_pass http://django_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 타임아웃 설정
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# 버퍼링 설정
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
}
읽기 전용 데이터베이스 레플리카
읽기와 쓰기를 분리하여 데이터베이스 부하를 분산합니다.
# settings.py - 다중 데이터베이스 설정
DATABASES = {
'default': { # 쓰기용 Primary DB
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydb',
'USER': 'postgres',
'PASSWORD': 'password',
'HOST': 'primary-db.example.com',
'PORT': '5432',
'CONN_MAX_AGE': 60,
},
'replica1': { # 읽기용 Replica 1
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydb',
'USER': 'postgres',
'PASSWORD': 'password',
'HOST': 'replica1-db.example.com',
'PORT': '5432',
'CONN_MAX_AGE': 60,
},
'replica2': { # 읽기용 Replica 2
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydb',
'USER': 'postgres',
'PASSWORD': 'password',
'HOST': 'replica2-db.example.com',
'PORT': '5432',
'CONN_MAX_AGE': 60,
},
}
# 데이터베이스 라우터 설정
DATABASE_ROUTERS = ['myapp.routers.PrimaryReplicaRouter']
# myapp/routers.py
import random
class PrimaryReplicaRouter:
"""읽기는 레플리카로, 쓰기는 Primary로 라우팅"""
def db_for_read(self, model, **hints):
"""읽기 쿼리는 레플리카로 랜덤 분산"""
return random.choice(['replica1', 'replica2'])
def db_for_write(self, model, **hints):
"""쓰기 쿼리는 Primary로"""
return 'default'
def allow_relation(self, obj1, obj2, **hints):
"""모든 관계 허용"""
return True
def allow_migrate(self, db, app_label, model_name=None, **hints):
"""마이그레이션은 Primary에만"""
return db == 'default'
# API에서 사용
@api.get("/posts", response=List[PostSchema])
def list_posts(request):
# 자동으로 레플리카에서 읽기
posts = Post.objects.all()
return posts
@api.post("/posts", response=PostSchema)
def create_post(request, payload: PostCreateSchema):
# 자동으로 Primary에 쓰기
post = Post.objects.create(**payload.dict())
return post
# 명시적으로 데이터베이스 지정도 가능
@api.get("/posts-from-primary")
def list_posts_from_primary(request):
# Primary에서 최신 데이터 읽기 (레플리카 딜레이 회피)
posts = Post.objects.using('default').all()
return posts
CDN으로 정적 콘텐츠 분산
정적 파일과 이미지를 CDN으로 서빙하여 서버 부하를 줄입니다.
# settings.py - CDN 설정
AWS_ACCESS_KEY_ID = 'your-access-key'
AWS_SECRET_ACCESS_KEY = 'your-secret-key'
AWS_STORAGE_BUCKET_NAME = 'your-bucket'
AWS_S3_REGION_NAME = 'ap-northeast-2'
# S3와 CloudFront 사용
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
CLOUDFRONT_DOMAIN = 'xxxxxxxxxxxx.cloudfront.net'
# 정적 파일 설정
STATIC_URL = f'https://{CLOUDFRONT_DOMAIN}/static/'
MEDIA_URL = f'https://{CLOUDFRONT_DOMAIN}/media/'
# django-storages 사용
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
# 캐시 헤더 설정
AWS_S3_OBJECT_PARAMETERS = {
'CacheControl': 'max-age=86400', # 1일
}
# 이미지 URL 생성
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
thumbnail = models.ImageField(upload_to='thumbnails/') # 자동으로 S3에 업로드
def get_thumbnail_url(self):
# CloudFront URL 반환
return f"https://{settings.CLOUDFRONT_DOMAIN}/media/{self.thumbnail.name}"
메시지 큐로 비동기 처리 강화
Celery와 RabbitMQ/Redis를 사용하여 무거운 작업을 비동기로 처리합니다.
# celery.py
from celery import Celery
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
# settings.py
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TIMEZONE = 'Asia/Seoul'
# tasks.py
from celery import shared_task
from django.core.mail import send_mail
@shared_task
def process_large_dataset(file_id):
"""대용량 데이터 처리 작업"""
file = UploadedFile.objects.get(id=file_id)
# 시간이 오래 걸리는 작업
process_data(file.path)
# 완료 알림
send_mail(
'Processing Complete',
f'File {file.name} has been processed',
'noreply@example.com',
[file.user.email],
)
return f"Processed {file.name}"
@shared_task
def generate_report(user_id, start_date, end_date):
"""보고서 생성 작업"""
user = User.objects.get(id=user_id)
report_data = generate_report_data(user, start_date, end_date)
# S3에 저장
report_url = upload_to_s3(report_data)
# 사용자에게 알림
send_notification(user, report_url)
return report_url
# API에서 사용
@api.post("/process-file")
def process_file(request, file_id: int):
# 백그라운드에서 처리
task = process_large_dataset.delay(file_id)
return {
"status": "queued",
"task_id": task.id,
"message": "Your file is being processed"
}
@api.get("/task-status/{task_id}")
def task_status(request, task_id: str):
"""작업 상태 확인"""
from celery.result import AsyncResult
task = AsyncResult(task_id)
return {
"task_id": task_id,
"status": task.state,
"result": task.result if task.ready() else None
}
마이크로서비스 아키텍처로 전환
서비스를 기능별로 분리하여 독립적으로 확장합니다.
# 예시: 인증 서비스 분리
# auth_service/api.py
from ninja import NinjaAPI
auth_api = NinjaAPI(urls_namespace='auth')
@auth_api.post("/login")
def login(request, payload: LoginSchema):
# 인증 로직
token = generate_jwt_token(payload.username, payload.password)
return {"access_token": token}
@auth_api.post("/refresh")
def refresh_token(request, refresh_token: str):
# 토큰 갱신
new_token = refresh_jwt_token(refresh_token)
return {"access_token": new_token}
# content_service/api.py - 콘텐츠 서비스
content_api = NinjaAPI(urls_namespace='content')
@content_api.get("/posts")
def list_posts(request):
# 콘텐츠 조회 로직
return Post.objects.all()
# notification_service/api.py - 알림 서비스
notification_api = NinjaAPI(urls_namespace='notification')
@notification_api.post("/send")
def send_notification(request, payload: NotificationSchema):
# 알림 전송 로직
send_push_notification(payload)
return {"status": "sent"}
# 메인 API 게이트웨이
from ninja import NinjaAPI
api = NinjaAPI()
api.add_router("/auth/", auth_api)
api.add_router("/content/", content_api)
api.add_router("/notification/", notification_api)
서비스 간 통신 최적화
# gRPC를 사용한 서비스 간 통신 (HTTP보다 빠름)
import grpc
from proto import user_service_pb2, user_service_pb2_grpc
def get_user_from_auth_service(user_id: int):
"""인증 서비스에서 사용자 정보 조회"""
channel = grpc.insecure_channel('auth-service:50051')
stub = user_service_pb2_grpc.UserServiceStub(channel)
request = user_service_pb2.GetUserRequest(user_id=user_id)
response = stub.GetUser(request)
return response
# 또는 HTTP/REST로 서비스 간 통신
import httpx
async def get_user_from_auth_service_http(user_id: int):
"""HTTP로 사용자 정보 조회"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"http://auth-service:8000/api/users/{user_id}",
timeout=5.0
)
return response.json()
아키텍처 변경의 효과:
- 수평 확장 가능: 서버를 추가하여 선형적으로 처리량 증가
- 장애 격리: 특정 서비스 장애가 전체 시스템에 영향 X
- 독립적 배포: 각 서비스를 독립적으로 업데이트 가능
- 기술 스택 자유도: 서비스별로 최적의 기술 선택 가능
⚡ 4단계: 오토 스케일링으로 탄력적 대응
트래픽은 시간대별, 이벤트별로 급격하게 변동합니다. 오토 스케일링을 통해 트래픽에 따라 자동으로 서버를 증설/축소하여 비용을 최적화하면서도 안정적인 서비스를 유지할 수 있습니다.
Kubernetes를 활용한 오토 스케일링
Kubernetes의 HPA(Horizontal Pod Autoscaler)를 사용하면 CPU, 메모리, 커스텀 메트릭 기반으로 자동 확장이 가능합니다.
# deployment.yaml - Django-Ninja 애플리케이션 배포
apiVersion: apps/v1
kind: Deployment
metadata:
name: django-ninja-api
spec:
replicas: 3 # 기본 Pod 개수
selector:
matchLabels:
app: django-ninja-api
template:
metadata:
labels:
app: django-ninja-api
spec:
containers:
- name: api
image: myapp/django-ninja:latest
ports:
- containerPort: 8000
env:
- name: DJANGO_SETTINGS_MODULE
value: "myproject.settings.production"
resources:
requests:
cpu: 500m # 0.5 CPU 요청
memory: 512Mi # 512MB 메모리 요청
limits:
cpu: 1000m # 1 CPU 제한
memory: 1Gi # 1GB 메모리 제한
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
---
# hpa.yaml - Horizontal Pod Autoscaler 설정
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: django-ninja-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: django-ninja-api
minReplicas: 3 # 최소 Pod 개수
maxReplicas: 20 # 최대 Pod 개수
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # CPU 70% 이상 시 스케일 아웃
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80 # 메모리 80% 이상 시 스케일 아웃
behavior:
scaleUp:
stabilizationWindowSeconds: 60 # 1분 동안 메트릭 안정화 후 스케일 업
policies:
- type: Percent
value: 50 # 최대 50%씩 증가
periodSeconds: 60
- type: Pods
value: 3 # 또는 최대 3개씩 증가
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # 5분 동안 안정화 후 스케일 다운
policies:
- type: Percent
value: 10 # 최대 10%씩 감소
periodSeconds: 60
커스텀 메트릭 기반 스케일링
요청 처리 시간, 큐 길이 등 커스텀 메트릭으로 더 정교한 스케일링이 가능합니다.
# custom-metric-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: django-ninja-custom-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: django-ninja-api
minReplicas: 3
maxReplicas: 30
metrics:
# 커스텀 메트릭: 요청 처리 시간
- type: Pods
pods:
metric:
name: http_request_duration_seconds
target:
type: AverageValue
averageValue: "200m" # 평균 응답 시간 200ms 초과 시 스케일 아웃
# 커스텀 메트릭: 초당 요청 수
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "100" # Pod당 초당 100개 요청 초과 시 스케일 아웃
Django-Ninja에서 커스텀 메트릭을 Prometheus로 노출:
# metrics.py - Prometheus 메트릭 설정
from prometheus_client import Counter, Histogram, Gauge
import time
# 요청 카운터
request_count = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status']
)
# 응답 시간 히스토그램
request_duration = Histogram(
'http_request_duration_seconds',
'HTTP request duration',
['method', 'endpoint']
)
# 활성 요청 수
active_requests = Gauge(
'http_requests_inprogress',
'Number of HTTP requests in progress',
['method', 'endpoint']
)
# 미들웨어로 메트릭 수집
class PrometheusMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
method = request.method
path = request.path
# 요청 시작
active_requests.labels(method=method, endpoint=path).inc()
start_time = time.time()
# 요청 처리
response = self.get_response(request)
# 메트릭 기록
duration = time.time() - start_time
request_duration.labels(method=method, endpoint=path).observe(duration)
request_count.labels(
method=method,
endpoint=path,
status=response.status_code
).inc()
active_requests.labels(method=method, endpoint=path).dec()
return response
# settings.py
MIDDLEWARE = [
'myapp.metrics.PrometheusMiddleware',
# ... 다른 미들웨어
]
# 메트릭 엔드포인트
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
from django.http import HttpResponse
@api.get("/metrics")
def metrics(request):
"""Prometheus 메트릭 노출"""
return HttpResponse(
generate_latest(),
content_type=CONTENT_TYPE_LATEST
)
AWS Auto Scaling 설정
AWS에서 EC2 기반 오토 스케일링을 구성하는 방법입니다.
# aws_autoscaling.py - Boto3를 사용한 Auto Scaling 설정
import boto3
autoscaling = boto3.client('autoscaling', region_name='ap-northeast-2')
# Launch Template 생성
ec2 = boto3.client('ec2', region_name='ap-northeast-2')
launch_template = ec2.create_launch_template(
LaunchTemplateName='django-ninja-template',
LaunchTemplateData={
'ImageId': 'ami-xxxxxxxxx', # Ubuntu/Amazon Linux AMI
'InstanceType': 't3.medium',
'KeyName': 'my-key-pair',
'SecurityGroupIds': ['sg-xxxxxxxxx'],
'UserData': '''#!/bin/bash
# 인스턴스 시작 스크립트
cd /opt/myapp
git pull origin main
docker-compose up -d
''',
'IamInstanceProfile': {
'Name': 'django-ninja-instance-profile'
},
'TagSpecifications': [{
'ResourceType': 'instance',
'Tags': [
{'Key': 'Name', 'Value': 'django-ninja-api'},
{'Key': 'Environment', 'Value': 'production'}
]
}]
}
)
# Auto Scaling Group 생성
autoscaling.create_auto_scaling_group(
AutoScalingGroupName='django-ninja-asg',
LaunchTemplate={
'LaunchTemplateId': launch_template['LaunchTemplate']['LaunchTemplateId'],
'Version': '$Latest'
},
MinSize=3, # 최소 인스턴스
MaxSize=20, # 최대 인스턴스
DesiredCapacity=5, # 목표 인스턴스
VPCZoneIdentifier='subnet-xxxxx,subnet-yyyyy', # 여러 AZ 사용
HealthCheckType='ELB',
HealthCheckGracePeriod=300,
TargetGroupARNs=['arn:aws:elasticloadbalancing:...']
)
# CPU 기반 스케일링 정책
autoscaling.put_scaling_policy(
AutoScalingGroupName='django-ninja-asg',
PolicyName='cpu-scale-out',
PolicyType='TargetTrackingScaling',
TargetTrackingConfiguration={
'PredefinedMetricSpecification': {
'PredefinedMetricType': 'ASGAverageCPUUtilization'
},
'TargetValue': 70.0 # CPU 70% 유지
}
)
# 요청 수 기반 스케일링 정책
autoscaling.put_scaling_policy(
AutoScalingGroupName='django-ninja-asg',
PolicyName='request-count-scale-out',
PolicyType='TargetTrackingScaling',
TargetTrackingConfiguration={
'PredefinedMetricSpecification': {
'PredefinedMetricType': 'ALBRequestCountPerTarget',
'ResourceLabel': 'app/my-load-balancer/xxx/targetgroup/my-targets/yyy'
},
'TargetValue': 1000.0 # 타겟당 1000 요청 유지
}
)
예측 기반 스케일링 (Predictive Scaling)
과거 트래픽 패턴을 분석하여 미리 스케일 아웃합니다.
# AWS Predictive Scaling 설정
autoscaling.put_scaling_policy(
AutoScalingGroupName='django-ninja-asg',
PolicyName='predictive-scaling',
PolicyType='PredictiveScaling',
PredictiveScalingConfiguration={
'MetricSpecifications': [{
'TargetValue': 70.0,
'PredefinedMetricPairSpecification': {
'PredefinedMetricType': 'ASGCPUUtilization'
}
}],
'Mode': 'ForecastAndScale', # 예측 및 스케일링 실행
'SchedulingBufferTime': 600 # 10분 전 미리 스케일 아웃
}
)
스케줄 기반 스케일링
특정 시간대에 트래픽이 증가하는 패턴이 명확할 때 사용합니다.
# 평일 오전 9시에 스케일 아웃
autoscaling.put_scheduled_action(
AutoScalingGroupName='django-ninja-asg',
ScheduledActionName='morning-scale-out',
Recurrence='0 9 * * 1-5', # Cron 표현식: 평일 오전 9시
MinSize=10,
MaxSize=30,
DesiredCapacity=15
)
# 평일 오후 6시에 스케일 인
autoscaling.put_scheduled_action(
AutoScalingGroupName='django-ninja-asg',
ScheduledActionName='evening-scale-in',
Recurrence='0 18 * * 1-5', # 평일 오후 6시
MinSize=3,
MaxSize=20,
DesiredCapacity=5
)
모니터링과 알람 설정
스케일링 이벤트를 모니터링하고 문제 발생 시 알림을 받습니다.
# CloudWatch 알람 생성
cloudwatch = boto3.client('cloudwatch', region_name='ap-northeast-2')
# 높은 CPU 사용률 알람
cloudwatch.put_metric_alarm(
AlarmName='django-ninja-high-cpu',
ComparisonOperator='GreaterThanThreshold',
EvaluationPeriods=2,
MetricName='CPUUtilization',
Namespace='AWS/EC2',
Period=300,
Statistic='Average',
Threshold=80.0,
ActionsEnabled=True,
AlarmActions=['arn:aws:sns:ap-northeast-2:xxxxx:alerts'],
AlarmDescription='Django-Ninja API 높은 CPU 사용률',
Dimensions=[{
'Name': 'AutoScalingGroupName',
'Value': 'django-ninja-asg'
}]
)
# 응답 시간 알람
cloudwatch.put_metric_alarm(
AlarmName='django-ninja-slow-response',
ComparisonOperator='GreaterThanThreshold',
EvaluationPeriods=3,
MetricName='TargetResponseTime',
Namespace='AWS/ApplicationELB',
Period=60,
Statistic='Average',
Threshold=1.0, # 1초
ActionsEnabled=True,
AlarmActions=['arn:aws:sns:ap-northeast-2:xxxxx:alerts'],
AlarmDescription='Django-Ninja API 느린 응답 시간'
)
스케일링 효과 측정
# 스케일링 이벤트 조회
import boto3
from datetime import datetime, timedelta
autoscaling = boto3.client('autoscaling')
def get_scaling_activities(hours=24):
"""최근 스케일링 활동 조회"""
response = autoscaling.describe_scaling_activities(
AutoScalingGroupName='django-ninja-asg',
MaxRecords=100
)
activities = response['Activities']
for activity in activities:
print(f"""
시간: {activity['StartTime']}
활동: {activity['Description']}
상태: {activity['StatusCode']}
원인: {activity['Cause']}
""")
return activities
# 현재 인스턴스 상태 확인
def get_current_capacity():
"""현재 Auto Scaling 그룹 상태"""
response = autoscaling.describe_auto_scaling_groups(
AutoScalingGroupNames=['django-ninja-asg']
)
group = response['AutoScalingGroups'][0]
return {
'desired': group['DesiredCapacity'],
'min': group['MinSize'],
'max': group['MaxSize'],
'current': len(group['Instances']),
'instances': [i['InstanceId'] for i in group['Instances']]
}
오토 스케일링의 효과:
- 비용 최적화: 트래픽에 따라 리소스 자동 조절로 30-50% 비용 절감
- 안정성 향상: 트래픽 급증 시 자동 대응으로 서비스 다운 방지
- 관리 부담 감소: 수동 개입 없이 자동으로 용량 조절
- 탄력적 대응: 예상치 못한 트래픽 폭증에도 자동 대응
📊 단계별 성능 개선 효과 비교
각 최적화 단계를 적용했을 때의 실제 성능 개선 효과를 정리해보겠습니다.
| 최적화 단계 | 처리량 (RPS) | 평균 응답시간 | DB 쿼리 수 | 비용 효율 |
|---|---|---|---|---|
| 초기 상태 | 500 | 500ms | 100 | 기준 |
| 1단계: 자체 최적화 | 2,500 | 100ms | 10 | +400% |
| 2단계: Redis 캐싱 | 8,000 | 15ms | 3 | +1,500% |
| 3단계: 아키텍처 변경 | 25,000 | 20ms | 2 | +4,900% |
| 4단계: 오토스케일링 | 100,000+ | 25ms | 2 | +19,900% |
주요 성능 지표 개선:
- 처리량: 500 → 100,000+ RPS (200배 향상)
- 응답 시간: 500ms → 25ms (20배 개선)
- 데이터베이스 부하: 100 → 2 쿼리 (98% 감소)
- 비용 대비 효율: 단계적 최적화로 199배 향상
🎯 실전 적용 로드맵
실제 서비스에 적용할 때는 다음 순서로 단계적으로 진행하는 것을 권장합니다.
1주차: 기초 최적화
# 1. Django Debug Toolbar로 병목 지점 파악
pip install django-debug-toolbar
# 2. N+1 쿼리 제거
# 3. 데이터베이스 인덱스 추가
python manage.py makemigrations
python manage.py migrate
# 4. 페이지네이션 적용
# 5. 성능 테스트 (Apache Bench, Locust)
pip install locust
locust -f locustfile.py --host=http://localhost:8000
2주차: 캐싱 도입
# 1. Redis 설치 및 설정
docker run -d -p 6379:6379 redis:alpine
# 2. Django-Redis 설치
pip install django-redis
# 3. 캐싱 전략 구현
# 4. 캐시 히트율 모니터링
# 5. 캐시 무효화 로직 테스트
3-4주차: 인프라 확장
# 1. Docker 컨테이너화
docker build -t django-ninja-api .
# 2. 로드 밸런서 설정 (Nginx)
# 3. 데이터베이스 레플리카 설정
# 4. 정적 파일 CDN 이전
# 5. 부하 테스트 및 병목 지점 재확인
5-6주차: 오토 스케일링
# 1. Kubernetes 클러스터 구성
kubectl apply -f deployment.yaml
# 2. HPA 설정
kubectl apply -f hpa.yaml
# 3. 모니터링 대시보드 구축 (Prometheus + Grafana)
# 4. 알람 설정
# 5. 스트레스 테스트
💡 베스트 프랙티스와 주의사항
반드시 지켜야 할 원칙
- 측정 없이 최적화 하지 말 것
- 추측이 아닌 데이터 기반 최적화
- 병목 지점을 먼저 파악하고 해결
- 단계적 적용
- 한 번에 모든 것을 바꾸지 말 것
- 각 단계마다 성능 측정 및 검증
- 모니터링 우선
- 최적화 전후 비교 가능하도록 메트릭 수집
- 실시간 알람 설정으로 문제 조기 발견
- 캐시 무효화 전략
- 캐시는 양날의 검
- 잘못된 캐시로 인한 데이터 불일치 방지
흔한 실수와 해결 방법
# ❌ 나쁜 예: 모든 것을 캐싱
@api.get("/user-balance/{user_id}")
@cache_response(timeout=3600) # 1시간 캐싱 - 잔액 데이터는 실시간이어야 함!
def get_user_balance(request, user_id: int):
return {"balance": User.objects.get(id=user_id).balance}
# ✅ 좋은 예: 적절한 TTL과 무효화
@api.get("/user-balance/{user_id}")
@cache_response(timeout=10) # 10초 캐싱
def get_user_balance(request, user_id: int):
return {"balance": User.objects.get(id=user_id).balance}
@api.post("/user-balance/deposit")
def deposit(request, user_id: int, amount: float):
user = User.objects.get(id=user_id)
user.balance += amount
user.save()
# 즉시 캐시 무효화
cache.delete(f"api:get_user_balance:{user_id}")
return {"balance": user.balance}
# ❌ 나쁜 예: 과도한 프리페치
posts = Post.objects.prefetch_related(
'author',
'author__profile',
'author__posts',
'author__comments',
'tags',
'comments',
'comments__author',
# ... 20개 이상의 관계
) # 메모리 폭발!
# ✅ 좋은 예: 필요한 관계만 로드
posts = Post.objects.prefetch_related('tags').select_related('author')
🔍 추가 학습 리소스
더 깊이 있는 학습을 위한 리소스:
- Django-Ninja 공식 문서: https://django-ninja.rest-framework.com/
- Django 성능 최적화 가이드: Django 공식 문서 Performance 섹션
- Redis 캐싱 패턴: Redis University 무료 강의
- Kubernetes 실전 가이드: CNCF 공식 튜토리얼
- 시스템 설계 패턴: “Designing Data-Intensive Applications” by Martin Kleppmann
마치며
Django-Ninja로 대량 트래픽을 처리하는 것은 단순히 빠른 프레임워크를 선택하는 것 이상의 작업입니다. 애플리케이션 최적화, 캐싱 전략, 아키텍처 설계, 오토 스케일링까지 체계적인 접근이 필요합니다.
핵심 요약:
- 애플리케이션 최적화: N+1 쿼리 제거, 인덱스 추가, 페이지네이션으로 5-10배 성능 향상
- Redis 캐싱: 데이터베이스 부하를 70-90% 줄이고 응답 시간을 20배 단축
- 아키텍처 변경: 로드 밸런서, DB 레플리카, 마이크로서비스로 수평 확장 가능
- 오토 스케일링: 트래픽에 따라 자동 확장하여 비용 절감과 안정성 확보
가장 중요한 것은 측정과 모니터링입니다. 데이터 기반으로 병목 지점을 파악하고 단계적으로 최적화하면, Django-Ninja로도 초당 수만 건의 요청을 안정적으로 처리할 수 있습니다.
여러분의 서비스가 폭발적으로 성장하는 그날까지, 이 가이드가 도움이 되기를 바랍니다! 🚀