← 블로그 홈

Django Ninja 협업 가이드: 코드 가시성을 위한 실전 패턴

Django Ninja는 FastAPI의 장점을 Django에 결합한 현대적인 API 프레임워크입니다. 팀 프로젝트에서 Django Ninja를 활용할 때 코드의 가시성과 유지보수성을 높이는 실전 패턴들을 소개합니다.

Django Ninja는 FastAPI의 장점을 Django에 결합한 현대적인 API 프레임워크입니다. 팀 프로젝트에서 Django Ninja를 활용할 때 코드의 가시성과 유지보수성을 높이는 실전 패턴들을 소개합니다.

🎯 협업에서 중요한 코드 가시성

왜 코드 가시성이 중요한가?

팀 협업 시 발생하는 문제들

  • API 스펙 불일치로 인한 프론트엔드-백엔드 소통 오류
  • 복잡한 비즈니스 로직의 이해 부족
  • 일관성 없는 코드 스타일로 인한 혼란
  • 문서화 부족으로 인한 온보딩 어려움

Django Ninja가 제공하는 해결책

  • 자동 문서 생성 (OpenAPI/Swagger)
  • 타입 힌트 기반 검증
  • 직관적인 코드 구조
  • FastAPI와 유사한 선언적 문법

📝 1. Schema 설계로 명확한 데이터 구조 정의

입력/출력 Schema 분리

# schemas.py
from ninja import Schema
from typing import Optional
from datetime import datetime

class UserCreateSchema(Schema):
    """사용자 생성 요청 스키마"""
    username: str
    email: str
    password: str
    full_name: Optional[str] = None
    
    class Config:
        schema_extra = {
            "example": {
                "username": "john_doe",
                "email": "john@example.com",
                "password": "secure_password123",
                "full_name": "John Doe"
            }
        }

class UserResponseSchema(Schema):
    """사용자 응답 스키마"""
    id: int
    username: str
    email: str
    full_name: Optional[str]
    created_at: datetime
    is_active: bool
    
class UserUpdateSchema(Schema):
    """사용자 수정 요청 스키마"""
    email: Optional[str] = None
    full_name: Optional[str] = None
    is_active: Optional[bool] = None

중첩 Schema로 복잡한 데이터 표현

# schemas/product.py
from ninja import Schema
from typing import List, Optional
from decimal import Decimal

class CategorySchema(Schema):
    id: int
    name: str
    slug: str

class ProductImageSchema(Schema):
    id: int
    url: str
    alt_text: str
    is_primary: bool

class ProductDetailSchema(Schema):
    """상품 상세 정보 스키마"""
    id: int
    name: str
    description: str
    price: Decimal
    category: CategorySchema
    images: List[ProductImageSchema]
    tags: List[str]
    stock_quantity: int
    is_available: bool
    created_at: datetime
    
    @staticmethod
    def resolve_tags(obj):
        """태그 리스트 변환 로직"""
        return [tag.name for tag in obj.tags.all()]

🏗️ 2. Router 기반 모듈화 구조

도메인별 Router 분리

# api/users.py
from ninja import Router
from typing import List
from django.shortcuts import get_object_or_404
from django.contrib.auth.models import User

from .schemas import UserCreateSchema, UserResponseSchema, UserUpdateSchema
from .auth import AuthBearer

router = Router(tags=["Users"])

@router.get("/", response=List[UserResponseSchema])
def list_users(request):
    """
    사용자 목록 조회
    
    관리자만 접근 가능한 엔드포인트입니다.
    페이지네이션은 추후 추가 예정입니다.
    """
    users = User.objects.select_related().all()[:50]
    return users

@router.post("/", response=UserResponseSchema)
def create_user(request, user_data: UserCreateSchema):
    """
    새 사용자 생성
    
    - 이메일 중복 검사 수행
    - 패스워드 해싱 자동 처리
    - 기본 권한 그룹 할당
    """
    user = User.objects.create_user(
        username=user_data.username,
        email=user_data.email,
        password=user_data.password,
        first_name=user_data.full_name
    )
    return user

@router.get("/{user_id}", response=UserResponseSchema)
def get_user(request, user_id: int):
    """사용자 상세 정보 조회"""
    user = get_object_or_404(User, id=user_id)
    return user

