Django Ninja ModelSchema와 PatchDict 완전 활용 가이드: 더 스마트한 API 개발
Django Ninja ModelSchema와 PatchDict 완전 활용 가이드
Django Ninja ModelSchema와 PatchDict 완전 활용 가이드
Django Ninja를 사용하면서 매번 모델 필드를 Schema로 다시 정의하는 것이 번거로웠던 경험이 있나요? 또는 PATCH 요청에서 선택적 필드 업데이트를 처리할 때 복잡한 로직을 작성해야 했던 적이 있나요?
Django Ninja의 ModelSchema와 PatchDict는 이런 문제들을 우아하게 해결해주는 강력한 도구들입니다. 이 포스트에서는 이 두 기능을 실무에서 어떻게 효과적으로 활용할 수 있는지 상세한 예제와 함께 알아보겠습니다.
🎯 학습 목표
- ModelSchema의 다양한 활용 방법과 최적화 기법
- PatchDict를 활용한 우아한 PATCH 요청 처리
- 실무에서 자주 마주치는 시나리오별 해결책
- 보안과 성능을 고려한 스키마 설계 전략
📋 1. ModelSchema 기초: Django 모델에서 스키마 자동 생성
1.1 기본 사용법
Django Ninja의 ModelSchema는 Django 모델에서 자동으로 Pydantic 스키마를 생성해주는 혁신적인 기능입니다.
# models.py
from django.db import models
from django.contrib.auth.models import User
class Category(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
author = models.ForeignKey(User, on_delete=models.CASCADE)
category = models.ForeignKey(Category, on_delete=models.CASCADE)
tags = models.CharField(max_length=200, blank=True)
is_published = models.BooleanField(default=False)
view_count = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
# schemas.py
from ninja import ModelSchema
from .models import Post, Category
# 기본 ModelSchema 사용
class PostSchema(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'content', 'is_published', 'created_at']
# 생성되는 스키마는 다음과 동일합니다:
# class PostSchema(Schema):
# id: int
# title: str
# content: str
# is_published: bool
# created_at: datetime
1.2 기존 방식과의 비교
# 기존 방식 (수동 스키마 정의)
from ninja import Schema
from datetime import datetime
class PostSchemaManual(Schema):
id: int
title: str
content: str
is_published: bool
created_at: datetime
# 필드가 많아질수록 반복 작업 증가
# 모델 변경 시 스키마도 수동으로 업데이트 필요
# ModelSchema 방식 (자동 생성)
class PostSchemaAuto(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'content', 'is_published', 'created_at']
# 모델 변경이 자동으로 반영됨
# 타입 안정성 보장
# 코드 중복 제거
1.3 API 엔드포인트 활용
# api.py
from ninja import NinjaAPI
from django.shortcuts import get_object_or_404
from .models import Post
from .schemas import PostSchema
api = NinjaAPI()
@api.get("/posts", response=list[PostSchema])
def list_posts(request):
"""포스트 목록 조회"""
return Post.objects.filter(is_published=True)
@api.get("/posts/{post_id}", response=PostSchema)
def get_post(request, post_id: int):
"""포스트 상세 조회"""
return get_object_or_404(Post, id=post_id, is_published=True)
🔧 2. ModelSchema 고급 활용법
2.1 필드 선택과 제외 전략
2.1.1 보안을 고려한 필드 선택
# 안전하지 않은 방법 - 절대 사용 금지!
class UnsafeUserSchema(ModelSchema):
class Meta:
model = User
fields = "__all__" # 패스워드까지 노출됨!
# 안전한 방법 - 명시적 필드 선택
class SafeUserSchema(ModelSchema):
class Meta:
model = User
fields = ['id', 'username', 'first_name', 'last_name', 'email', 'date_joined']
# 또는 제외 방식 사용
class SafeUserSchemaExclude(ModelSchema):
class Meta:
model = User
exclude = ['password', 'user_permissions', 'groups', 'last_login']
2.1.2 역할별 스키마 설계
# 공개 API용 스키마 (최소 정보)
class PostPublicSchema(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'content', 'created_at']
# 관리자용 스키마 (상세 정보)
class PostAdminSchema(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'content', 'author', 'category',
'is_published', 'view_count', 'created_at', 'updated_at']
# 작성자용 스키마 (편집 가능 정보)
class PostAuthorSchema(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'content', 'category', 'tags', 'is_published']
2.2 관계 필드 처리 및 중첩 스키마
# 관계 필드가 포함된 고급 스키마
class CategorySchema(ModelSchema):
class Meta:
model = Category
fields = ['id', 'name', 'description']
class UserSimpleSchema(ModelSchema):
class Meta:
model = User
fields = ['id', 'username', 'first_name', 'last_name']
class PostDetailSchema(ModelSchema):
# 관계 필드 오버라이드
author: UserSimpleSchema
category: CategorySchema
class Meta:
model = Post
fields = ['id', 'title', 'content', 'author', 'category',
'tags', 'is_published', 'view_count', 'created_at']
# API에서 사용
@api.get("/posts/{post_id}/detail", response=PostDetailSchema)
def get_post_detail(request, post_id: int):
"""포스트 상세 정보 (관계 필드 포함)"""
return get_object_or_404(
Post.objects.select_related('author', 'category'),
id=post_id
)
2.3 계산된 필드와 커스텀 필드
from ninja import Field
class PostWithStatsSchema(ModelSchema):
# 계산된 필드 추가
author_name: str = Field(..., description="작성자 이름")
category_name: str = Field(..., description="카테고리 이름")
is_recent: bool = Field(..., description="최근 작성 여부")
read_time: int = Field(..., description="예상 읽기 시간(분)")
class Meta:
model = Post
fields = ['id', 'title', 'content', 'view_count', 'created_at']
@staticmethod
def resolve_author_name(obj):
return f"{obj.author.first_name} {obj.author.last_name}".strip() or obj.author.username
@staticmethod
def resolve_category_name(obj):
return obj.category.name
@staticmethod
def resolve_is_recent(obj):
from django.utils import timezone
from datetime import timedelta
return obj.created_at >= timezone.now() - timedelta(days=7)
@staticmethod
def resolve_read_time(obj):
# 간단한 읽기 시간 계산 (분당 200단어 기준)
word_count = len(obj.content.split())
return max(1, word_count // 200)
# 사용 예시
@api.get("/posts/{post_id}/stats", response=PostWithStatsSchema)
def get_post_with_stats(request, post_id: int):
"""포스트 통계 정보 포함 조회"""
return get_object_or_404(
Post.objects.select_related('author', 'category'),
id=post_id
)
🔄 3. PatchDict: PATCH 요청의 혁신
3.1 기존 PATCH 처리의 문제점
# 기존 방식의 문제점들
from ninja import Schema
from typing import Optional
class PostUpdateSchema(Schema):
title: Optional[str] = None
content: Optional[str] = None
category_id: Optional[int] = None
tags: Optional[str] = None
is_published: Optional[bool] = None
@api.patch("/posts/{post_id}")
def update_post_old_way(request, post_id: int, payload: PostUpdateSchema):
post = get_object_or_404(Post, id=post_id)
# 문제 1: 명시적으로 제공되지 않은 필드도 None으로 설정될 수 있음
# 문제 2: 실제로 업데이트된 필드를 구분하기 어려움
# 문제 3: validation 로직이 복잡해짐
update_data = payload.dict(exclude_unset=True) # 해결책이지만 번거로움
for field, value in update_data.items():
if value is not None: # 추가 검증 필요
setattr(post, field, value)
post.save()
return {"message": "Updated successfully"}
3.2 PatchDict로 우아한 해결
from ninja import PatchDict
# 기본 스키마 (모든 필드가 required)
class PostUpdateSchema(Schema):
title: str
content: str
category_id: int
tags: str
is_published: bool
@api.patch("/posts/{post_id}")
def update_post_with_patchdict(request, post_id: int, payload: PatchDict[PostUpdateSchema]):
"""PatchDict를 사용한 우아한 PATCH 처리"""
post = get_object_or_404(Post, id=post_id)
# payload는 dict 타입이며, 실제로 제공된 필드만 포함됨
for field, value in payload.items():
setattr(post, field, value)
post.save()
return {"message": f"Updated fields: {list(payload.keys())}"}
# 사용 예시:
# PATCH /posts/1 {"title": "새 제목"}
# → payload = {"title": "새 제목"}
#
# PATCH /posts/1 {"title": "새 제목", "is_published": true}
# → payload = {"title": "새 제목", "is_published": true}
3.3 PatchDict와 ModelSchema 조합
# ModelSchema와 PatchDict 조합으로 최강의 PATCH API
class PostPatchSchema(ModelSchema):
class Meta:
model = Post
fields = ['title', 'content', 'category', 'tags', 'is_published']
# 모든 필드가 required로 정의되지만 PatchDict가 optional로 만들어줌
@api.patch("/posts/{post_id}")
def update_post_advanced(request, post_id: int, payload: PatchDict[PostPatchSchema]):
"""ModelSchema + PatchDict 조합"""
post = get_object_or_404(Post, id=post_id)
# 관계 필드 특별 처리
if 'category' in payload:
category_id = payload.pop('category')
post.category_id = category_id
# 나머지 필드 일괄 업데이트
for field, value in payload.items():
setattr(post, field, value)
post.save()
# 업데이트된 포스트 반환
return PostDetailSchema.from_orm(post)
3.4 복잡한 비즈니스 로직과 PatchDict
from django.db import transaction
from django.utils import timezone
class PostAdvancedPatchSchema(ModelSchema):
class Meta:
model = Post
fields = ['title', 'content', 'category', 'tags', 'is_published']
@api.patch("/posts/{post_id}/advanced")
def update_post_with_business_logic(request, post_id: int, payload: PatchDict[PostAdvancedPatchSchema]):
"""비즈니스 로직이 포함된 고급 PATCH 처리"""
with transaction.atomic():
post = get_object_or_404(Post, id=post_id)
# 권한 검증
if post.author != request.user:
return {"error": "Permission denied"}, 403
updated_fields = []
# 제목 업데이트 시 특별 로직
if 'title' in payload:
old_title = post.title
new_title = payload['title']
post.title = new_title
updated_fields.append('title')
# 로그 기록
print(f"Title changed: '{old_title}' → '{new_title}'")
# 발행 상태 변경 시 특별 로직
if 'is_published' in payload:
if payload['is_published'] and not post.is_published:
# 발행 시점 기록
post.published_at = timezone.now()
updated_fields.append('published_at')
# 알림 발송 등의 로직
send_publication_notification(post)
post.is_published = payload['is_published']
updated_fields.append('is_published')
# 카테고리 변경 시 특별 로직
if 'category' in payload:
old_category = post.category.name
new_category = get_object_or_404(Category, id=payload['category'])
post.category = new_category
updated_fields.append('category')
print(f"Category changed: '{old_category}' → '{new_category.name}'")
# 나머지 필드 일괄 처리
for field, value in payload.items():
if field not in ['title', 'is_published', 'category']:
setattr(post, field, value)
updated_fields.append(field)
post.updated_at = timezone.now()
post.save()
return {
"message": "Post updated successfully",
"updated_fields": updated_fields,
"post": PostDetailSchema.from_orm(post)
}
def send_publication_notification(post):
"""발행 알림 발송 (예시)"""
# 실제로는 Celery 태스크나 이메일 발송 로직
print(f"📢 새 포스트 발행: {post.title}")
🎨 4. 실무 시나리오별 활용 예제
4.1 사용자 프로필 관리 API
# 복잡한 사용자 프로필 모델
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
avatar = models.ImageField(upload_to='avatars/', blank=True)
birth_date = models.DateField(null=True, blank=True)
phone = models.CharField(max_length=20, blank=True)
website = models.URLField(blank=True)
location = models.CharField(max_length=100, blank=True)
is_public = models.BooleanField(default=True)
email_notifications = models.BooleanField(default=True)
# 소셜 미디어 링크
twitter_url = models.URLField(blank=True)
linkedin_url = models.URLField(blank=True)
github_url = models.URLField(blank=True)
# 다양한 용도별 스키마
class UserProfilePublicSchema(ModelSchema):
"""공개 프로필 스키마"""
username: str
full_name: str
class Meta:
model = UserProfile
fields = ['bio', 'location', 'website', 'twitter_url', 'linkedin_url', 'github_url']
@staticmethod
def resolve_username(obj):
return obj.user.username
@staticmethod
def resolve_full_name(obj):
return f"{obj.user.first_name} {obj.user.last_name}".strip()
class UserProfilePrivateSchema(ModelSchema):
"""개인 프로필 스키마 (본인만 접근)"""
username: str
email: str
class Meta:
model = UserProfile
fields = ['bio', 'birth_date', 'phone', 'website', 'location',
'is_public', 'email_notifications', 'twitter_url', 'linkedin_url', 'github_url']
class UserProfilePatchSchema(ModelSchema):
"""프로필 업데이트용 스키마"""
class Meta:
model = UserProfile
fields = ['bio', 'phone', 'website', 'location', 'is_public',
'email_notifications', 'twitter_url', 'linkedin_url', 'github_url']
# API 엔드포인트
@api.get("/users/{user_id}/profile", response=UserProfilePublicSchema)
def get_user_profile(request, user_id: int):
"""사용자 프로필 조회 (공개)"""
profile = get_object_or_404(UserProfile, user_id=user_id, is_public=True)
return profile
@api.get("/me/profile", response=UserProfilePrivateSchema)
def get_my_profile(request):
"""내 프로필 조회 (비공개 정보 포함)"""
profile, created = UserProfile.objects.get_or_create(user=request.user)
return profile
@api.patch("/me/profile")
def update_my_profile(request, payload: PatchDict[UserProfilePatchSchema]):
"""내 프로필 업데이트"""
profile, created = UserProfile.objects.get_or_create(user=request.user)
# 유효성 검증
if 'phone' in payload and payload['phone']:
# 전화번호 형식 검증 (예시)
import re
phone_pattern = r'^[\d\-\+\(\)\s]+$'
if not re.match(phone_pattern, payload['phone']):
return {"error": "Invalid phone number format"}, 400
# 프로필 업데이트
for field, value in payload.items():
setattr(profile, field, value)
profile.save()
return {
"message": "Profile updated successfully",
"updated_fields": list(payload.keys()),
"profile": UserProfilePrivateSchema.from_orm(profile)
}
4.2 전자상거래 상품 관리 API
# 상품 모델
class Product(models.Model):
name = models.CharField(max_length=200)
description = models.TextField()
price = models.DecimalField(max_digits=10, decimal_places=2)
sale_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
stock_quantity = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
category = models.ForeignKey('Category', on_delete=models.CASCADE)
brand = models.CharField(max_length=100, blank=True)
weight = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
dimensions = models.CharField(max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# 스키마 정의
class ProductListSchema(ModelSchema):
"""상품 목록용 스키마"""
effective_price: float
is_on_sale: bool
category_name: str
class Meta:
model = Product
fields = ['id', 'name', 'price', 'sale_price', 'stock_quantity', 'is_active']
@staticmethod
def resolve_effective_price(obj):
return float(obj.sale_price if obj.sale_price else obj.price)
@staticmethod
def resolve_is_on_sale(obj):
return obj.sale_price is not None and obj.sale_price < obj.price
@staticmethod
def resolve_category_name(obj):
return obj.category.name
class ProductDetailSchema(ModelSchema):
"""상품 상세 스키마"""
effective_price: float
is_on_sale: bool
discount_percentage: int
category_name: str
is_in_stock: bool
class Meta:
model = Product
fields = ['id', 'name', 'description', 'price', 'sale_price',
'stock_quantity', 'brand', 'weight', 'dimensions', 'created_at']
@staticmethod
def resolve_effective_price(obj):
return float(obj.sale_price if obj.sale_price else obj.price)
@staticmethod
def resolve_is_on_sale(obj):
return obj.sale_price is not None and obj.sale_price < obj.price
@staticmethod
def resolve_discount_percentage(obj):
if obj.sale_price and obj.price:
return int((1 - float(obj.sale_price) / float(obj.price)) * 100)
return 0
@staticmethod
def resolve_category_name(obj):
return obj.category.name
@staticmethod
def resolve_is_in_stock(obj):
return obj.stock_quantity > 0
class ProductUpdateSchema(ModelSchema):
"""상품 업데이트용 스키마"""
class Meta:
model = Product
fields = ['name', 'description', 'price', 'sale_price', 'stock_quantity',
'is_active', 'category', 'brand', 'weight', 'dimensions']
# API 엔드포인트
@api.get("/products", response=list[ProductListSchema])
def list_products(request, category_id: int = None, is_active: bool = True):
"""상품 목록 조회"""
queryset = Product.objects.select_related('category').filter(is_active=is_active)
if category_id:
queryset = queryset.filter(category_id=category_id)
return queryset
@api.patch("/products/{product_id}")
def update_product(request, product_id: int, payload: PatchDict[ProductUpdateSchema]):
"""상품 정보 업데이트"""
with transaction.atomic():
product = get_object_or_404(Product, id=product_id)
# 재고 변경 시 특별 로직
if 'stock_quantity' in payload:
old_stock = product.stock_quantity
new_stock = payload['stock_quantity']
# 재고 알림 로직
if old_stock <= 0 and new_stock > 0:
# 재입고 알림
notify_restock(product)
elif new_stock <= 5 and old_stock > 5:
# 재고 부족 알림
notify_low_stock(product)
# 가격 변경 시 특별 로직
if 'price' in payload or 'sale_price' in payload:
# 가격 변경 히스토리 기록
record_price_change(product, payload)
# 카테고리 변경 처리
if 'category' in payload:
category = get_object_or_404(Category, id=payload.pop('category'))
product.category = category
# 나머지 필드 업데이트
for field, value in payload.items():
setattr(product, field, value)
product.save()
return {
"message": "Product updated successfully",
"product": ProductDetailSchema.from_orm(product)
}
def notify_restock(product):
"""재입고 알림"""
print(f"🔄 재입고: {product.name}")
def notify_low_stock(product):
"""재고 부족 알림"""
print(f"⚠️ 재고 부족: {product.name} (남은 수량: {product.stock_quantity})")
def record_price_change(product, changes):
"""가격 변경 히스토리 기록"""
print(f"💰 가격 변경: {product.name} - {changes}")
🛡️ 5. 보안과 성능 최적화
5.1 민감한 정보 보호
# 민감한 정보가 포함된 모델
class PaymentMethod(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
card_type = models.CharField(max_length=20)
card_last_four = models.CharField(max_length=4)
card_number_encrypted = models.TextField() # 암호화된 카드번호
expiry_month = models.IntegerField()
expiry_year = models.IntegerField()
cardholder_name = models.CharField(max_length=100)
billing_address = models.TextField()
is_default = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
# 안전한 스키마 (민감한 정보 제외)
class PaymentMethodSafeSchema(ModelSchema):
class Meta:
model = PaymentMethod
fields = ['id', 'card_type', 'card_last_four', 'expiry_month', 'expiry_year', 'is_default']
# card_number_encrypted, billing_address 등 민감한 정보는 절대 노출하지 않음
# 업데이트용 스키마 (카드번호는 수정 불가)
class PaymentMethodUpdateSchema(ModelSchema):
class Meta:
model = PaymentMethod
fields = ['expiry_month', 'expiry_year', 'cardholder_name', 'is_default']
@api.patch("/payment-methods/{payment_id}")
def update_payment_method(request, payment_id: int, payload: PatchDict[PaymentMethodUpdateSchema]):
"""결제 수단 업데이트 (보안 고려)"""
payment_method = get_object_or_404(PaymentMethod, id=payment_id, user=request.user)
# 만료일 검증
if 'expiry_month' in payload or 'expiry_year' in payload:
from datetime import date
current_date = date.today()
expiry_month = payload.get('expiry_month', payment_method.expiry_month)
expiry_year = payload.get('expiry_year', payment_method.expiry_year)
if expiry_year < current_date.year or (expiry_year == current_date.year and expiry_month < current_date.month):
return {"error": "Card expiry date cannot be in the past"}, 400
# 기본 결제 수단 변경 시 다른 카드들의 기본 설정 해제
if payload.get('is_default'):
PaymentMethod.objects.filter(user=request.user).update(is_default=False)
for field, value in payload.items():
setattr(payment_method, field, value)
payment_method.save()
return PaymentMethodSafeSchema.from_orm(payment_method)
5.2 성능 최적화 전략
# 대용량 데이터 처리를 위한 최적화
class ArticleSchema(ModelSchema):
author_name: str
category_name: str
comment_count: int
class Meta:
model = Post
fields = ['id', 'title', 'created_at', 'view_count']
@staticmethod
def resolve_author_name(obj):
# select_related로 이미 로드된 데이터 활용
return obj.author.username
@staticmethod
def resolve_category_name(obj):
# select_related로 이미 로드된 데이터 활용
return obj.category.name
@staticmethod
def resolve_comment_count(obj):
# prefetch_related나 annotation 활용
return getattr(obj, 'comment_count', 0)
@api.get("/articles", response=list[ArticleSchema])
def list_articles_optimized(request, page: int = 1, per_page: int = 20):
"""최적화된 게시글 목록 조회"""
from django.db.models import Count
offset = (page - 1) * per_page
# 성능 최적화: select_related, prefetch_related, annotation 활용
queryset = Post.objects.select_related('author', 'category').annotate(
comment_count=Count('comments')
).filter(is_published=True).order_by('-created_at')[offset:offset + per_page]
return list(queryset)
# 캐싱을 활용한 최적화
from django.core.cache import cache
@api.get("/articles/{article_id}", response=ArticleSchema)
def get_article_cached(request, article_id: int):
"""캐싱을 활용한 게시글 조회"""
cache_key = f"article:{article_id}"
cached_article = cache.get(cache_key)
if cached_article is None:
article = get_object_or_404(
Post.objects.select_related('author', 'category').annotate(
comment_count=Count('comments')
),
id=article_id,
is_published=True
)
# 5분간 캐싱
cache.set(cache_key, article, 300)
cached_article = article
return cached_article
📋 결론 및 Best Practices
🎯 핵심 요약
- ModelSchema의 활용:
- 모델과 스키마 간의 동기화 자동화
- 명시적 필드 선택으로 보안 강화
- 역할별 스키마 설계로 유연성 확보
- PatchDict의 강력함:
- 우아한 PATCH 요청 처리
- 실제 변경된 필드만 식별
- 복잡한 비즈니스 로직과의 완벽한 조화
🛡️ 보안 Best Practices
- 절대
fields = "__all__"사용 금지 - 민감한 정보는 항상 명시적으로 제외
- 역할 기반 스키마 설계
- 입력 데이터 검증 강화
⚡ 성능 Best Practices
- select_related/prefetch_related 적극 활용
- annotation으로 계산 필드 최적화
- 적절한 캐싱 전략 수립
- 페이지네이션 구현
🔧 개발 Best Practices
- 용도별 스키마 분리 (Public, Private, Update)
- PatchDict로 PATCH API 단순화
- 계산된 필드로 클라이언트 편의성 향상
- 명확한 에러 메시지 제공
Django Ninja의 ModelSchema와 PatchDict는 단순한 편의 기능을 넘어서, 안전하고 효율적인 API 개발을 가능하게 해주는 강력한 도구입니다. 이 기능들을 적절히 활용하면 개발 생산성을 크게 향상시키면서도 높은 품질의 API를 구축할 수 있습니다.
앞으로 Django Ninja 프로젝트에서 이 기능들을 적극적으로 활용해보시기 바랍니다! 🚀
🏷️ 태그
Django Django-Ninja ModelSchema PatchDict API RESTful Pydantic Schema 성능최적화 보안