Django-Ninja로 구축하는 모바일 청첩장 서비스 - 데이터 구조 설계
1. 서비스 개요 및 요구사항
1. 서비스 개요 및 요구사항
1.1 모바일 청첩장 서비스란?
모바일 청첩장 서비스는 사용자가 종이 청첩장 없이 웹 기반으로 결혼식 초대장을 제작하고 공유할 수 있는 플랫폼입니다. 최근 COVID-19 이후 비대면 문화가 확산되면서 폭발적으로 성장한 시장으로, 카카오톡, 문자 등으로 손쉽게 공유할 수 있어 MZ세대에게 인기가 높습니다.
주요 서비스 예시:
- 청첩장: 카카오 메이크, 웨딩북, 웨드빌
- 비슷한 서비스: 링크드인(프로필 생성), Linktree(링크 모음)
- 핵심: 템플릿 기반 + 개인화 커스터마이징
1.2 핵심 기능 요구사항
# 기능적 요구사항
FUNCTIONAL_REQUIREMENTS = {
# 1. 사용자 관리
"user_management": [
"회원가입/로그인 (소셜 로그인 포함)",
"사용자별 여러 청첩장 생성 가능",
"프리미엄/무료 요금제 구분"
],
# 2. 템플릿 시스템
"template_system": [
"다양한 디자인 템플릿 제공",
"템플릿 카테고리별 분류 (모던, 클래식, 미니멀 등)",
"무료/유료 템플릿 구분",
"미리보기 기능"
],
# 3. 커스터마이징
"customization": [
"신랑/신부 정보 입력",
"날짜, 시간, 장소 정보",
"사진 업로드 (메인, 갤러리)",
"색상, 폰트 변경",
"배경 음악 설정",
"지도 및 교통 정보",
"방명록 기능",
"축의금 계좌번호"
],
# 4. 배포 및 공유
"deployment": [
"고유 URL 생성 (예: domain.com/wedding/abc123)",
"QR 코드 생성",
"카카오톡, 문자 공유",
"통계 (조회수, 방문자 수)"
],
# 5. 관리
"management": [
"청첩장 수정/삭제",
"참석 여부 관리",
"방명록 관리",
"비공개 설정"
]
}
# 비기능적 요구사항
NON_FUNCTIONAL_REQUIREMENTS = {
"performance": "1초 이내 페이지 로딩",
"scalability": "월 10만 청첩장 처리 가능",
"availability": "99.9% 가동률 (결혼식 당일 중요!)",
"security": "개인정보 보호, HTTPS 필수",
"mobile_first": "모바일 최적화 UI/UX",
"seo": "검색엔진 노출 (오픈그래프 태그)"
}
1.3 데이터 구조 설계의 핵심 과제
모바일 청첩장 서비스의 데이터 구조를 설계할 때 가장 중요한 고려사항:
1. 템플릿과 인스턴스 분리
- 템플릿: 재사용 가능한 디자인/레이아웃 (1개 템플릿 → 수천 명이 사용)
- 인스턴스: 사용자가 만든 실제 청첩장 (개인화된 데이터)
- 문제: 템플릿이 업데이트되면 기존 청첩장은?
2. 유연한 커스터마이징
- 템플릿마다 다른 설정 항목 (어떤 템플릿은 동영상 지원, 어떤 템플릿은 불가)
- 사용자별 다양한 요구사항 (일부는 신랑/신부 정보만, 일부는 갤러리 50장)
- 해결책: JSON Field vs 정규화된 테이블?
3. 멀티테넌시 URL 구조
- 각 청첩장은 고유 URL (예:
/wedding/john-mary-2025) - Slug 충돌 방지 (같은 이름 여러 명)
- SEO 친화적 URL
4. 미디어 파일 관리
- 사진 수십 장 업로드 (용량 제한, CDN 필요)
- 썸네일 자동 생성
- 임베디드 동영상 (YouTube, Vimeo)
5. 실시간 데이터
- 방명록 실시간 업데이트
- 참석 여부 집계
- 조회수 카운팅
이러한 요구사항을 만족하는 최적의 데이터베이스 스키마를 설계해보겠습니다.
2. 핵심 엔티티 설계
2.1 ERD (Entity Relationship Diagram)
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ User │1 * │ Invitation │* 1 │ Template │
│─────────────│─────────│──────────────────│─────────│─────────────────│
│ id │ │ id │ │ id │
│ email │ │ user_id (FK) │ │ name │
│ username │ │ template_id (FK) │ │ category │
│ is_premium │ │ slug (unique) │ │ preview_url │
│ created_at │ │ status │ │ is_premium │
└─────────────┘ │ settings (JSON) │ │ schema (JSON) │
│ published_at │ │ version │
└──────────────────┘ └─────────────────┘
│
│1
│
┌───────┴────────┬──────────────────┬────────────────┐
│* │* │* │*
┌───────┴──────┐ ┌──────┴─────┐ ┌──────────┴─────┐ ┌───────┴────────┐
│ GuestBook │ │ Photo │ │ Attendance │ │ Analytics │
│──────────────│ │────────────│ │────────────────│ │────────────────│
│ id │ │ id │ │ id │ │ id │
│ invitation_id│ │ invitation │ │ invitation_id │ │ invitation_id │
│ author_name │ │ image_url │ │ guest_name │ │ visit_date │
│ message │ │ order │ │ will_attend │ │ user_agent │
│ created_at │ │ is_main │ │ guest_count │ │ referrer │
└──────────────┘ └────────────┘ └────────────────┘ └────────────────┘
2.2 User 모델
# users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
"""
사용자 모델
Django 기본 AbstractUser 확장
"""
# 추가 필드
phone_number = models.CharField(
max_length=15,
blank=True,
help_text="전화번호 (알림용)"
)
# 소셜 로그인
social_provider = models.CharField(
max_length=20,
blank=True,
choices=[
('kakao', '카카오'),
('naver', '네이버'),
('google', '구글'),
]
)
social_id = models.CharField(max_length=100, blank=True, unique=True)
# 구독 정보
is_premium = models.BooleanField(
default=False,
help_text="프리미엄 회원 여부"
)
premium_until = models.DateTimeField(
null=True,
blank=True,
help_text="프리미엄 만료일"
)
# 통계
invitation_count = models.IntegerField(
default=0,
help_text="생성한 청첩장 수"
)
# 메타데이터
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'users'
indexes = [
models.Index(fields=['email']),
models.Index(fields=['social_provider', 'social_id']),
]
def __str__(self):
return self.email
@property
def is_premium_active(self):
"""현재 프리미엄 활성 상태"""
from django.utils import timezone
return self.is_premium and (
self.premium_until is None or
self.premium_until > timezone.now()
)
2.3 Template 모델
# invitations/models.py
from django.db import models
from django.core.validators import MinValueValidator
class Template(models.Model):
"""
청첩장 템플릿
재사용 가능한 디자인/레이아웃 정의
"""
# 기본 정보
name = models.CharField(
max_length=100,
help_text="템플릿 이름 (예: 모던 화이트)"
)
description = models.TextField(
help_text="템플릿 설명"
)
category = models.CharField(
max_length=50,
choices=[
('modern', '모던'),
('classic', '클래식'),
('minimal', '미니멀'),
('vintage', '빈티지'),
('floral', '플로럴'),
('elegant', '우아함'),
],
default='modern'
)
# 미리보기
thumbnail_url = models.URLField(
help_text="썸네일 이미지 URL"
)
preview_url = models.URLField(
help_text="미리보기 페이지 URL"
)
# 템플릿 파일
# 옵션 1: HTML 템플릿 경로
template_path = models.CharField(
max_length=255,
help_text="Django 템플릿 파일 경로 (예: templates/invitations/modern_white.html)"
)
# 옵션 2: React 컴포넌트 이름
component_name = models.CharField(
max_length=100,
blank=True,
help_text="React 컴포넌트 이름 (SPA인 경우)"
)
# 가격
is_free = models.BooleanField(
default=True,
help_text="무료 템플릿 여부"
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
validators=[MinValueValidator(0)],
help_text="유료 템플릿 가격"
)
# 스키마 (템플릿이 지원하는 설정 항목)
schema = models.JSONField(
default=dict,
help_text="""
템플릿 설정 스키마 (JSON Schema 형식)
예: {
"fields": {
"bride_name": {"type": "string", "required": true},
"groom_name": {"type": "string", "required": true},
"wedding_date": {"type": "datetime", "required": true},
"venue": {"type": "object", "required": true},
"photos": {"type": "array", "max_items": 10},
"background_music": {"type": "string", "required": false},
"theme_color": {
"type": "string",
"default": "#FFB6C1",
"options": ["#FFB6C1", "#E6E6FA", "#FFF8DC"]
}
},
"features": {
"gallery": true,
"guestbook": true,
"attendance": true,
"video": false,
"map": true
}
}
"""
)
# 버전 관리
version = models.CharField(
max_length=20,
default='1.0.0',
help_text="템플릿 버전 (시맨틱 버저닝)"
)
# 통계
usage_count = models.IntegerField(
default=0,
help_text="사용 횟수"
)
# 상태
is_active = models.BooleanField(
default=True,
help_text="활성화 여부 (false면 목록에서 숨김)"
)
# 정렬
display_order = models.IntegerField(
default=0,
help_text="표시 순서 (낮을수록 먼저)"
)
# 메타데이터
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'templates'
ordering = ['display_order', '-created_at']
indexes = [
models.Index(fields=['category', 'is_active']),
models.Index(fields=['is_free', 'is_active']),
]
def __str__(self):
return f"{self.name} (v{self.version})"
def increment_usage(self):
"""사용 횟수 증가 (원자적)"""
self.__class__.objects.filter(id=self.id).update(
usage_count=models.F('usage_count') + 1
)
템플릿 스키마 예시:
{
"fields": {
"bride": {
"type": "object",
"required": true,
"properties": {
"name": {"type": "string", "max_length": 50},
"father_name": {"type": "string", "required": false},
"mother_name": {"type": "string", "required": false},
"phone": {"type": "string", "required": false}
}
},
"groom": {
"type": "object",
"required": true,
"properties": {
"name": {"type": "string", "max_length": 50},
"father_name": {"type": "string", "required": false},
"mother_name": {"type": "string", "required": false},
"phone": {"type": "string", "required": false}
}
},
"wedding_date": {
"type": "datetime",
"required": true
},
"venue": {
"type": "object",
"required": true,
"properties": {
"name": {"type": "string"},
"address": {"type": "string"},
"floor": {"type": "string"},
"hall_name": {"type": "string"},
"latitude": {"type": "number"},
"longitude": {"type": "number"}
}
},
"theme_color": {
"type": "string",
"default": "#FFB6C1",
"enum": ["#FFB6C1", "#E6E6FA", "#FFF8DC", "#F0E68C"]
},
"font_family": {
"type": "string",
"default": "Noto Sans KR",
"enum": ["Noto Sans KR", "Nanum Gothic", "Nanum Myeongjo"]
},
"background_music": {
"type": "string",
"required": false,
"description": "YouTube URL 또는 파일 URL"
}
},
"features": {
"gallery": {
"enabled": true,
"max_photos": 20
},
"guestbook": {
"enabled": true,
"requires_password": false
},
"attendance": {
"enabled": true
},
"video": {
"enabled": false
},
"map": {
"enabled": true,
"provider": "kakao"
},
"account_transfer": {
"enabled": true,
"supports_kakaopay": true
}
},
"layout": {
"sections": [
"header",
"intro",
"couple_info",
"gallery",
"map",
"guestbook",
"account"
]
}
}
이 스키마를 통해:
- 템플릿마다 다른 설정 항목 지원
- 프론트엔드에서 동적 폼 생성
- 유효성 검사 (Pydantic으로 변환 가능)
3. Invitation 모델 - 핵심 엔티티
3.1 Invitation 모델 설계
# invitations/models.py
from django.db import models
from django.conf import settings
from django.utils.text import slugify
from django.core.validators import RegexValidator
import uuid
class Invitation(models.Model):
"""
청첩장 인스턴스
사용자가 템플릿을 기반으로 생성한 실제 청첩장
"""
# 관계
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='invitations',
help_text="청첩장 소유자"
)
template = models.ForeignKey(
'Template',
on_delete=models.PROTECT, # 템플릿 삭제 방지
related_name='invitations',
help_text="사용된 템플릿"
)
# 고유 식별자
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False,
help_text="UUID (보안상 순차 ID 대신 사용)"
)
# URL Slug
slug = models.SlugField(
max_length=100,
unique=True,
validators=[
RegexValidator(
regex=r'^[a-z0-9-]+$',
message='소문자, 숫자, 하이픈만 사용 가능'
)
],
help_text="URL 경로 (예: john-mary-2025)"
)
# 기본 정보
title = models.CharField(
max_length=200,
help_text="청첩장 제목 (예: John & Mary의 결혼식에 초대합니다)"
)
# 상태
class Status(models.TextChoices):
DRAFT = 'draft', '임시저장'
PUBLISHED = 'published', '공개'
PRIVATE = 'private', '비공개'
ARCHIVED = 'archived', '보관됨'
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.DRAFT,
help_text="공개 상태"
)
# 설정 데이터 (JSON)
settings = models.JSONField(
default=dict,
help_text="""
템플릿 스키마에 따른 사용자 설정값
예: {
"bride": {"name": "김지영", "father_name": "김철수"},
"groom": {"name": "이민수", "mother_name": "박영희"},
"wedding_date": "2025-12-25T14:00:00+09:00",
"venue": {
"name": "서울 웨딩홀",
"address": "서울시 강남구...",
"latitude": 37.123456,
"longitude": 127.123456
},
"theme_color": "#FFB6C1",
"font_family": "Noto Sans KR"
}
"""
)
# 보안
password = models.CharField(
max_length=128,
blank=True,
help_text="비공개 청첩장 비밀번호 (해싱된 값)"
)
is_password_protected = models.BooleanField(
default=False,
help_text="비밀번호 보호 여부"
)
# 통계
view_count = models.IntegerField(
default=0,
help_text="조회수"
)
unique_visitor_count = models.IntegerField(
default=0,
help_text="순 방문자 수"
)
share_count = models.IntegerField(
default=0,
help_text="공유 횟수"
)
# 날짜
wedding_date = models.DateTimeField(
null=True,
blank=True,
help_text="결혼식 날짜 (빠른 조회용)"
)
published_at = models.DateTimeField(
null=True,
blank=True,
help_text="공개 일시"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'invitations'
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', 'status']),
models.Index(fields=['slug']),
models.Index(fields=['status', 'published_at']),
models.Index(fields=['wedding_date']),
]
def __str__(self):
return f"{self.title} ({self.slug})"
def save(self, *args, **kwargs):
# Slug 자동 생성
if not self.slug:
self.slug = self.generate_unique_slug()
# wedding_date 자동 추출
if self.settings.get('wedding_date'):
from dateutil import parser
self.wedding_date = parser.parse(self.settings['wedding_date'])
super().save(*args, **kwargs)
def generate_unique_slug(self):
"""고유한 Slug 생성"""
base_slug = slugify(self.title[:50])
# 영문이 없으면 UUID 일부 사용
if not base_slug:
base_slug = str(uuid.uuid4())[:8]
slug = base_slug
counter = 1
# 중복 확인
while Invitation.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
return slug
@property
def url(self):
"""공개 URL"""
from django.urls import reverse
return reverse('invitation_detail', kwargs={'slug': self.slug})
@property
def full_url(self):
"""전체 URL (도메인 포함)"""
from django.conf import settings
return f"{settings.SITE_URL}{self.url}"
def increment_view(self, is_unique=False):
"""조회수 증가"""
update_fields = {'view_count': models.F('view_count') + 1}
if is_unique:
update_fields['unique_visitor_count'] = models.F('unique_visitor_count') + 1
self.__class__.objects.filter(id=self.id).update(**update_fields)
def get_bride_name(self):
"""신부 이름 추출"""
return self.settings.get('bride', {}).get('name', '')
def get_groom_name(self):
"""신랑 이름 추출"""
return self.settings.get('groom', {}).get('name', '')
3.2 JSON Settings vs 정규화 테이블 비교
왜 settings를 JSONField로?
# ❌ 안티패턴: 모든 필드를 테이블 컬럼으로
class BadInvitation(models.Model):
bride_name = models.CharField(max_length=50)
bride_father = models.CharField(max_length=50, blank=True)
bride_mother = models.CharField(max_length=50, blank=True)
groom_name = models.CharField(max_length=50)
groom_father = models.CharField(max_length=50, blank=True)
venue_name = models.CharField(max_length=100)
venue_address = models.TextField()
theme_color = models.CharField(max_length=7)
font_family = models.CharField(max_length=50)
# 템플릿마다 필드가 다르면? → 테이블 변경 필요!
# 새로운 설정 추가 시 마이그레이션 필수!
# ✅ 올바른 패턴: JSON으로 유연하게
class Invitation(models.Model):
settings = models.JSONField(default=dict)
# 템플릿 스키마에 따라 자유롭게 확장 가능
# 마이그레이션 없이 새 필드 추가 가능
JSONField 장점:
- 유연성: 템플릿마다 다른 필드 구조 지원
- 확장성: 새 설정 추가 시 DB 변경 불필요
- 간결성: 수십 개 컬럼 대신 하나의 JSON
JSONField 단점 및 해결책:
- 쿼리 복잡도: JSON 내부 검색 어려움
- 해결: 자주 검색하는 필드는 별도 컬럼 (
wedding_date,title)
- 해결: 자주 검색하는 필드는 별도 컬럼 (
- 타입 안정성: 런타임 에러 가능
- 해결: Pydantic으로 검증 (아래 예시)
- 인덱싱 제한: JSON 내부 필드 인덱싱 어려움
- 해결: PostgreSQL의 GIN 인덱스 활용
# PostgreSQL JSON 인덱싱 (마이그레이션)
from django.contrib.postgres.operations import AddIndexConcurrently
from django.contrib.postgres.indexes import GinIndex
class Migration(migrations.Migration):
operations = [
# JSON 필드 전체 인덱싱
AddIndexConcurrently(
model_name='invitation',
index=GinIndex(fields=['settings'], name='idx_settings_gin'),
),
]
# JSON 쿼리 예시 (PostgreSQL)
from django.contrib.postgres.fields.jsonb import KeyTextTransform
# 신부 이름으로 검색
invitations = Invitation.objects.annotate(
bride_name=KeyTextTransform('name', KeyTextTransform('bride', 'settings'))
).filter(bride_name='김지영')
3.3 Pydantic으로 Settings 검증
# invitations/schemas.py
from pydantic import BaseModel, Field, validator
from typing import Optional, List
from datetime import datetime
class PersonInfo(BaseModel):
"""인물 정보"""
name: str = Field(..., max_length=50)
father_name: Optional[str] = Field(None, max_length=50)
mother_name: Optional[str] = Field(None, max_length=50)
phone: Optional[str] = Field(None, regex=r'^\d{3}-\d{4}-\d{4}$')
relation: Optional[str] = None # "장남", "차녀" 등
class VenueInfo(BaseModel):
"""장소 정보"""
name: str = Field(..., max_length=100)
address: str
floor: Optional[str] = None
hall_name: Optional[str] = None
latitude: float = Field(..., ge=-90, le=90)
longitude: float = Field(..., ge=-180, le=180)
phone: Optional[str] = None
@validator('latitude', 'longitude')
def validate_coords(cls, v):
if v == 0:
raise ValueError('Invalid coordinates')
return v
class AccountInfo(BaseModel):
"""계좌 정보"""
bank_name: str
account_number: str
account_holder: str
relation: str # "신랑", "신부", "신랑 아버지" 등
class InvitationSettings(BaseModel):
"""청첩장 설정 (템플릿 스키마 기반)"""
# 필수 정보
bride: PersonInfo
groom: PersonInfo
wedding_date: datetime
venue: VenueInfo
# 선택 정보
message: Optional[str] = Field(None, max_length=1000)
theme_color: str = Field(default='#FFB6C1', regex=r'^#[0-9A-Fa-f]{6}$')
font_family: str = Field(default='Noto Sans KR')
background_music: Optional[str] = None
# 계좌 정보 (여러 개 가능)
accounts: List[AccountInfo] = Field(default_factory=list, max_items=10)
# 기능 활성화
enable_guestbook: bool = True
enable_attendance: bool = True
enable_gallery: bool = True
# 커스텀 섹션
custom_sections: Optional[dict] = None
@validator('wedding_date')
def validate_wedding_date(cls, v):
if v < datetime.now():
raise ValueError('Wedding date must be in the future')
return v
class Config:
json_schema_extra = {
"example": {
"bride": {
"name": "김지영",
"father_name": "김철수",
"mother_name": "이영희"
},
"groom": {
"name": "박민수",
"father_name": "박동진",
"mother_name": "최수진"
},
"wedding_date": "2025-12-25T14:00:00+09:00",
"venue": {
"name": "서울 웨딩홀",
"address": "서울시 강남구 테헤란로 123",
"floor": "3층",
"hall_name": "그랜드홀",
"latitude": 37.5012345,
"longitude": 127.0398765,
"phone": "02-1234-5678"
},
"theme_color": "#FFB6C1",
"accounts": [
{
"bank_name": "국민은행",
"account_number": "123-456-789012",
"account_holder": "박민수",
"relation": "신랑"
}
]
}
}
# Django-Ninja API에서 사용
from ninja import Router, Schema
from typing import Optional
router = Router(tags=["Invitations"])
class CreateInvitationIn(Schema):
template_id: int
title: str
slug: Optional[str] = None
settings: dict # InvitationSettings로 검증됨
@router.post("/")
def create_invitation(request, data: CreateInvitationIn):
# Pydantic으로 검증
try:
validated_settings = InvitationSettings(**data.settings)
except Exception as e:
return 400, {"error": str(e)}
# Invitation 생성
invitation = Invitation.objects.create(
user=request.user,
template_id=data.template_id,
title=data.title,
slug=data.slug,
settings=validated_settings.dict() # dict로 변환하여 저장
)
return {"id": str(invitation.id), "slug": invitation.slug}
4. 관련 엔티티 설계
4.1 Photo 모델 (갤러리)
# invitations/models.py
from django.db import models
from django.core.validators import FileExtensionValidator
import os
def invitation_photo_path(instance, filename):
"""사진 업로드 경로"""
ext = os.path.splitext(filename)[1]
return f'invitations/{instance.invitation.id}/photos/{instance.id}{ext}'
class Photo(models.Model):
"""
청첩장 사진
메인 사진, 갤러리 사진 관리
"""
invitation = models.ForeignKey(
'Invitation',
on_delete=models.CASCADE,
related_name='photos'
)
# 이미지
image = models.ImageField(
upload_to=invitation_photo_path,
validators=[
FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'webp'])
],
help_text="원본 이미지"
)
# 썸네일 (자동 생성)
thumbnail = models.ImageField(
upload_to=invitation_photo_path,
blank=True,
null=True,
help_text="썸네일 (자동 생성)"
)
# 메타데이터
width = models.IntegerField(default=0)
height = models.IntegerField(default=0)
file_size = models.IntegerField(default=0, help_text="바이트 단위")
# 순서 및 타입
display_order = models.IntegerField(
default=0,
help_text="표시 순서 (낮을수록 먼저)"
)
is_main = models.BooleanField(
default=False,
help_text="메인 사진 여부 (OG 이미지로 사용)"
)
# 캡션
caption = models.CharField(
max_length=200,
blank=True,
help_text="사진 설명"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'photos'
ordering = ['display_order', 'created_at']
indexes = [
models.Index(fields=['invitation', 'display_order']),
]
def __str__(self):
return f"Photo for {self.invitation.slug}"
def save(self, *args, **kwargs):
# 이미지 메타데이터 추출
if self.image:
from PIL import Image
img = Image.open(self.image)
self.width, self.height = img.size
self.file_size = self.image.size
# 썸네일 생성
if not self.thumbnail:
self.create_thumbnail(img)
super().save(*args, **kwargs)
def create_thumbnail(self, img):
"""썸네일 생성"""
from PIL import Image
from io import BytesIO
from django.core.files.base import ContentFile
# 비율 유지하며 리사이즈
img.thumbnail((400, 400), Image.Resampling.LANCZOS)
# 메모리에 저장
thumb_io = BytesIO()
img.save(thumb_io, format='JPEG', quality=85)
# 파일로 저장
thumb_file = ContentFile(thumb_io.getvalue())
self.thumbnail.save(
f'thumb_{self.id}.jpg',
thumb_file,
save=False
)
4.2 GuestBook 모델 (방명록)
class GuestBook(models.Model):
"""
방명록
축하 메시지 및 덕담
"""
invitation = models.ForeignKey(
'Invitation',
on_delete=models.CASCADE,
related_name='guestbook_entries'
)
# 작성자 정보
author_name = models.CharField(
max_length=50,
help_text="작성자 이름"
)
password = models.CharField(
max_length=128,
help_text="수정/삭제 비밀번호 (해싱)"
)
# 메시지
message = models.TextField(
max_length=500,
help_text="축하 메시지"
)
# 관계
relation = models.CharField(
max_length=50,
blank=True,
choices=[
('friend', '친구'),
('colleague', '직장동료'),
('family', '가족'),
('relative', '친척'),
('etc', '기타'),
],
help_text="신랑/신부와의 관계"
)
# 공개 여부
is_public = models.BooleanField(
default=True,
help_text="공개 여부 (비공개 시 주인만 볼 수 있음)"
)
# IP 및 User Agent (스팸 방지)
ip_address = models.GenericIPAddressField(
null=True,
blank=True
)
user_agent = models.TextField(
blank=True
)
# 메타데이터
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'guestbook'
ordering = ['-created_at']
indexes = [
models.Index(fields=['invitation', '-created_at']),
models.Index(fields=['invitation', 'is_public', '-created_at']),
]
def __str__(self):
return f"{self.author_name}: {self.message[:50]}"
### 4.3 Attendance 모델 (참석 여부)
```python
class Attendance(models.Model):
"""
참석 여부 관리
식사 준비, 좌석 배치 등에 활용
"""
invitation = models.ForeignKey(
'Invitation',
on_delete=models.CASCADE,
related_name='attendances'
)
# 참석자 정보
guest_name = models.CharField(
max_length=50,
help_text="참석자 이름"
)
phone_number = models.CharField(
max_length=15,
blank=True,
help_text="연락처 (선택)"
)
# 참석 여부
will_attend = models.BooleanField(
help_text="참석 여부"
)
guest_count = models.IntegerField(
default=1,
validators=[MinValueValidator(0)],
help_text="동반 인원 (본인 포함)"
)
# 식사 정보
meal_type = models.CharField(
max_length=20,
blank=True,
choices=[
('normal', '일반식'),
('vegetarian', '채식'),
('halal', '할랄'),
('kids', '어린이식'),
],
help_text="식사 종류"
)
# 메시지
message = models.TextField(
max_length=200,
blank=True,
help_text="전할 말"
)
# 메타데이터
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'attendances'
ordering = ['-created_at']
indexes = [
models.Index(fields=['invitation', 'will_attend']),
]
# 동일인 중복 제출 방지
unique_together = [['invitation', 'guest_name', 'phone_number']]
def __str__(self):
status = "참석" if self.will_attend else "불참"
return f"{self.guest_name} - {status}"
4.4 Analytics 모델 (방문 통계)
class Analytics(models.Model):
"""
방문 통계
조회수, 유입 경로 등 추적
"""
invitation = models.ForeignKey(
'Invitation',
on_delete=models.CASCADE,
related_name='analytics'
)
# 방문 정보
visitor_id = models.CharField(
max_length=100,
help_text="방문자 식별자 (쿠키 또는 fingerprint)"
)
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(
help_text="User-Agent 문자열"
)
# 디바이스 정보 (파싱)
device_type = models.CharField(
max_length=20,
choices=[
('mobile', '모바일'),
('tablet', '태블릿'),
('desktop', '데스크톱'),
],
blank=True
)
os = models.CharField(max_length=50, blank=True)
browser = models.CharField(max_length=50, blank=True)
# 유입 정보
referrer = models.URLField(
blank=True,
help_text="유입 URL"
)
utm_source = models.CharField(max_length=100, blank=True)
utm_medium = models.CharField(max_length=100, blank=True)
utm_campaign = models.CharField(max_length=100, blank=True)
# 페이지 정보
page_url = models.CharField(max_length=500)
# 세션 정보
session_duration = models.IntegerField(
default=0,
help_text="세션 시간 (초)"
)
# 날짜
visit_date = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'analytics'
ordering = ['-visit_date']
indexes = [
models.Index(fields=['invitation', '-visit_date']),
models.Index(fields=['visitor_id']),
]
def __str__(self):
return f"{self.invitation.slug} - {self.visit_date}"
5. Django-Ninja API 구현
5.1 스키마 정의
# invitations/schemas.py
from ninja import Schema, Field
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
# ===== 템플릿 스키마 =====
class TemplateOut(Schema):
"""템플릿 응답"""
id: int
name: str
description: str
category: str
thumbnail_url: str
preview_url: str
is_free: bool
price: Decimal
usage_count: int
@staticmethod
def resolve_price(obj):
return str(obj.price) # Decimal을 문자열로
class TemplateListOut(Schema):
"""템플릿 목록"""
templates: List[TemplateOut]
total: int
category: Optional[str] = None
# ===== 청첩장 스키마 =====
class InvitationCreateIn(Schema):
"""청첩장 생성 요청"""
template_id: int = Field(..., description="템플릿 ID")
title: str = Field(..., min_length=1, max_length=200)
slug: Optional[str] = Field(None, regex=r'^[a-z0-9-]+$')
settings: dict = Field(default_factory=dict)
class InvitationUpdateIn(Schema):
"""청첩장 수정 요청"""
title: Optional[str] = None
settings: Optional[dict] = None
status: Optional[str] = None
class InvitationOut(Schema):
"""청첩장 응답 (간략)"""
id: str
slug: str
title: str
status: str
template: TemplateOut
view_count: int
created_at: datetime
updated_at: datetime
url: str
@staticmethod
def resolve_id(obj):
return str(obj.id)
@staticmethod
def resolve_url(obj):
return obj.url
class InvitationDetailOut(InvitationOut):
"""청첩장 상세 응답"""
settings: dict
wedding_date: Optional[datetime]
unique_visitor_count: int
share_count: int
# ===== 사진 스키마 =====
class PhotoOut(Schema):
"""사진 응답"""
id: int
image_url: str
thumbnail_url: Optional[str]
caption: str
display_order: int
is_main: bool
@staticmethod
def resolve_image_url(obj):
return obj.image.url if obj.image else None
@staticmethod
def resolve_thumbnail_url(obj):
return obj.thumbnail.url if obj.thumbnail else None
# ===== 방명록 스키마 =====
class GuestBookCreateIn(Schema):
"""방명록 작성"""
author_name: str = Field(..., min_length=1, max_length=50)
password: str = Field(..., min_length=4, max_length=20)
message: str = Field(..., min_length=1, max_length=500)
relation: Optional[str] = None
is_public: bool = True
class GuestBookOut(Schema):
"""방명록 응답"""
id: int
author_name: str
message: str
relation: str
is_public: bool
created_at: datetime
# 비밀번호는 절대 노출하지 않음!
# ===== 참석 스키마 =====
class AttendanceCreateIn(Schema):
"""참석 여부 제출"""
guest_name: str = Field(..., min_length=1, max_length=50)
phone_number: Optional[str] = Field(None, regex=r'^\d{3}-\d{4}-\d{4}$')
will_attend: bool
guest_count: int = Field(default=1, ge=0, le=10)
meal_type: Optional[str] = None
message: Optional[str] = Field(None, max_length=200)
class AttendanceOut(Schema):
"""참석 응답"""
id: int
guest_name: str
will_attend: bool
guest_count: int
created_at: datetime
class AttendanceSummaryOut(Schema):
"""참석 통계"""
total_responses: int
attending_count: int
not_attending_count: int
total_guests: int # 동반 인원 포함
5.2 API 엔드포인트
# invitations/api.py
from ninja import Router
from ninja.pagination import paginate
from django.shortcuts import get_object_or_404
from django.db import transaction
from typing import List
from .models import Template, Invitation, Photo, GuestBook, Attendance
from .schemas import *
router = Router(tags=["Invitations"])
# ===== 템플릿 API =====
@router.get("/templates", response=TemplateListOut)
def list_templates(
request,
category: Optional[str] = None,
is_free: Optional[bool] = None
):
"""
템플릿 목록 조회
Query Parameters:
- category: 카테고리 필터 (modern, classic, minimal 등)
- is_free: 무료/유료 필터
"""
templates = Template.objects.filter(is_active=True)
if category:
templates = templates.filter(category=category)
if is_free is not None:
templates = templates.filter(is_free=is_free)
return {
"templates": list(templates),
"total": templates.count(),
"category": category
}
@router.get("/templates/{template_id}", response=TemplateOut)
def get_template(request, template_id: int):
"""템플릿 상세 조회"""
template = get_object_or_404(Template, id=template_id, is_active=True)
return template
# ===== 청첩장 CRUD =====
@router.post("/", response=InvitationOut)
@transaction.atomic
def create_invitation(request, data: InvitationCreateIn):
"""
청첩장 생성
1. 템플릿 선택
2. 기본 정보 입력
3. 임시저장 상태로 생성
"""
# 템플릿 확인
template = get_object_or_404(Template, id=data.template_id, is_active=True)
# 유료 템플릿인 경우 권한 확인
if not template.is_free and not request.user.is_premium_active:
return 403, {"error": "Premium template requires premium subscription"}
# Pydantic으로 settings 검증 (선택적)
if data.settings:
try:
from .schemas import InvitationSettings
InvitationSettings(**data.settings)
except Exception as e:
return 400, {"error": f"Invalid settings: {str(e)}"}
# 청첩장 생성
invitation = Invitation.objects.create(
user=request.user,
template=template,
title=data.title,
slug=data.slug,
settings=data.settings,
status=Invitation.Status.DRAFT
)
# 템플릿 사용 횟수 증가
template.increment_usage()
# 사용자 청첩장 수 증가
request.user.invitation_count += 1
request.user.save(update_fields=['invitation_count'])
return invitation
@router.get("/", response=List[InvitationOut])
def list_my_invitations(request):
"""내 청첩장 목록"""
invitations = Invitation.objects.filter(
user=request.user
).select_related('template')
return list(invitations)
@router.get("/{slug}", response=InvitationDetailOut)
def get_invitation(request, slug: str):
"""
청첩장 상세 조회
공개된 청첩장은 누구나 볼 수 있음
"""
invitation = get_object_or_404(Invitation, slug=slug)
# 비공개 청첩장 권한 확인
if invitation.status == Invitation.Status.PRIVATE:
if not request.user.is_authenticated or invitation.user != request.user:
return 403, {"error": "Private invitation"}
# 조회수 증가 (비동기 권장)
invitation.increment_view()
return invitation
@router.patch("/{slug}", response=InvitationOut)
@transaction.atomic
def update_invitation(request, slug: str, data: InvitationUpdateIn):
"""청첩장 수정"""
invitation = get_object_or_404(Invitation, slug=slug, user=request.user)
# 업데이트
update_data = data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(invitation, field, value)
invitation.save()
return invitation
@router.post("/{slug}/publish", response=InvitationOut)
def publish_invitation(request, slug: str):
"""청첩장 공개"""
invitation = get_object_or_404(Invitation, slug=slug, user=request.user)
# 필수 정보 검증
required_fields = ['bride', 'groom', 'wedding_date', 'venue']
missing = [f for f in required_fields if f not in invitation.settings]
if missing:
return 400, {"error": f"Missing required fields: {', '.join(missing)}"}
# 공개 처리
from django.utils import timezone
invitation.status = Invitation.Status.PUBLISHED
invitation.published_at = timezone.now()
invitation.save(update_fields=['status', 'published_at'])
return invitation
@router.delete("/{slug}")
def delete_invitation(request, slug: str):
"""청첩장 삭제"""
invitation = get_object_or_404(Invitation, slug=slug, user=request.user)
# 소프트 삭제 (보관)
invitation.status = Invitation.Status.ARCHIVED
invitation.save(update_fields=['status'])
return {"success": True}
# ===== 사진 API =====
@router.get("/{slug}/photos", response=List[PhotoOut])
def list_photos(request, slug: str):
"""사진 목록"""
invitation = get_object_or_404(Invitation, slug=slug)
photos = invitation.photos.all()
return list(photos)
@router.post("/{slug}/photos")
@transaction.atomic
def upload_photo(request, slug: str):
"""
사진 업로드
multipart/form-data로 이미지 전송
"""
invitation = get_object_or_404(Invitation, slug=slug, user=request.user)
# 템플릿의 최대 사진 수 확인
template_schema = invitation.template.schema
max_photos = template_schema.get('features', {}).get('gallery', {}).get('max_photos', 20)
current_count = invitation.photos.count()
if current_count >= max_photos:
return 400, {"error": f"Maximum {max_photos} photos allowed"}
# 파일 업로드
uploaded_file = request.FILES.get('image')
if not uploaded_file:
return 400, {"error": "No image file"}
# 파일 크기 확인 (5MB 제한)
if uploaded_file.size > 5 * 1024 * 1024:
return 400, {"error": "File size must be less than 5MB"}
# Photo 생성
photo = Photo.objects.create(
invitation=invitation,
image=uploaded_file,
display_order=current_count,
caption=request.POST.get('caption', '')
)
return {"id": photo.id, "image_url": photo.image.url}
@router.delete("/{slug}/photos/{photo_id}")
def delete_photo(request, slug: str, photo_id: int):
"""사진 삭제"""
invitation = get_object_or_404(Invitation, slug=slug, user=request.user)
photo = get_object_or_404(Photo, id=photo_id, invitation=invitation)
photo.delete()
return {"success": True}
# ===== 방명록 API =====
@router.get("/{slug}/guestbook", response=List[GuestBookOut])
@paginate
def list_guestbook(request, slug: str):
"""방명록 목록"""
invitation = get_object_or_404(Invitation, slug=slug)
# 공개된 항목만 (소유자는 모두 볼 수 있음)
entries = invitation.guestbook_entries.all()
if not request.user.is_authenticated or invitation.user != request.user:
entries = entries.filter(is_public=True)
return list(entries)
@router.post("/{slug}/guestbook", response=GuestBookOut)
def create_guestbook(request, slug: str, data: GuestBookCreateIn):
"""방명록 작성"""
invitation = get_object_or_404(Invitation, slug=slug)
# 공개 상태 확인
if invitation.status != Invitation.Status.PUBLISHED:
return 403, {"error": "Invitation is not published"}
# 비밀번호 해싱
from django.contrib.auth.hashers import make_password
entry = GuestBook.objects.create(
invitation=invitation,
author_name=data.author_name,
password=make_password(data.password),
message=data.message,
relation=data.relation or 'etc',
is_public=data.is_public,
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
return entry
@router.delete("/{slug}/guestbook/{entry_id}")
def delete_guestbook(request, slug: str, entry_id: int, password: str):
"""방명록 삭제"""
invitation = get_object_or_404(Invitation, slug=slug)
entry = get_object_or_404(GuestBook, id=entry_id, invitation=invitation)
# 비밀번호 확인
from django.contrib.auth.hashers import check_password
if not check_password(password, entry.password):
return 403, {"error": "Invalid password"}
entry.delete()
return {"success": True}
# ===== 참석 API =====
@router.post("/{slug}/attendance", response=AttendanceOut)
def submit_attendance(request, slug: str, data: AttendanceCreateIn):
"""참석 여부 제출"""
invitation = get_object_or_404(Invitation, slug=slug)
# 중복 체크
existing = Attendance.objects.filter(
invitation=invitation,
guest_name=data.guest_name,
phone_number=data.phone_number or ''
).first()
if existing:
# 업데이트
for field, value in data.dict().items():
setattr(existing, field, value)
existing.save()
return existing
# 새로 생성
attendance = Attendance.objects.create(
invitation=invitation,
**data.dict()
)
return attendance
@router.get("/{slug}/attendance/summary", response=AttendanceSummaryOut)
def get_attendance_summary(request, slug: str):
"""참석 통계 (소유자만)"""
invitation = get_object_or_404(Invitation, slug=slug, user=request.user)
from django.db.models import Sum, Count
stats = invitation.attendances.aggregate(
total_responses=Count('id'),
attending_count=Count('id', filter=models.Q(will_attend=True)),
not_attending_count=Count('id', filter=models.Q(will_attend=False)),
total_guests=Sum('guest_count', filter=models.Q(will_attend=True))
)
return stats
5.3 인증 및 권한
# invitations/auth.py
from ninja.security import HttpBearer
from django.contrib.auth import get_user_model
User = get_user_model()
class AuthBearer(HttpBearer):
"""JWT 인증"""
def authenticate(self, request, token):
try:
# JWT 디코딩
import jwt
from django.conf import settings
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=['HS256']
)
user_id = payload.get('user_id')
user = User.objects.get(id=user_id)
return user
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist):
return None
# API에 인증 적용
from ninja import NinjaAPI
api = NinjaAPI(auth=AuthBearer())
# 인증 필요한 엔드포인트
@api.post("/invitations/")
def create_invitation(request, data: InvitationCreateIn):
# request.auth에 User 객체
user = request.auth
# ...
# 인증 선택적 엔드포인트
from ninja.security import HttpBearer
from typing import Optional
class OptionalAuth(HttpBearer):
def authenticate(self, request, token):
# 토큰이 없어도 None 반환 (에러 X)
if not token:
return None
# ... JWT 검증
return user
@api.get("/invitations/{slug}", auth=OptionalAuth())
def get_invitation(request, slug: str):
user = request.auth # None 또는 User
# ...
6. 고급 기능 구현
6.1 실시간 조회수 추적
# invitations/middleware.py
from django.utils.deprecation import MiddlewareMixin
from django.core.cache import cache
import hashlib
class VisitorTrackingMiddleware(MiddlewareMixin):
"""방문자 추적 미들웨어"""
def process_request(self, request):
if request.path.startswith('/wedding/'):
slug = request.path.split('/')[-1]
# 방문자 ID 생성 (IP + User-Agent)
visitor_data = f"{request.META.get('REMOTE_ADDR')}{request.META.get('HTTP_USER_AGENT', '')}"
visitor_id = hashlib.md5(visitor_data.encode()).hexdigest()
# 중복 방문 체크 (24시간)
cache_key = f"visitor:{slug}:{visitor_id}"
if not cache.get(cache_key):
# 첫 방문
from .models import Invitation, Analytics
try:
invitation = Invitation.objects.get(slug=slug)
# Analytics 기록
Analytics.objects.create(
invitation=invitation,
visitor_id=visitor_id,
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
referrer=request.META.get('HTTP_REFERER', ''),
page_url=request.build_absolute_uri(),
utm_source=request.GET.get('utm_source', ''),
utm_medium=request.GET.get('utm_medium', ''),
utm_campaign=request.GET.get('utm_campaign', '')
)
# 조회수 증가
invitation.increment_view(is_unique=True)
# 캐시 설정 (24시간)
cache.set(cache_key, True, 86400)
except Invitation.DoesNotExist:
pass
# settings.py
MIDDLEWARE = [
# ...
'invitations.middleware.VisitorTrackingMiddleware',
]
6.2 QR 코드 생성
# invitations/utils.py
import qrcode
from io import BytesIO
from django.core.files.base import ContentFile
def generate_qr_code(invitation):
"""
청첩장 QR 코드 생성
Returns:
BytesIO: QR 코드 이미지
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(invitation.full_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
return buffer
# API에서 사용
@router.get("/{slug}/qr-code")
def get_qr_code(request, slug: str):
"""QR 코드 다운로드"""
invitation = get_object_or_404(Invitation, slug=slug, user=request.user)
qr_buffer = generate_qr_code(invitation)
from django.http import HttpResponse
response = HttpResponse(qr_buffer, content_type='image/png')
response['Content-Disposition'] = f'attachment; filename="{slug}_qr.png"'
return response
6.3 카카오톡 공유 (Open Graph)
# templates/invitation_detail.html
<!DOCTYPE html>
<html>
<head>
{% raw %}
<!-- Open Graph 메타 태그 -->
<meta property="og:type" content="website">
<meta property="og:title" content="{{ invitation.title }}">
<meta property="og:description" content="{{ invitation.settings.bride.name }}💕{{ invitation.settings.groom.name }}의 결혼식에 초대합니다">
<meta property="og:url" content="{{ invitation.full_url }}">
{% if invitation.photos.filter(is_main=True).first %}
<meta property="og:image" content="{{ invitation.photos.filter(is_main=True).first.image.url }}">
{% endif %}
{% endraw %}
<!-- 카카오톡 -->
<meta property="og:image:width" content="800">
<meta property="og:image:height" content="400">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
</head>
<body>
<!-- 청첩장 내용 -->
</body>
</html>
6.4 캐싱 전략
# invitations/views.py
from django.views.decorators.cache import cache_page
from django.utils.decorators import method_decorator
# 공개된 청첩장은 5분 캐싱
@cache_page(60 * 5)
def invitation_detail_view(request, slug):
invitation = get_object_or_404(Invitation, slug=slug)
# ...
# Redis 캐싱
from django.core.cache import cache
def get_invitation_cached(slug):
"""청첩장 조회 (캐시 활용)"""
cache_key = f"invitation:{slug}"
invitation = cache.get(cache_key)
if not invitation:
invitation = Invitation.objects.select_related('template').get(slug=slug)
cache.set(cache_key, invitation, 300) # 5분
return invitation
# 수정 시 캐시 무효화
@transaction.atomic
def update_invitation(invitation_id, **data):
invitation = Invitation.objects.get(id=invitation_id)
for key, value in data.items():
setattr(invitation, key, value)
invitation.save()
# 캐시 삭제
cache.delete(f"invitation:{invitation.slug}")
return invitation
7. 성능 최적화 및 확장성
7.1 데이터베이스 인덱싱 전략
# 복합 인덱스 마이그레이션
from django.db import migrations, models
class Migration(migrations.Migration):
operations = [
# 1. 자주 조회되는 필드 조합
migrations.AddIndex(
model_name='invitation',
index=models.Index(
fields=['user', 'status', '-created_at'],
name='idx_user_status_date'
),
),
# 2. 공개된 청첩장 조회
migrations.AddIndex(
model_name='invitation',
index=models.Index(
fields=['status', '-wedding_date'],
name='idx_status_wedding'
),
),
# 3. Slug 조회 (이미 unique=True지만 명시적 인덱스)
migrations.AddIndex(
model_name='invitation',
index=models.Index(
fields=['slug'],
name='idx_slug'
),
),
# 4. 방명록 pagination
migrations.AddIndex(
model_name='guestbook',
index=models.Index(
fields=['invitation', 'is_public', '-created_at'],
name='idx_guestbook_list'
),
),
# 5. PostgreSQL 부분 인덱스 (공개된 청첩장만)
migrations.RunSQL(
sql="""
CREATE INDEX idx_published_invitations
ON invitations (wedding_date DESC)
WHERE status = 'published';
""",
reverse_sql="DROP INDEX idx_published_invitations;"
),
# 6. JSON 필드 GIN 인덱스
migrations.RunSQL(
sql="""
CREATE INDEX idx_invitation_settings_gin
ON invitations USING GIN (settings);
""",
reverse_sql="DROP INDEX idx_invitation_settings_gin;"
),
]
7.2 쿼리 최적화
# ❌ 나쁜 예: N+1 쿼리
def bad_list_invitations(user_id):
invitations = Invitation.objects.filter(user_id=user_id)
for inv in invitations:
print(inv.template.name) # 매번 DB 조회!
print(inv.photos.count()) # 매번 DB 조회!
# ✅ 좋은 예: select_related, prefetch_related
def good_list_invitations(user_id):
invitations = Invitation.objects.filter(
user_id=user_id
).select_related(
'template' # ForeignKey JOIN
).prefetch_related(
'photos', # Reverse ForeignKey
'guestbook_entries',
'attendances'
)
return invitations
# ✅ 더 나은 예: annotate로 집계
from django.db.models import Count, Prefetch
def optimized_list_invitations(user_id):
invitations = Invitation.objects.filter(
user_id=user_id
).select_related(
'template'
).annotate(
photo_count=Count('photos'),
guestbook_count=Count('guestbook_entries'),
attendance_count=Count('attendances', filter=models.Q(attendances__will_attend=True))
).prefetch_related(
Prefetch(
'photos',
queryset=Photo.objects.filter(is_main=True)[:1] # 메인 사진만
)
)
return invitations
7.3 CDN 및 미디어 최적화
# settings.py
# AWS S3 + CloudFront 설정
if not DEBUG:
# S3 스토리지
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = 'wedding-invitations'
AWS_S3_REGION_NAME = 'ap-northeast-2'
# CloudFront CDN
AWS_S3_CUSTOM_DOMAIN = 'd1234567890.cloudfront.net'
# 미디어 URL
MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/'
# 캐시 설정
AWS_S3_OBJECT_PARAMETERS = {
'CacheControl': 'max-age=86400', # 1일
}
# 이미지 최적화
from PIL import Image
def optimize_image(image_file):
"""
이미지 최적화
1. WebP 변환
2. 리사이즈 (최대 1920px)
3. 압축
"""
img = Image.open(image_file)
# EXIF 회전 처리
try:
from PIL import ImageOps
img = ImageOps.exif_transpose(img)
except:
pass
# 리사이즈 (비율 유지)
max_size = 1920
if img.width > max_size or img.height > max_size:
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
# WebP 변환
from io import BytesIO
buffer = BytesIO()
img.save(buffer, format='WebP', quality=85, optimize=True)
buffer.seek(0)
return buffer
# Signal로 자동 최적화
from django.db.models.signals import pre_save
from django.dispatch import receiver
@receiver(pre_save, sender=Photo)
def optimize_photo_before_save(sender, instance, **kwargs):
"""사진 저장 전 최적화"""
if instance.image:
optimized = optimize_image(instance.image)
instance.image.save(
instance.image.name.replace('.jpg', '.webp'),
optimized,
save=False
)
7.4 멀티테넌시 URL 라우팅
# urls.py
from django.urls import path
from invitations import views
urlpatterns = [
# 관리 API
path('api/invitations/', include('invitations.api')),
# 공개 청첩장 (서브도메인 또는 경로)
# 옵션 1: 경로 기반
path('wedding/<slug:slug>/', views.invitation_detail, name='invitation_detail'),
# 옵션 2: 서브도메인 (커스텀 미들웨어 필요)
# john-mary.weddingcard.com
]
# 커스텀 도메인 지원
class CustomDomainMiddleware:
"""
사용자 커스텀 도메인 지원
예: johnanmary.com → invitation의 slug
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().split(':')[0]
# 메인 도메인이 아닌 경우
if host not in ['weddingcard.com', 'localhost', '127.0.0.1']:
# 커스텀 도메인 조회
try:
from invitations.models import CustomDomain
custom_domain = CustomDomain.objects.get(domain=host)
# 해당 청첩장으로 리다이렉트
request.custom_invitation = custom_domain.invitation
except CustomDomain.DoesNotExist:
pass
response = self.get_response(request)
return response
# CustomDomain 모델
class CustomDomain(models.Model):
"""사용자 커스텀 도메인"""
invitation = models.OneToOneField(
'Invitation',
on_delete=models.CASCADE,
related_name='custom_domain'
)
domain = models.CharField(
max_length=255,
unique=True,
help_text="커스텀 도메인 (예: johnanmary.com)"
)
is_verified = models.BooleanField(
default=False,
help_text="DNS 인증 완료 여부"
)
ssl_enabled = models.BooleanField(
default=False,
help_text="SSL 인증서 발급 여부"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'custom_domains'
7.5 백그라운드 작업 (Celery)
# invitations/tasks.py
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
@shared_task
def send_wedding_reminder(invitation_id):
"""
결혼식 D-7 리마인더 발송
결혼식 7일 전에 참석 확정자에게 알림
"""
from .models import Invitation, Attendance
from .notifications import send_sms
invitation = Invitation.objects.get(id=invitation_id)
# 참석 확정자 목록
attendees = Attendance.objects.filter(
invitation=invitation,
will_attend=True,
phone_number__isnull=False
)
for attendee in attendees:
message = f"""
[{invitation.get_bride_name()} ❤️ {invitation.get_groom_name()}]
결혼식이 7일 남았습니다!
📅 {invitation.wedding_date.strftime('%Y년 %m월 %d일 %H시')}
📍 {invitation.settings['venue']['name']}
청첩장: {invitation.full_url}
"""
send_sms(attendee.phone_number, message)
@shared_task
def generate_thumbnails(photo_id):
"""
사진 썸네일 생성 (비동기)
여러 사이즈 생성: small(200px), medium(400px), large(800px)
"""
from .models import Photo
from PIL import Image
from io import BytesIO
from django.core.files.base import ContentFile
photo = Photo.objects.get(id=photo_id)
sizes = {
'small': 200,
'medium': 400,
'large': 800
}
for name, size in sizes.items():
img = Image.open(photo.image)
img.thumbnail((size, size), Image.Resampling.LANCZOS)
buffer = BytesIO()
img.save(buffer, format='JPEG', quality=85)
buffer.seek(0)
# 별도 필드에 저장 또는 파일명으로 구분
# photo.thumbnail_small, photo.thumbnail_medium 등
@shared_task
def cleanup_old_drafts():
"""
오래된 임시저장 청첩장 삭제
30일 이상 DRAFT 상태인 청첩장 자동 삭제
"""
from .models import Invitation
threshold = timezone.now() - timedelta(days=30)
old_drafts = Invitation.objects.filter(
status=Invitation.Status.DRAFT,
created_at__lt=threshold
)
count = old_drafts.count()
old_drafts.delete()
return f"Deleted {count} old drafts"
@shared_task
def generate_analytics_report(invitation_id):
"""
통계 리포트 생성
일일/주간/월간 통계 집계
"""
from .models import Invitation, Analytics
from django.db.models import Count
import pandas as pd
invitation = Invitation.objects.get(id=invitation_id)
# 일별 방문자 수
daily_stats = Analytics.objects.filter(
invitation=invitation
).extra(
select={'day': 'DATE(visit_date)'}
).values('day').annotate(
visitors=Count('visitor_id', distinct=True),
views=Count('id')
).order_by('day')
# 디바이스별 통계
device_stats = Analytics.objects.filter(
invitation=invitation
).values('device_type').annotate(
count=Count('id')
)
# 유입 경로
referrer_stats = Analytics.objects.filter(
invitation=invitation,
utm_source__isnull=False
).values('utm_source', 'utm_medium').annotate(
count=Count('id')
)
return {
'daily': list(daily_stats),
'devices': list(device_stats),
'referrers': list(referrer_stats)
}
# Celery Beat 스케줄링
from celery.schedules import crontab
app.conf.beat_schedule = {
# 매일 자정 청소
'cleanup-old-drafts': {
'task': 'invitations.tasks.cleanup_old_drafts',
'schedule': crontab(hour=0, minute=0),
},
# 결혼식 D-7 리마인더 (매일 오전 10시)
'send-reminders': {
'task': 'invitations.tasks.check_and_send_reminders',
'schedule': crontab(hour=10, minute=0),
},
}
8. 보안 고려사항
8.1 개인정보 보호
# 1. 민감 정보 암호화
from django.conf import settings
from cryptography.fernet import Fernet
class EncryptedTextField(models.TextField):
"""암호화된 텍스트 필드"""
def get_prep_value(self, value):
if value is None:
return value
# 암호화
f = Fernet(settings.ENCRYPTION_KEY)
encrypted = f.encrypt(value.encode())
return encrypted.decode()
def from_db_value(self, value, expression, connection):
if value is None:
return value
# 복호화
f = Fernet(settings.ENCRYPTION_KEY)
decrypted = f.decrypt(value.encode())
return decrypted.decode()
# 계좌번호 암호화
class AccountInfo(models.Model):
account_number = EncryptedTextField() # 암호화
# 2. 민감 정보 로깅 제외
import logging
class SensitiveDataFilter(logging.Filter):
"""민감 정보 필터링"""
SENSITIVE_KEYS = ['password', 'account_number', 'phone_number']
def filter(self, record):
if hasattr(record, 'msg'):
msg = str(record.msg)
# 전화번호 마스킹
import re
msg = re.sub(r'\d{3}-\d{4}-\d{4}', '***-****-****', msg)
# 계좌번호 마스킹
msg = re.sub(r'\d{3}-\d{2}-\d{5}', '***-**-*****', msg)
record.msg = msg
return True
# 3. GDPR 준수 - 개인정보 삭제
def delete_user_data(user_id):
"""
사용자 탈퇴 시 개인정보 삭제
GDPR의 'Right to be Forgotten' 구현
"""
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.get(id=user_id)
# 청첩장은 익명화 (삭제 X)
Invitation.objects.filter(user=user).update(
user=None, # 익명 사용자로
status=Invitation.Status.ARCHIVED
)
# 방명록/참석 정보는 유지 (청첩장 소유자가 아니므로)
# 사용자 계정 삭제
user.delete()
8.2 Rate Limiting
# Rate Limiting (django-ratelimit 사용)
from django_ratelimit.decorators import ratelimit
@router.post("/{slug}/guestbook")
@ratelimit(key='ip', rate='5/h', method='POST') # IP당 시간당 5회
def create_guestbook(request, slug: str, data: GuestBookCreateIn):
# 제한 초과 시 자동으로 429 반환
# ...
# 커스텀 Rate Limit
from django.core.cache import cache
def rate_limit_check(key, limit=10, period=3600):
"""
Rate limit 확인
Args:
key: 식별자 (IP, user_id 등)
limit: 제한 횟수
period: 기간 (초)
Returns:
bool: 허용 여부
"""
cache_key = f"rate_limit:{key}"
current = cache.get(cache_key, 0)
if current >= limit:
return False
cache.set(cache_key, current + 1, period)
return True
# API에서 사용
@router.post("/{slug}/attendance")
def submit_attendance(request, slug: str, data: AttendanceCreateIn):
ip = request.META.get('REMOTE_ADDR')
if not rate_limit_check(f"attendance:{slug}:{ip}", limit=3, period=3600):
return 429, {"error": "Too many requests. Please try again later."}
# ...
8.3 XSS 및 CSRF 방지
# 1. XSS 방지 - 사용자 입력 이스케이프
from django.utils.html import escape
class GuestBookCreateIn(Schema):
message: str
@validator('message')
def sanitize_message(cls, v):
# HTML 태그 제거
import bleach
allowed_tags = [] # 태그 허용 안함
cleaned = bleach.clean(v, tags=allowed_tags, strip=True)
return cleaned
# 2. CSRF 토큰 (Django 기본 제공)
# Django-Ninja는 자동으로 CSRF 체크
# 3. SQL Injection 방지 (ORM 사용으로 자동 방지)
# ❌ 절대 금지
def bad_query(slug):
from django.db import connection
cursor = connection.cursor()
cursor.execute(f"SELECT * FROM invitations WHERE slug = '{slug}'") # SQL Injection!
# ✅ ORM 사용
def good_query(slug):
return Invitation.objects.get(slug=slug) # 파라미터화된 쿼리
9. 테스트 전략
9.1 단위 테스트
# invitations/tests/test_models.py
from django.test import TestCase
from django.contrib.auth import get_user_model
from invitations.models import Invitation, Template
User = get_user_model()
class InvitationModelTest(TestCase):
"""Invitation 모델 테스트"""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='test@example.com'
)
self.template = Template.objects.create(
name='Test Template',
category='modern',
is_free=True
)
def test_slug_auto_generation(self):
"""Slug 자동 생성"""
invitation = Invitation.objects.create(
user=self.user,
template=self.template,
title='John & Mary Wedding'
)
self.assertIsNotNone(invitation.slug)
self.assertIn('john', invitation.slug.lower())
def test_slug_uniqueness(self):
"""Slug 중복 방지"""
Invitation.objects.create(
user=self.user,
template=self.template,
title='Same Title'
)
# 같은 제목으로 다시 생성
invitation2 = Invitation.objects.create(
user=self.user,
template=self.template,
title='Same Title'
)
# Slug가 달라야 함 (예: same-title, same-title-1)
self.assertNotEqual(
Invitation.objects.first().slug,
invitation2.slug
)
def test_increment_view(self):
"""조회수 증가"""
invitation = Invitation.objects.create(
user=self.user,
template=self.template,
title='Test'
)
initial_count = invitation.view_count
invitation.increment_view()
invitation.refresh_from_db()
self.assertEqual(invitation.view_count, initial_count + 1)
9.2 API 테스트
# invitations/tests/test_api.py
from ninja.testing import TestClient
from django.test import TestCase
class InvitationAPITest(TestCase):
"""Invitation API 테스트"""
def setUp(self):
from invitations.api import router
self.client = TestClient(router)
# 사용자 및 템플릿 생성
self.user = User.objects.create_user(
username='testuser',
password='testpass'
)
self.template = Template.objects.create(
name='Test Template',
is_free=True
)
def test_create_invitation(self):
"""청첩장 생성 API"""
response = self.client.post(
'/',
json={
'template_id': self.template.id,
'title': 'Test Wedding',
'settings': {
'bride': {'name': 'Jane'},
'groom': {'name': 'John'}
}
},
# 인증 헤더 (실제로는 JWT)
headers={'Authorization': f'Bearer {self.get_token()}'}
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn('id', data)
self.assertIn('slug', data)
def test_get_invitation_detail(self):
"""청첩장 상세 조회"""
invitation = Invitation.objects.create(
user=self.user,
template=self.template,
title='Test',
status=Invitation.Status.PUBLISHED
)
response = self.client.get(f'/{invitation.slug}')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['slug'], invitation.slug)
10. 배포 및 운영
10.1 환경 설정
# config/settings/production.py
import os
DEBUG = False
ALLOWED_HOSTS = [
'weddingcard.com',
'www.weddingcard.com',
'*.weddingcard.com', # 서브도메인
]
# 데이터베이스 (PostgreSQL)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME'),
'USER': os.environ.get('DB_USER'),
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': os.environ.get('DB_HOST'),
'PORT': '5432',
'CONN_MAX_AGE': 600, # 커넥션 풀링
'OPTIONS': {
'connect_timeout': 10,
}
}
}
# Redis 캐시
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': os.environ.get('REDIS_URL'),
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'COMPRESSOR': 'django_redis.compressors.zlib.ZlibCompressor',
}
}
}
# Celery
CELERY_BROKER_URL = os.environ.get('REDIS_URL')
CELERY_RESULT_BACKEND = os.environ.get('REDIS_URL')
# 보안
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
10.2 모니터링
# Sentry 에러 트래킹
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn=os.environ.get('SENTRY_DSN'),
integrations=[DjangoIntegration()],
traces_sample_rate=0.1, # 10% 트랜잭션 추적
send_default_pii=False # 개인정보 전송 안함
)
# 커스텀 메트릭
from prometheus_client import Counter, Histogram
invitation_created = Counter(
'invitation_created_total',
'Total invitations created'
)
invitation_view_duration = Histogram(
'invitation_view_duration_seconds',
'Time to render invitation'
)
# 사용
@invitation_view_duration.time()
def invitation_detail_view(request, slug):
invitation_created.inc()
# ...
결론
핵심 설계 원칙 정리
1. 템플릿과 인스턴스 분리
Template모델: 재사용 가능한 디자인Invitation모델: 사용자 개인화 데이터- JSON Schema로 템플릿별 다른 설정 지원
2. JSON vs 정규화
- 유연한 커스터마이징: JSONField 사용
- 자주 검색하는 필드: 별도 컬럼 (
wedding_date,title) - PostgreSQL GIN 인덱스로 JSON 검색 최적화
3. 멀티테넌시 구조
- 고유 Slug로 청첩장별 URL (
/wedding/john-mary-2025) - UUID Primary Key로 보안 강화
- 커스텀 도메인 지원 가능
4. 성능 최적화
- select_related/prefetch_related로 N+1 방지
- Redis 캐싱 (청첩장 조회 5분)
- CDN + WebP로 이미지 최적화
- Celery로 무거운 작업 비동기 처리
5. 보안
- 민감 정보 암호화 (계좌번호)
- Rate Limiting (스팸 방지)
- XSS/CSRF/SQL Injection 방어
- GDPR 준수 (개인정보 삭제)
확장 가능성
이 구조는 다음으로 확장 가능:
- 돌잔치, 생일파티, 이벤트 청첩장
- 다국어 지원 (i18n)
- 결제 시스템 (프리미엄 템플릿)
- AI 추천 (사용자 취향 기반 템플릿 추천)
- 실시간 방명록 (WebSocket)
추가 학습 자료
모바일 청첩장 서비스 구축 화이팅! 💒💐