@router.patch("/{user_id}", response=UserResponseSchema, auth=AuthBearer())
def update_user(request, user_id: int, user_data: UserUpdateSchema):
    """
    사용자 정보 수정
    
    인증된 사용자만 자신의 정보를 수정할 수 있습니다.
    """
    user = get_object_or_404(User, id=user_id)
    
    # 권한 검사
    if request.auth.id != user_id and not request.auth.is_staff:
        return {"error": "Permission denied"}, 403
    
    # 부분 업데이트
    for attr, value in user_data.dict(exclude_unset=True).items():
        setattr(user, attr, value)
    
    user.save()
    return user

메인 API 라우터 구성

# api/__init__.py
from ninja import NinjaAPI
from .users import router as users_router
from .products import router as products_router
from .orders import router as orders_router

api = NinjaAPI(
    title="E-Commerce API",
    version="1.0.0",
    description="팀 프로젝트용 전자상거래 API",
    docs_url="/docs/"
)

# 라우터 등록
api.add_router("/users", users_router)
api.add_router("/products", products_router)
api.add_router("/orders", orders_router)

# URL 구성
# urls.py
from django.urls import path
from .api import api

urlpatterns = [
    path("api/", api.urls),
]

🔐 3. 인증 및 권한 관리

토큰 기반 인증 구현

# auth.py
from ninja.security import HttpBearer
from django.contrib.auth.models import User
from django.contrib.auth import authenticate
from rest_framework_simplejwt.tokens import RefreshToken

class AuthBearer(HttpBearer):
    """JWT 토큰 기반 인증"""
    
    def authenticate(self, request, token):
        try:
            # JWT 토큰 검증 로직
            user = self.get_user_from_token(token)
            return user
        except Exception:
            return None
    
    def get_user_from_token(self, token):
        # 실제 JWT 검증 로직 구현
        pass

# 로그인 엔드포인트
@router.post("/auth/login")
def login(request, credentials: LoginSchema):
    """
    사용자 로그인
    
    성공 시 access_token과 refresh_token 반환
    """
    user = authenticate(
        username=credentials.username,
        password=credentials.password
    )
    
    if user:
        refresh = RefreshToken.for_user(user)
        return {
            "access_token": str(refresh.access_token),
            "refresh_token": str(refresh),
            "user": UserResponseSchema.from_orm(user)
        }
    
    return {"error": "Invalid credentials"}, 401

📊 4. 에러 핸들링과 응답 표준화

일관된 에러 응답 구조

# exceptions.py
from ninja import Schema

class ErrorSchema(Schema):
    """표준 에러 응답 스키마"""
    error: str
    message: str
    details: dict = None

class ValidationErrorSchema(Schema):
    """유효성 검사 에러 스키마"""
    error: str = "validation_error"
    field_errors: dict

# 전역 예외 핸들러
@api.exception_handler(ValidationError)
def validation_exception_handler(request, exc):
    return api.create_response(
        request,
        {"error": "validation_error", "field_errors": exc.errors},
        status=400
    )

@api.exception_handler(PermissionError)
def permission_exception_handler(request, exc):
    return api.create_response(
        request,
        {"error": "permission_denied", "message": str(exc)},
        status=403
    )

응답 래퍼 활용

# response_wrapper.py
from ninja import Schema
from typing import Generic, TypeVar, Optional

T = TypeVar('T')

class ApiResponse(Schema, Generic[T]):
    """표준 API 응답 래퍼"""
    success: bool
    data: Optional[T] = None
    message: str = ""
    pagination: Optional[dict] = None

# 사용 예시
@router.get("/", response=ApiResponse[List[UserResponseSchema]])
def list_users(request, page: int = 1, size: int = 20):
    """페이지네이션이 적용된 사용자 목록"""
    users = User.objects.all()
    paginated_users = paginate(users, page, size)
    
    return ApiResponse(
        success=True,
        data=paginated_users.items,
        pagination={
            "current_page": page,
            "total_pages": paginated_users.pages,
            "total_items": paginated_users.total
        }
    )

🧪 5. 테스트 작성으로 코드 신뢰성 확보

API 엔드포인트 테스트

# tests/test_users_api.py
from django.test import TestCase
from django.contrib.auth.models import User
from ninja.testing import TestClient
from api import api

