Django Ninja로 체험단 플랫폼 구축하기: 완전 가이드
체험단 플랫폼은 현대 마케팅에서 중요한 역할을 하는 서비스입니다. 이 글에서는 Django Ninja를 사용해 체험단 사이트를 구축하는 전 과정을 다루겠습니다. Project(체험공고)와 ProjectUser(참여자) 모델을 중심으로 실용적인 API 서비스를 만들어보겠습니다.
체험단 플랫폼은 현대 마케팅에서 중요한 역할을 하는 서비스입니다. 이 글에서는 Django Ninja를 사용해 체험단 사이트를 구축하는 전 과정을 다루겠습니다. Project(체험공고)와 ProjectUser(참여자) 모델을 중심으로 실용적인 API 서비스를 만들어보겠습니다.
🏗️ 프로젝트 개요
주요 기능
- 체험공고 관리: 기업이 체험단 모집 공고를 등록, 수정, 삭제
- 참여자 관리: 사용자가 체험단에 신청하고 진행 상태 추적
- 인증 시스템: JWT 기반 사용자 인증
- 관리자 기능: 체험단 승인/거절 및 전체 현황 관리
기술 스택
- Backend: Django + Django Ninja
- Database: PostgreSQL (개발 시 SQLite)
- Authentication: Django JWT
- API Documentation: Automatic Swagger/OpenAPI
🚀 프로젝트 설정
1. 가상환경 및 의존성 설치
# 가상환경 생성
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 필수 패키지 설치
pip install django django-ninja python-decouple django-cors-headers pillow
pip install psycopg2-binary # PostgreSQL 사용 시
2. Django 프로젝트 초기화
django-admin startproject experience_platform
cd experience_platform
python manage.py startapp core
python manage.py startapp projects
python manage.py startapp users
3. 설정 파일 구성
settings.py
import os
from decouple import config
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'core',
'projects',
'users',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'experience_platform.urls'
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME', default='experience_platform'),
'USER': config('DB_USER', default='postgres'),
'PASSWORD': config('DB_PASSWORD', default='password'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='5432'),
}
}
# CORS 설정
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1:3000",
]
# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
📊 모델 설계
Project 모델 (체험공고)
# projects/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from PIL import Image
import os
class ProjectCategory(models.TextChoices):
FOOD = 'food', '식품/음료'
BEAUTY = 'beauty', '뷰티/코스메틱'
FASHION = 'fashion', '패션/의류'
TECH = 'tech', '디지털/가전'
LIFESTYLE = 'lifestyle', '생활용품'
BOOK = 'book', '도서/문화'
TRAVEL = 'travel', '여행/레저'
OTHER = 'other', '기타'
class ProjectStatus(models.TextChoices):
DRAFT = 'draft', '작성중'
RECRUITING = 'recruiting', '모집중'
SELECTION = 'selection', '선발중'
ONGOING = 'ongoing', '진행중'
REVIEW = 'review', '리뷰작성중'
COMPLETED = 'completed', '완료'
CANCELLED = 'cancelled', '취소'
class Project(models.Model):
# 기본 정보
title = models.CharField(max_length=200, verbose_name="체험단 제목")
description = models.TextField(verbose_name="상세 설명")
category = models.CharField(
max_length=20,
choices=ProjectCategory.choices,
default=ProjectCategory.OTHER,
verbose_name="카테고리"
)
# 이미지
main_image = models.ImageField(
upload_to='projects/main/',
null=True,
blank=True,
verbose_name="메인 이미지"
)
detail_images = models.JSONField(
default=list,
blank=True,
verbose_name="상세 이미지 URL 목록"
)
# 모집 정보
recruit_count = models.PositiveIntegerField(verbose_name="모집 인원")
current_count = models.PositiveIntegerField(default=0, verbose_name="현재 신청자 수")
# 일정
recruit_start_date = models.DateTimeField(verbose_name="모집 시작일")
recruit_end_date = models.DateTimeField(verbose_name="모집 마감일")
experience_start_date = models.DateTimeField(verbose_name="체험 시작일")
experience_end_date = models.DateTimeField(verbose_name="체험 종료일")
review_deadline = models.DateTimeField(verbose_name="리뷰 작성 마감일")
# 조건
required_conditions = models.JSONField(
default=list,
blank=True,
help_text="예: ['성별', '연령대', '거주지역', '관심분야']",
verbose_name="참여 조건"
)
# 제공 혜택
benefits = models.JSONField(
default=list,
blank=True,
help_text="예: ['상품 무료 제공', '배송비 무료', '추가 할인 혜택']",
verbose_name="제공 혜택"
)
# 상태 관리
status = models.CharField(
max_length=20,
choices=ProjectStatus.choices,
default=ProjectStatus.DRAFT,
verbose_name="진행 상태"
)
# 관계
company = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='created_projects',
verbose_name="기업 계정"
)
participants = models.ManyToManyField(
User,
through='ProjectUser',
related_name='participating_projects',
verbose_name="참여자들"
)
# 메타 정보
created_at = models.DateTimeField(auto_now_add=True, verbose_name="생성일")
updated_at = models.DateTimeField(auto_now=True, verbose_name="수정일")
# SEO 및 검색
tags = models.JSONField(
default=list,
blank=True,
verbose_name="태그"
)
view_count = models.PositiveIntegerField(default=0, verbose_name="조회수")
class Meta:
verbose_name = "체험 프로젝트"
verbose_name_plural = "체험 프로젝트들"
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'category']),
models.Index(fields=['recruit_end_date']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"{self.title} ({self.get_status_display()})"
@property
def is_recruiting(self):
"""현재 모집 중인지 확인"""
now = timezone.now()
return (self.status == ProjectStatus.RECRUITING and
self.recruit_start_date <= now <= self.recruit_end_date)
@property
def is_full(self):
"""모집 인원이 가득 찼는지 확인"""
return self.current_count >= self.recruit_count
@property
def remaining_days(self):
"""모집 마감까지 남은 일수"""
if self.recruit_end_date:
remaining = self.recruit_end_date - timezone.now()
return max(0, remaining.days)
return 0
def save(self, *args, **kwargs):
# 이미지 최적화
if self.main_image:
self._optimize_image(self.main_image)
super().save(*args, **kwargs)
def _optimize_image(self, image_field):
"""이미지 크기 최적화"""
if image_field and hasattr(image_field, 'path'):
img = Image.open(image_field.path)
if img.width > 1200 or img.height > 800:
img.thumbnail((1200, 800), Image.Resampling.LANCZOS)
img.save(image_field.path, optimize=True, quality=85)
ProjectUser 모델 (참여자)
# projects/models.py (계속)
class ApplicationStatus(models.TextChoices):
PENDING = 'pending', '신청완료'
REVIEWING = 'reviewing', '검토중'
ACCEPTED = 'accepted', '선발완료'
REJECTED = 'rejected', '선발탈락'
PARTICIPATING = 'participating', '체험중'
REVIEW_REQUIRED = 'review_required', '리뷰작성필요'
COMPLETED = 'completed', '체험완료'
CANCELLED = 'cancelled', '신청취소'
class ProjectUser(models.Model):
# 관계
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name='applications',
verbose_name="프로젝트"
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='applications',
verbose_name="사용자"
)
# 신청 정보
application_message = models.TextField(
max_length=1000,
verbose_name="신청 메시지",
help_text="자신을 어필할 수 있는 내용을 작성해주세요"
)
additional_info = models.JSONField(
default=dict,
blank=True,
help_text="추가 정보 (연령, 성별, 거주지 등)",
verbose_name="추가 정보"
)
# 상태 관리
status = models.CharField(
max_length=20,
choices=ApplicationStatus.choices,
default=ApplicationStatus.PENDING,
verbose_name="신청 상태"
)
# 날짜 정보
applied_at = models.DateTimeField(auto_now_add=True, verbose_name="신청일")
status_updated_at = models.DateTimeField(auto_now=True, verbose_name="상태 업데이트일")
selected_at = models.DateTimeField(null=True, blank=True, verbose_name="선발일")
# 관리자 메모
admin_note = models.TextField(
blank=True,
verbose_name="관리자 메모",
help_text="선발/탈락 사유 등"
)
# 평가 점수 (선발 시 참고용)
score = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name="평가 점수",
help_text="1-100점 사이"
)
# 체험 후 정보
review_submitted = models.BooleanField(default=False, verbose_name="리뷰 제출 완료")
review_url = models.URLField(
blank=True,
verbose_name="리뷰 URL",
help_text="블로그, SNS 등 리뷰 링크"
)
class Meta:
verbose_name = "프로젝트 참여자"
verbose_name_plural = "프로젝트 참여자들"
unique_together = ['project', 'user'] # 한 프로젝트에 한 번만 신청 가능
ordering = ['-applied_at']
indexes = [
models.Index(fields=['status', 'applied_at']),
models.Index(fields=['project', 'status']),
]
def __str__(self):
return f"{self.user.username} - {self.project.title} ({self.get_status_display()})"
@property
def can_cancel(self):
"""신청 취소 가능 여부"""
return self.status in [ApplicationStatus.PENDING, ApplicationStatus.REVIEWING]
@property
def is_selected(self):
"""선발 여부"""
return self.status in [
ApplicationStatus.ACCEPTED,
ApplicationStatus.PARTICIPATING,
ApplicationStatus.REVIEW_REQUIRED,
ApplicationStatus.COMPLETED
]
def save(self, *args, **kwargs):
# 상태 변경 시 선발일 자동 설정
if self.status == ApplicationStatus.ACCEPTED and not self.selected_at:
self.selected_at = timezone.now()
# 선발 시 프로젝트 참여자 수 증가
if self.pk is None and self.status == ApplicationStatus.ACCEPTED:
self.project.current_count = models.F('current_count') + 1
self.project.save()
super().save(*args, **kwargs)
# 추가 모델들
class ProjectImage(models.Model):
"""프로젝트 상세 이미지"""
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name='images'
)
image = models.ImageField(upload_to='projects/details/')
caption = models.CharField(max_length=200, blank=True)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['order']
class ProjectReview(models.Model):
"""체험단 리뷰"""
project_user = models.OneToOneField(
ProjectUser,
on_delete=models.CASCADE,
related_name='review'
)
rating = models.PositiveIntegerField(
choices=[(i, f"{i}점") for i in range(1, 6)],
verbose_name="별점"
)
content = models.TextField(verbose_name="리뷰 내용")
images = models.JSONField(default=list, blank=True, verbose_name="리뷰 이미지")
external_url = models.URLField(blank=True, verbose_name="외부 리뷰 링크")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "체험 리뷰"
verbose_name_plural = "체험 리뷰들"
🔧 Django Ninja API 구현
1. 스키마 정의
# projects/schemas.py
from ninja import Schema, ModelSchema
from datetime import datetime
from typing import List, Optional, Dict, Any
from .models import Project, ProjectUser, ProjectCategory, ProjectStatus, ApplicationStatus
# 공통 스키마
class MessageSchema(Schema):
message: str
success: bool = True
class PaginationSchema(Schema):
count: int
next: Optional[str] = None
previous: Optional[str] = None
results: List[Any]
# Project 관련 스키마
class ProjectCreateSchema(Schema):
title: str
description: str
category: ProjectCategory
recruit_count: int
recruit_start_date: datetime
recruit_end_date: datetime
experience_start_date: datetime
experience_end_date: datetime
review_deadline: datetime
required_conditions: List[str] = []
benefits: List[str] = []
tags: List[str] = []
class ProjectUpdateSchema(Schema):
title: Optional[str] = None
description: Optional[str] = None
category: Optional[ProjectCategory] = None
recruit_count: Optional[int] = None
recruit_start_date: Optional[datetime] = None
recruit_end_date: Optional[datetime] = None
experience_start_date: Optional[datetime] = None
experience_end_date: Optional[datetime] = None
review_deadline: Optional[datetime] = None
required_conditions: Optional[List[str]] = None
benefits: Optional[List[str]] = None
tags: Optional[List[str]] = None
status: Optional[ProjectStatus] = None
class ProjectListSchema(ModelSchema):
remaining_days: int
is_recruiting: bool
is_full: bool
company_name: str
class Config:
model = Project
model_fields = [
'id', 'title', 'category', 'recruit_count', 'current_count',
'recruit_end_date', 'status', 'main_image', 'tags', 'view_count'
]
@staticmethod
def resolve_company_name(obj):
return obj.company.username
@staticmethod
def resolve_remaining_days(obj):
return obj.remaining_days
@staticmethod
def resolve_is_recruiting(obj):
return obj.is_recruiting
@staticmethod
def resolve_is_full(obj):
return obj.is_full
class ProjectDetailSchema(ModelSchema):
remaining_days: int
is_recruiting: bool
is_full: bool
company_name: str
application_count: int
class Config:
model = Project
model_fields = '__all__'
@staticmethod
def resolve_company_name(obj):
return obj.company.username
@staticmethod
def resolve_remaining_days(obj):
return obj.remaining_days
@staticmethod
def resolve_is_recruiting(obj):
return obj.is_recruiting
@staticmethod
def resolve_is_full(obj):
return obj.is_full
@staticmethod
def resolve_application_count(obj):
return obj.applications.count()
# ProjectUser 관련 스키마
class ProjectApplicationSchema(Schema):
application_message: str
additional_info: Dict[str, Any] = {}
class ProjectUserSchema(ModelSchema):
user_name: str
project_title: str
class Config:
model = ProjectUser
model_fields = [
'id', 'application_message', 'status', 'applied_at',
'status_updated_at', 'selected_at', 'score'
]
@staticmethod
def resolve_user_name(obj):
return obj.user.username
@staticmethod
def resolve_project_title(obj):
return obj.project.title
class ApplicationUpdateSchema(Schema):
status: ApplicationStatus
admin_note: Optional[str] = None
score: Optional[int] = None
# 필터링 스키마
class ProjectFilterSchema(Schema):
category: Optional[ProjectCategory] = None
status: Optional[ProjectStatus] = None
search: Optional[str] = None
ordering: Optional[str] = None
page: int = 1
page_size: int = 20
2. API 엔드포인트 구현
# projects/api.py
from ninja import Router, Query, File
from ninja.files import UploadedFile
from django.shortcuts import get_object_or_404
from django.db.models import Q, Count
from django.core.paginator import Paginator
from django.contrib.auth.models import User
from typing import List, Optional
from .models import Project, ProjectUser, ProjectCategory, ProjectStatus
from .schemas import (
ProjectCreateSchema, ProjectUpdateSchema, ProjectListSchema,
ProjectDetailSchema, ProjectApplicationSchema, ProjectUserSchema,
ApplicationUpdateSchema, MessageSchema, ProjectFilterSchema
)
router = Router()
# 프로젝트 목록 조회 (필터링, 검색, 정렬 지원)
@router.get("/projects", response=List[ProjectListSchema])
def list_projects(request, filters: ProjectFilterSchema = Query(...)):
queryset = Project.objects.select_related('company').prefetch_related('applications')
# 필터링
if filters.category:
queryset = queryset.filter(category=filters.category)
if filters.status:
queryset = queryset.filter(status=filters.status)
if filters.search:
queryset = queryset.filter(
Q(title__icontains=filters.search) |
Q(description__icontains=filters.search) |
Q(tags__icontains=filters.search)
)
# 정렬
ordering_map = {
'latest': '-created_at',
'oldest': 'created_at',
'deadline': 'recruit_end_date',
'popular': '-view_count',
'participants': '-current_count',
}
ordering = ordering_map.get(filters.ordering, '-created_at')
queryset = queryset.order_by(ordering)
# 페이지네이션
paginator = Paginator(queryset, filters.page_size)
page_obj = paginator.get_page(filters.page)
return page_obj.object_list
# 프로젝트 상세 조회
@router.get("/projects/{int:project_id}", response=ProjectDetailSchema)
def get_project(request, project_id: int):
project = get_object_or_404(
Project.objects.select_related('company')
.prefetch_related('applications', 'images'),
id=project_id
)
# 조회수 증가
Project.objects.filter(id=project_id).update(view_count=models.F('view_count') + 1)
return project
# 프로젝트 생성 (기업 계정만)
@router.post("/projects", response=ProjectDetailSchema)
def create_project(request, payload: ProjectCreateSchema):
if not request.user.is_authenticated:
return {"error": "로그인이 필요합니다"}, 401
# 기업 계정 확인 로직 (예: 별도 프로필 모델 확인)
if not hasattr(request.user, 'company_profile'):
return {"error": "기업 계정만 프로젝트를 생성할 수 있습니다"}, 403
project = Project.objects.create(
company=request.user,
**payload.dict()
)
return project
# 프로젝트 수정
@router.patch("/projects/{int:project_id}", response=ProjectDetailSchema)
def update_project(request, project_id: int, payload: ProjectUpdateSchema):
project = get_object_or_404(Project, id=project_id, company=request.user)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(project, attr, value)
project.save()
return project
# 프로젝트 삭제
@router.delete("/projects/{int:project_id}", response=MessageSchema)
def delete_project(request, project_id: int):
project = get_object_or_404(Project, id=project_id, company=request.user)
project.delete()
return {"message": "프로젝트가 삭제되었습니다"}
# 프로젝트 이미지 업로드
@router.post("/projects/{int:project_id}/images", response=MessageSchema)
def upload_project_image(request, project_id: int, file: UploadedFile = File(...)):
project = get_object_or_404(Project, id=project_id, company=request.user)
# 메인 이미지 업로드
project.main_image = file
project.save()
return {"message": "이미지가 업로드되었습니다"}
# 체험단 신청
@router.post("/projects/{int:project_id}/apply", response=MessageSchema)
def apply_project(request, project_id: int, payload: ProjectApplicationSchema):
if not request.user.is_authenticated:
return {"error": "로그인이 필요합니다"}, 401
project = get_object_or_404(Project, id=project_id)
# 이미 신청했는지 확인
if ProjectUser.objects.filter(project=project, user=request.user).exists():
return {"error": "이미 신청한 프로젝트입니다"}, 400
# 모집 기간 확인
if not project.is_recruiting:
return {"error": "모집 기간이 아닙니다"}, 400
# 모집 인원 확인
if project.is_full:
return {"error": "모집 인원이 마감되었습니다"}, 400
ProjectUser.objects.create(
project=project,
user=request.user,
**payload.dict()
)
return {"message": "체험단 신청이 완료되었습니다"}
# 신청 취소
@router.delete("/projects/{int:project_id}/apply", response=MessageSchema)
def cancel_application(request, project_id: int):
application = get_object_or_404(
ProjectUser,
project_id=project_id,
user=request.user
)
if not application.can_cancel:
return {"error": "취소할 수 없는 상태입니다"}, 400
application.delete()
return {"message": "신청이 취소되었습니다"}
# 내 신청 목록
@router.get("/my/applications", response=List[ProjectUserSchema])
def my_applications(request):
if not request.user.is_authenticated:
return {"error": "로그인이 필요합니다"}, 401
applications = ProjectUser.objects.filter(user=request.user)\
.select_related('project')\
.order_by('-applied_at')
return applications
# 프로젝트 신청자 목록 (기업용)
@router.get("/projects/{int:project_id}/applications", response=List[ProjectUserSchema])
def project_applications(request, project_id: int):
project = get_object_or_404(Project, id=project_id, company=request.user)
applications = ProjectUser.objects.filter(project=project)\
.select_related('user')\
.order_by('-applied_at')
return applications
# 신청자 상태 변경 (기업용)
@router.patch("/applications/{int:application_id}", response=ProjectUserSchema)
def update_application(request, application_id: int, payload: ApplicationUpdateSchema):
application = get_object_or_404(
ProjectUser.objects.select_related('project'),
id=application_id,
project__company=request.user
)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(application, attr, value)
application.save()
return application
# 프로젝트 통계 (기업용)
@router.get("/projects/{int:project_id}/stats")
def project_stats(request, project_id: int):
project = get_object_or_404(Project, id=project_id, company=request.user)
stats = ProjectUser.objects.filter(project=project).aggregate(
total_applications=Count('id'),
pending_count=Count('id', filter=Q(status='pending')),
accepted_count=Count('id', filter=Q(status='accepted')),
rejected_count=Count('id', filter=Q(status='rejected')),
)
return {
"project_id": project_id,
"project_title": project.title,
"view_count": project.view_count,
**stats
}
3. 메인 API 라우터 설정
# experience_platform/api.py
from ninja import NinjaAPI
from projects.api import router as projects_router
api = NinjaAPI(
title="체험단 플랫폼 API",
description="체험단 모집 및 참여자 관리 API",
version="1.0.0",
docs_url="/docs/",
)
# 프로젝트 관련 API
api.add_router("/api/", projects_router, tags=["Projects"])
# 인증 관련 API (추가 구현)
# api.add_router("/api/auth/", auth_router, tags=["Authentication"])
# experience_platform/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from .api import api
urlpatterns = [
path('admin/', admin.site.urls),
path('', api.urls), # API 엔드포인트
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
🔐 인증 시스템 구현
JWT 인증 추가
pip install djangorestframework-simplejwt
# users/authentication.py
from ninja.security import HttpBearer
from django.contrib.auth.models import User
from django.http import HttpRequest
import jwt
from django.conf import settings
class AuthBearer(HttpBearer):
def authenticate(self, request: HttpRequest, token: str):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
user = User.objects.get(id=payload['user_id'])
return user
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist):
return None
auth = AuthBearer()
# users/api.py
from ninja import Router
from ninja.security import django_auth
from django.contrib.auth import authenticate, login
from django.contrib.auth.models import User
import jwt
from datetime import datetime, timedelta
from django.conf import settings
router = Router()
@router.post("/login")
def login_user(request, username: str, password: str):
user = authenticate(username=username, password=password)
if user:
payload = {
'user_id': user.id,
'username': user.username,
'exp': datetime.utcnow() + timedelta(days=7)
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256')
return {
"token": token,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
}
}
return {"error": "Invalid credentials"}, 401
@router.post("/register")
def register_user(request, username: str, email: str, password: str):
if User.objects.filter(username=username).exists():
return {"error": "Username already exists"}, 400
user = User.objects.create_user(
username=username,
email=email,
password=password
)
return {
"message": "User created successfully",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
}
}
🚀 배포 및 운영
1. Docker 설정
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DB_NAME=experience_platform
- DB_USER=postgres
- DB_PASSWORD=password
- DB_HOST=db
- DB_PORT=5432
depends_on:
- db
volumes:
- ./media:/app/media
db:
image: postgres:15
environment:
- POSTGRES_DB=experience_platform
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
2. 환경별 설정
# settings/production.py
from .base import *
import os
DEBUG = False
ALLOWED_HOSTS = [os.getenv('DOMAIN', 'localhost')]
# 데이터베이스
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('DB_NAME'),
'USER': os.getenv('DB_USER'),
'PASSWORD': os.getenv('DB_PASSWORD'),
'HOST': os.getenv('DB_HOST'),
'PORT': os.getenv('DB_PORT', '5432'),
}
}
# 캐시 설정
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': os.getenv('REDIS_URL', 'redis://redis:6379/1'),
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
}
}
# 파일 저장 (AWS S3)
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
3. 모니터링 설정
# monitoring/middleware.py
import time
import logging
from django.utils.deprecation import MiddlewareMixin
logger = logging.getLogger('api_monitoring')
class APIMonitoringMiddleware(MiddlewareMixin):
def process_request(self, request):
request.start_time = time.time()
def process_response(self, request, response):
if hasattr(request, 'start_time'):
duration = time.time() - request.start_time
logger.info(f"{request.method} {request.path} - {response.status_code} - {duration:.3f}s")
return response
📊 성능 최적화
1. 데이터베이스 최적화
# projects/management/commands/optimize_db.py
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = '데이터베이스 최적화'
def handle(self, *args, **options):
# 인덱스 생성
with connection.cursor() as cursor:
cursor.execute("""
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_project_status_recruit_date
ON projects_project(status, recruit_end_date);
""")
cursor.execute("""
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_projectuser_compound
ON projects_projectuser(project_id, status, applied_at);
""")
self.stdout.write(self.style.SUCCESS('데이터베이스 최적화 완료'))
2. 캐싱 전략
# projects/views.py (캐싱 예시)
from django.core.cache import cache
from django.views.decorators.cache import cache_page
import hashlib
def get_projects_cache_key(filters):
"""프로젝트 목록 캐시 키 생성"""
key_data = f"{filters.category}-{filters.status}-{filters.search}-{filters.ordering}"
return f"projects_list_{hashlib.md5(key_data.encode()).hexdigest()}"
@router.get("/projects", response=List[ProjectListSchema])
def list_projects(request, filters: ProjectFilterSchema = Query(...)):
# 캐시 키 생성
cache_key = get_projects_cache_key(filters)
# 캐시에서 조회
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# DB에서 조회
queryset = Project.objects.select_related('company').prefetch_related('applications')
# ... (이전 로직과 동일)
# 캐시에 저장 (5분)
cache.set(cache_key, result, 300)
return result
🧪 테스트 코드
# tests/test_projects.py
from django.test import TestCase
from django.contrib.auth.models import User
from ninja.testing import TestClient
from projects.models import Project, ProjectUser
from projects.api import router
from datetime import datetime, timedelta
class ProjectAPITestCase(TestCase):
def setUp(self):
self.client = TestClient(router)
self.company_user = User.objects.create_user(
username='company',
email='company@test.com',
password='testpass123'
)
self.normal_user = User.objects.create_user(
username='user',
email='user@test.com',
password='testpass123'
)
self.project = Project.objects.create(
title='테스트 체험단',
description='테스트 설명',
company=self.company_user,
recruit_count=10,
recruit_start_date=datetime.now(),
recruit_end_date=datetime.now() + timedelta(days=7),
experience_start_date=datetime.now() + timedelta(days=10),
experience_end_date=datetime.now() + timedelta(days=20),
review_deadline=datetime.now() + timedelta(days=30),
)
def test_list_projects(self):
response = self.client.get('/projects')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
def test_get_project_detail(self):
response = self.client.get(f'/projects/{self.project.id}')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['title'], '테스트 체험단')
def test_apply_project(self):
# 로그인 필요
response = self.client.post(f'/projects/{self.project.id}/apply', {
'application_message': '신청합니다!'
})
self.assertEqual(response.status_code, 401)
# 로그인 후 신청
self.client.force_authenticate(self.normal_user)
response = self.client.post(f'/projects/{self.project.id}/apply', {
'application_message': '신청합니다!'
})
self.assertEqual(response.status_code, 200)
# 중복 신청 방지
response = self.client.post(f'/projects/{self.project.id}/apply', {
'application_message': '또 신청합니다!'
})
self.assertEqual(response.status_code, 400)
🎯 결론
Django Ninja를 사용한 체험단 플랫폼 구축을 통해 다음과 같은 이점을 얻을 수 있습니다:
주요 성과
- 빠른 개발: Django의 강력한 ORM과 Ninja의 직관적인 API 구문
- 자동 문서화: Swagger/OpenAPI 자동 생성으로 API 문서 관리 불필요
- 타입 안정성: Pydantic 스키마를 통한 데이터 검증
- 확장성: 마이크로서비스 아키텍처로 확장 가능
추가 개선 방안
- 알림 시스템: Django Channels를 이용한 실시간 알림
- 결제 연동: 토스페이먼츠, 카카오페이 등 결제 시스템
- 소셜 로그인: OAuth2를 이용한 소셜 미디어 연동
- 관리자 대시보드: 통계 및 분석 기능 강화
이러한 체험단 플랫폼은 브랜드 마케팅과 소비자 경험을 연결하는 중요한 역할을 하며, Django Ninja의 현대적인 API 개발 방식으로 효율적으로 구축할 수 있습니다.