class UserAPITestCase(TestCase):
    def setUp(self):
        self.client = TestClient(api)
        self.user = User.objects.create_user(
            username="testuser",
            email="test@example.com",
            password="testpass123"
        )
    
    def test_create_user_success(self):
        """사용자 생성 성공 테스트"""
        user_data = {
            "username": "newuser",
            "email": "new@example.com",
            "password": "newpass123",
            "full_name": "New User"
        }
        
        response = self.client.post("/users/", json=user_data)
        
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["username"], "newuser")
        self.assertTrue(User.objects.filter(username="newuser").exists())
    
    def test_create_user_duplicate_username(self):
        """중복 사용자명 생성 실패 테스트"""
        user_data = {
            "username": "testuser",  # 이미 존재하는 사용자명
            "email": "another@example.com",
            "password": "anotherpass123"
        }
        
        response = self.client.post("/users/", json=user_data)
        
        self.assertEqual(response.status_code, 400)
        self.assertIn("username", response.json()["field_errors"])

📚 6. 문서화와 주석 Best Practices

자동 문서 생성 활용

@router.get("/search", response=List[UserResponseSchema])
def search_users(
    request,
    q: str = Query(..., description="검색 키워드"),
    active_only: bool = Query(True, description="활성 사용자만 조회"),
    limit: int = Query(20, ge=1, le=100, description="결과 개수 제한")
):
    """
    사용자 검색
    
    **검색 기능:**
    - 사용자명, 이메일, 전체 이름에서 검색
    - 대소문자 구분 없음
    - 부분 문자열 매칭
    
    **필터 옵션:**
    - active_only: 활성 사용자만 조회 (기본값: True)
    - limit: 최대 결과 개수 (1-100, 기본값: 20)
    
    **예시 요청:**
    ```
    GET /users/search?q=john&active_only=true&limit=10
    ```
    """
    queryset = User.objects.all()
    
    if active_only:
        queryset = queryset.filter(is_active=True)
    
    queryset = queryset.filter(
        Q(username__icontains=q) |
        Q(email__icontains=q) |
        Q(first_name__icontains=q) |
        Q(last_name__icontains=q)
    )
    
    return queryset[:limit]

팀 컨벤션 문서화

# docs/api_conventions.md 참조용 코드
"""
API 개발 컨벤션

1. 네이밍 규칙:
   - 스키마: PascalCase + 용도 suffix (CreateSchema, ResponseSchema)
   - 함수: snake_case
   - 경로: kebab-case

2. 응답 코드 규칙:
   - 200: 성공
   - 201: 생성 성공
   - 400: 클라이언트 오류
   - 401: 인증 필요
   - 403: 권한 부족
   - 404: 리소스 없음
   - 500: 서버 오류

3. 에러 메시지:
   - 영어로 작성
   - 클라이언트가 이해할 수 있는 수준
   - 보안 정보 노출 금지
"""

🚀 7. 성능 최적화와 모니터링

데이터베이스 쿼리 최적화

@router.get("/", response=List[UserWithProfileSchema])
def list_users_optimized(request):
    """
    최적화된 사용자 목록 조회
    
    N+1 쿼리 문제를 해결하기 위해 select_related 사용
    """
    users = User.objects.select_related('profile').prefetch_related('groups')
    return users

# 쿼리 분석 데코레이터
from django.db import connection
from functools import wraps

def query_debugger(func):
    """개발 환경에서 SQL 쿼리 분석"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        initial_queries = len(connection.queries)
        result = func(*args, **kwargs)
        final_queries = len(connection.queries)
        
        if settings.DEBUG:
            print(f"Function {func.__name__} executed {final_queries - initial_queries} queries")
        
        return result
    return wrapper

🎉 결론

Django Ninja를 활용한 협업에서 코드 가시성을 높이는 핵심 요소들:

✅ 핵심 포인트

  1. 타입 힌트 활용: 명확한 데이터 구조 정의
  2. 모듈화 구조: 도메인별 Router 분리
  3. 표준화된 응답: 일관된 API 스펙
  4. 자동 문서화: Swagger/OpenAPI 활용
  5. 테스트 커버리지: 신뢰할 수 있는 코드
  6. 성능 고려: 최적화된 쿼리 패턴

🛠️ 실무 적용 팁

  • 점진적 도입: 기존 Django REST Framework에서 단계적 마이그레이션
  • 팀 교육: Django Ninja의 FastAPI 스타일 익히기
  • 컨벤션 정립: 팀만의 코딩 스타일 가이드 작성
  • CI/CD 통합: 자동 테스트와 문서 배포

Django Ninja는 FastAPI의 편의성과 Django의 안정성을 결합하여, 팀 프로젝트에서 높은 생산성과 코드 품질을 동시에 달성할 수 있는 강력한 도구입니다. 이러한 패턴들을 적용하여 더 나은 협업 환경을 만들어보세요!

이 글을 공유해보세요!