Django Ninja로 구현하는 대규모 공연 티켓 예매 시스템
서론: 티켓 예매 시스템의 도전과제
공연 티켓 예매 시스템은 백엔드 개발자에게 가장 도전적인 과제 중 하나입니다. 인기 아티스트의 콘서트 티켓이 오픈되는 순간, 수만 명의 사용자가 동시에 접속하여 한정된 좌석을 예매하려 합니다. 이 과정에서 발생할 수 있는 문제들은 다음과 같습니다:
- Race Condition: 여러 사용자가 동시에 같은 좌석을 예매하려 할 때 발생하는 경쟁 상태
- Overselling: 재고 관리 실패로 인해 실제 좌석 수보다 많은 티켓이 판매되는 상황
- DB Lock Contention: 대량의 동시 요청으로 인한 데이터베이스 락 경합
- 서버 과부하: 순간적인 트래픽 폭증으로 인한 서버 다운
본 포스트에서는 Django Ninja를 활용하여 이러한 문제들을 해결하고, 대규모 트래픽을 안정적으로 처리할 수 있는 티켓 예매 시스템을 구축하는 방법을 코드와 함께 살펴보겠습니다.
Django Ninja 소개 및 프로젝트 구조
Django Ninja는 FastAPI에서 영감을 받아 만들어진 Django용 웹 프레임워크로, Python 타입 힌트를 활용한 자동 검증, API 문서 자동 생성, 그리고 뛰어난 성능을 제공합니다. Django REST Framework(DRF)에 비해 약 2-3배 빠른 성능을 보이며, 특히 대량 트래픽 처리가 필요한 티켓 예매 시스템에 적합합니다.
먼저 프로젝트 구조를 설정하겠습니다:
# 가상환경 생성 및 활성화
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 필요한 패키지 설치
pip install django django-ninja redis celery python-decouple psycopg2-binary
# requirements.txt
Django==5.0.1
django-ninja==1.1.0
redis==5.0.1
celery==5.3.4
python-decouple==3.8
psycopg2-binary==2.9.9
django-redis==5.4.0
프로젝트 구조는 다음과 같이 구성합니다:
ticket_system/
├── manage.py
├── config/
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── celery.py
├── tickets/
│ ├── __init__.py
│ ├── models.py
│ ├── schemas.py
│ ├── api.py
│ ├── services.py
│ └── tasks.py
└── core/
├── __init__.py
├── redis_lock.py
└── exceptions.py
모델 설계 및 기본 API 구현
티켓 예매 시스템의 핵심은 적절한 데이터 모델 설계입니다. Concert(공연), Seat(좌석), Reservation(예약) 모델을 구현하고, 재고 관리를 위한 필드를 추가합니다:
# tickets/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
class Concert(models.Model):
"""공연 정보"""
title = models.CharField(max_length=200, verbose_name="공연명")
artist = models.CharField(max_length=100, verbose_name="아티스트")
venue = models.CharField(max_length=200, verbose_name="공연장")
event_date = models.DateTimeField(verbose_name="공연 일시")
sale_start_date = models.DateTimeField(verbose_name="판매 시작 일시")
total_seats = models.IntegerField(verbose_name="총 좌석 수")
available_seats = models.IntegerField(verbose_name="남은 좌석 수")
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="가격")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'concerts'
indexes = [
models.Index(fields=['sale_start_date']),
models.Index(fields=['event_date']),
]
def __str__(self):
return f"{self.title} - {self.artist}"
class Seat(models.Model):
"""좌석 정보"""
STATUS_CHOICES = [
('AVAILABLE', '예매 가능'),
('RESERVED', '예약됨'),
('CONFIRMED', '결제 완료'),
]
concert = models.ForeignKey(Concert, on_delete=models.CASCADE, related_name='seats')
section = models.CharField(max_length=50, verbose_name="구역") # VIP, R, S, A
row = models.CharField(max_length=10, verbose_name="열")
number = models.IntegerField(verbose_name="좌석 번호")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='AVAILABLE')
version = models.IntegerField(default=0, verbose_name="버전 (낙관적 락)")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'seats'
unique_together = [['concert', 'section', 'row', 'number']]
indexes = [
models.Index(fields=['concert', 'status']),
models.Index(fields=['status']),
]
def __str__(self):
return f"{self.concert.title} - {self.section}{self.row}-{self.number}"
class Reservation(models.Model):
"""예약 정보"""
STATUS_CHOICES = [
('PENDING', '결제 대기'),
('CONFIRMED', '예약 확정'),
('CANCELLED', '취소됨'),
('EXPIRED', '만료됨'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reservations')
seat = models.ForeignKey(Seat, on_delete=models.CASCADE, related_name='reservations')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
reserved_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(verbose_name="예약 만료 시간")
confirmed_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = 'reservations'
indexes = [
models.Index(fields=['user', 'status']),
models.Index(fields=['expires_at', 'status']),
]
def __str__(self):
return f"{self.user.username} - {self.seat}"
이제 Django Ninja를 사용하여 기본 API를 구현합니다:
# tickets/schemas.py
from ninja import Schema
from datetime import datetime
from typing import Optional
class ConcertSchema(Schema):
id: int
title: str
artist: str
venue: str
event_date: datetime
sale_start_date: datetime
available_seats: int
total_seats: int
price: float
class SeatSchema(Schema):
id: int
section: str
row: str
number: int
status: str
class ReservationRequest(Schema):
seat_id: int
class ReservationResponse(Schema):
id: int
seat: SeatSchema
status: str
reserved_at: datetime
expires_at: datetime
# tickets/api.py
from ninja import Router
from django.shortcuts import get_object_or_404
from django.contrib.auth.models import User
from typing import List
from .models import Concert, Seat, Reservation
from .schemas import ConcertSchema, SeatSchema, ReservationResponse, ReservationRequest
router = Router()
@router.get("/concerts", response=List[ConcertSchema])
def list_concerts(request):
"""공연 목록 조회"""
concerts = Concert.objects.filter(
sale_start_date__lte=timezone.now()
).order_by('-sale_start_date')
return concerts
@router.get("/concerts/{concert_id}/seats", response=List[SeatSchema])
def list_available_seats(request, concert_id: int):
"""특정 공연의 예매 가능한 좌석 목록"""
seats = Seat.objects.filter(
concert_id=concert_id,
status='AVAILABLE'
).select_related('concert')
return seats
Redis 분산 락을 활용한 동시성 제어
티켓 예매 시스템에서 가장 중요한 부분은 동시성 제어입니다. 여러 사용자가 동시에 같은 좌석을 예매하려 할 때, Race Condition을 방지해야 합니다. Redis의 분산 락(Distributed Lock)을 사용하여 이를 해결합니다.
먼저 Redis 설정을 추가합니다:
# config/settings.py
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
}
}
# Redis 연결 설정
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0
Redis 분산 락 구현:
# core/redis_lock.py
import redis
import time
import uuid
from contextlib import contextmanager
from typing import Optional
class RedisLock:
"""Redis 기반 분산 락 구현"""
def __init__(self, redis_client: redis.Redis):
self.redis_client = redis_client
@contextmanager
def acquire_lock(
self,
lock_key: str,
timeout: int = 10,
blocking_timeout: int = 5
):
"""
분산 락 획득
Args:
lock_key: 락의 키
timeout: 락 자동 만료 시간 (초)
blocking_timeout: 락 획득 대기 시간 (초)
"""
# 고유한 lock identifier 생성
lock_id = str(uuid.uuid4())
lock_acquired = False
start_time = time.time()
try:
# 락 획득 시도 (blocking)
while time.time() - start_time < blocking_timeout:
# SET NX (Not eXists) + EX (EXpire) 명령어 사용
if self.redis_client.set(
lock_key,
lock_id,
nx=True,
ex=timeout
):
lock_acquired = True
break
# 100ms 대기 후 재시도
time.sleep(0.1)
if not lock_acquired:
raise TimeoutError(f"Lock 획득 실패: {lock_key}")
yield lock_id
finally:
# 락 해제 (Lua 스크립트로 원자적 작업 보장)
if lock_acquired:
self._release_lock(lock_key, lock_id)
def _release_lock(self, lock_key: str, lock_id: str):
"""
락 해제 (자신이 획득한 락만 해제)
Lua 스크립트로 원자성 보장
"""
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
self.redis_client.eval(lua_script, 1, lock_key, lock_id)
# Redis 클라이언트 싱글톤
_redis_client = None
def get_redis_client() -> redis.Redis:
"""Redis 클라이언트 반환"""
global _redis_client
if _redis_client is None:
from django.conf import settings
_redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
decode_responses=True
)
return _redis_client
def get_redis_lock() -> RedisLock:
"""RedisLock 인스턴스 반환"""
return RedisLock(get_redis_client())
커스텀 예외 정의:
# core/exceptions.py
from ninja import Schema
class SeatNotAvailableError(Exception):
"""좌석이 이미 예약된 경우"""
pass
class ConcertNotFoundError(Exception):
"""공연을 찾을 수 없는 경우"""
pass
class ReservationExpiredError(Exception):
"""예약이 만료된 경우"""
pass
class ErrorResponse(Schema):
detail: str
좌석 예매 서비스: 낙관적 락과 비관적 락의 조합
이제 실제 티켓 예매 로직을 구현합니다. Redis 분산 락(비관적 락)과 DB의 낙관적 락을 조합하여 안정적이고 효율적인 예매 시스템을 만듭니다:
# tickets/services.py
from django.db import transaction, DatabaseError
from django.utils import timezone
from datetime import timedelta
from typing import Optional
import logging
from .models import Seat, Reservation, Concert
from core.redis_lock import get_redis_lock
from core.exceptions import SeatNotAvailableError, ConcertNotFoundError
logger = logging.getLogger(__name__)
class ReservationService:
"""티켓 예매 비즈니스 로직"""
def __init__(self):
self.redis_lock = get_redis_lock()
self.reservation_timeout_minutes = 10 # 예약 유지 시간
def reserve_seat(self, user_id: int, seat_id: int) -> Reservation:
"""
좌석 예매 메인 로직
1. Redis 분산 락으로 동시 접근 제어
2. 낙관적 락으로 DB 레벨 동시성 제어
3. 재고 감소 및 예약 생성
"""
lock_key = f"seat_lock:{seat_id}"
try:
# Redis 분산 락 획득 (최대 5초 대기)
with self.redis_lock.acquire_lock(
lock_key,
timeout=10,
blocking_timeout=5
):
return self._create_reservation_with_lock(user_id, seat_id)
except TimeoutError:
logger.warning(f"Lock timeout for seat {seat_id}")
raise SeatNotAvailableError("현재 많은 사용자가 이 좌석을 선택했습니다. 잠시 후 다시 시도해주세요.")
@transaction.atomic
def _create_reservation_with_lock(self, user_id: int, seat_id: int) -> Reservation:
"""
트랜잭션 내에서 예약 생성
낙관적 락(version 필드)을 사용하여 DB 레벨 동시성 제어
"""
# SELECT FOR UPDATE로 행 락 획득
seat = Seat.objects.select_for_update().get(id=seat_id)
# 좌석 상태 검증
if seat.status != 'AVAILABLE':
raise SeatNotAvailableError("이미 예약된 좌석입니다.")
# 공연 재고 검증
concert = seat.concert
if concert.available_seats <= 0:
raise SeatNotAvailableError("매진되었습니다.")
# 낙관적 락: version 체크 및 증가
current_version = seat.version
updated_count = Seat.objects.filter(
id=seat_id,
version=current_version,
status='AVAILABLE'
).update(
status='RESERVED',
version=current_version + 1
)
if updated_count == 0:
# 다른 트랜잭션이 먼저 업데이트함
raise SeatNotAvailableError("좌석 예약 중 충돌이 발생했습니다. 다시 시도해주세요.")
# 좌석 객체 새로고침
seat.refresh_from_db()
# Concert 재고 감소 (원자적 업데이트)
Concert.objects.filter(id=concert.id).update(
available_seats=models.F('available_seats') - 1
)
# 예약 생성
expires_at = timezone.now() + timedelta(minutes=self.reservation_timeout_minutes)
reservation = Reservation.objects.create(
user_id=user_id,
seat=seat,
status='PENDING',
expires_at=expires_at
)
logger.info(f"Reservation created: {reservation.id} for user {user_id}, seat {seat_id}")
return reservation
@transaction.atomic
def confirm_reservation(self, reservation_id: int, user_id: int) -> Reservation:
"""
예약 확정 (결제 완료 후)
"""
reservation = Reservation.objects.select_for_update().get(
id=reservation_id,
user_id=user_id
)
# 만료 시간 체크
if timezone.now() > reservation.expires_at:
self._cancel_expired_reservation(reservation)
raise ReservationExpiredError("예약 시간이 만료되었습니다.")
# 예약 확정
reservation.status = 'CONFIRMED'
reservation.confirmed_at = timezone.now()
reservation.save()
# 좌석 상태 업데이트
seat = reservation.seat
seat.status = 'CONFIRMED'
seat.save()
logger.info(f"Reservation confirmed: {reservation.id}")
return reservation
@transaction.atomic
def cancel_reservation(self, reservation_id: int, user_id: int) -> None:
"""예약 취소"""
reservation = Reservation.objects.select_for_update().get(
id=reservation_id,
user_id=user_id
)
if reservation.status == 'CONFIRMED':
raise ValueError("이미 확정된 예약은 취소할 수 없습니다.")
self._release_seat(reservation)
reservation.status = 'CANCELLED'
reservation.save()
logger.info(f"Reservation cancelled: {reservation.id}")
def _cancel_expired_reservation(self, reservation: Reservation) -> None:
"""만료된 예약 처리"""
self._release_seat(reservation)
reservation.status = 'EXPIRED'
reservation.save()
def _release_seat(self, reservation: Reservation) -> None:
"""좌석 해제 및 재고 복구"""
seat = reservation.seat
seat.status = 'AVAILABLE'
seat.save()
# Concert 재고 증가
Concert.objects.filter(id=seat.concert_id).update(
available_seats=models.F('available_seats') + 1
)
API 엔드포인트 구현 및 대기열 시스템
이제 Django Ninja로 실제 API 엔드포인트를 구현하고, 대량 트래픽을 처리하기 위한 대기열 시스템을 추가합니다:
# tickets/api.py (업데이트)
from ninja import Router
from ninja.errors import HttpError
from django.shortcuts import get_object_or_404
from typing import List
from .models import Concert, Seat, Reservation
from .schemas import (
ConcertSchema,
SeatSchema,
ReservationResponse,
ReservationRequest,
ErrorResponse
)
from .services import ReservationService
from core.exceptions import SeatNotAvailableError, ReservationExpiredError
router = Router()
reservation_service = ReservationService()
@router.post(
"/reservations",
response={200: ReservationResponse, 400: ErrorResponse, 409: ErrorResponse}
)
def create_reservation(request, data: ReservationRequest):
"""
좌석 예매
- 동시성 제어를 통해 안전한 예매 처리
- 예매 후 10분 내 결제 필요
"""
try:
# 인증된 사용자 확인 (실제로는 JWT 등으로 처리)
if not request.user.is_authenticated:
raise HttpError(401, "로그인이 필요합니다.")
reservation = reservation_service.reserve_seat(
user_id=request.user.id,
seat_id=data.seat_id
)
return 200, reservation
except SeatNotAvailableError as e:
return 409, {"detail": str(e)}
except Seat.DoesNotExist:
return 400, {"detail": "존재하지 않는 좌석입니다."}
except Exception as e:
logger.error(f"Reservation error: {str(e)}")
return 400, {"detail": "예매 처리 중 오류가 발생했습니다."}
@router.post(
"/reservations/{reservation_id}/confirm",
response={200: ReservationResponse, 400: ErrorResponse}
)
def confirm_reservation(request, reservation_id: int):
"""
예약 확정 (결제 완료 후 호출)
"""
try:
if not request.user.is_authenticated:
raise HttpError(401, "로그인이 필요합니다.")
reservation = reservation_service.confirm_reservation(
reservation_id=reservation_id,
user_id=request.user.id
)
return 200, reservation
except ReservationExpiredError as e:
return 400, {"detail": str(e)}
except Exception as e:
logger.error(f"Confirmation error: {str(e)}")
return 400, {"detail": "예약 확정 중 오류가 발생했습니다."}
@router.delete("/reservations/{reservation_id}")
def cancel_reservation(request, reservation_id: int):
"""예약 취소"""
try:
if not request.user.is_authenticated:
raise HttpError(401, "로그인이 필요합니다.")
reservation_service.cancel_reservation(
reservation_id=reservation_id,
user_id=request.user.id
)
return {"success": True, "message": "예약이 취소되었습니다."}
except Exception as e:
logger.error(f"Cancellation error: {str(e)}")
raise HttpError(400, "예약 취소 중 오류가 발생했습니다.")
대기열 시스템 구현 (Redis Sorted Set 활용):
# tickets/queue_service.py
import time
import uuid
from typing import Optional, Tuple
from core.redis_lock import get_redis_client
class WaitingQueueService:
"""
Redis Sorted Set을 이용한 대기열 시스템
- 공연별로 대기열 관리
- 순서 보장
- TTL 설정으로 자동 정리
"""
def __init__(self):
self.redis_client = get_redis_client()
self.queue_ttl = 3600 # 1시간
self.max_active_users = 1000 # 동시 처리 가능한 최대 사용자 수
def enqueue(self, concert_id: int, user_id: int) -> Tuple[str, int]:
"""
대기열에 사용자 추가
Returns:
(token, position): 대기열 토큰과 현재 순번
"""
queue_key = f"waiting_queue:{concert_id}"
active_key = f"active_users:{concert_id}"
# 이미 활성 사용자인지 확인
if self.redis_client.sismember(active_key, user_id):
return None, 0 # 이미 입장 가능
# 대기열에 추가 (timestamp를 score로 사용하여 FIFO 보장)
token = str(uuid.uuid4())
score = time.time()
self.redis_client.zadd(
queue_key,
{f"{user_id}:{token}": score}
)
# 현재 순번 계산
position = self.redis_client.zrank(queue_key, f"{user_id}:{token}")
return token, position + 1 if position is not None else 1
def get_position(self, concert_id: int, user_id: int, token: str) -> Optional[int]:
"""현재 대기 순번 조회"""
queue_key = f"waiting_queue:{concert_id}"
member = f"{user_id}:{token}"
position = self.redis_client.zrank(queue_key, member)
return position + 1 if position is not None else None
def promote_to_active(self, concert_id: int, batch_size: int = 100) -> int:
"""
대기열에서 활성 사용자로 승격
배치 단위로 처리하여 효율성 향상
Returns:
승격된 사용자 수
"""
queue_key = f"waiting_queue:{concert_id}"
active_key = f"active_users:{concert_id}"
# 현재 활성 사용자 수 확인
current_active = self.redis_client.scard(active_key)
available_slots = self.max_active_users - current_active
if available_slots <= 0:
return 0
# 승격할 사용자 수 결정
promote_count = min(batch_size, available_slots)
# 대기열에서 가장 앞의 사용자들 가져오기
waiting_users = self.redis_client.zrange(queue_key, 0, promote_count - 1)
if not waiting_users:
return 0
# 활성 사용자로 추가
pipeline = self.redis_client.pipeline()
for user_token in waiting_users:
user_id = user_token.decode('utf-8').split(':')[0]
# 활성 set에 추가
pipeline.sadd(active_key, user_id)
# 대기열에서 제거
pipeline.zrem(queue_key, user_token)
# TTL 설정 (1시간)
pipeline.expire(active_key, self.queue_ttl)
pipeline.execute()
return len(waiting_users)
def is_active(self, concert_id: int, user_id: int) -> bool:
"""사용자가 활성 상태인지 확인"""
active_key = f"active_users:{concert_id}"
return self.redis_client.sismember(active_key, user_id)
def remove_from_active(self, concert_id: int, user_id: int) -> None:
"""활성 사용자에서 제거 (예매 완료 또는 타임아웃)"""
active_key = f"active_users:{concert_id}"
self.redis_client.srem(active_key, user_id)
대기열 API 엔드포인트:
# tickets/api.py에 추가
from .queue_service import WaitingQueueService
queue_service = WaitingQueueService()
class QueueStatusResponse(Schema):
token: str
position: int
is_active: bool
estimated_wait_minutes: int
@router.post("/concerts/{concert_id}/queue", response=QueueStatusResponse)
def join_queue(request, concert_id: int):
"""대기열 진입"""
if not request.user.is_authenticated:
raise HttpError(401, "로그인이 필요합니다.")
# 이미 활성 사용자인지 확인
if queue_service.is_active(concert_id, request.user.id):
return {
"token": "",
"position": 0,
"is_active": True,
"estimated_wait_minutes": 0
}
token, position = queue_service.enqueue(concert_id, request.user.id)
# 예상 대기 시간 계산 (1분당 100명 처리 가정)
estimated_wait = max(0, (position - 1000) // 100)
return {
"token": token,
"position": position,
"is_active": False,
"estimated_wait_minutes": estimated_wait
}
@router.get("/concerts/{concert_id}/queue/status", response=QueueStatusResponse)
def get_queue_status(request, concert_id: int, token: str):
"""대기열 상태 조회"""
if not request.user.is_authenticated:
raise HttpError(401, "로그인이 필요합니다.")
# 활성 사용자 확인
if queue_service.is_active(concert_id, request.user.id):
return {
"token": token,
"position": 0,
"is_active": True,
"estimated_wait_minutes": 0
}
# 대기 순번 확인
position = queue_service.get_position(concert_id, request.user.id, token)
if position is None:
raise HttpError(404, "대기열에서 찾을 수 없습니다. 다시 진입해주세요.")
estimated_wait = max(0, (position - 1000) // 100)
return {
"token": token,
"position": position,
"is_active": False,
"estimated_wait_minutes": estimated_wait
}
Celery를 활용한 비동기 작업 처리 및 성능 최적화
만료된 예약을 자동으로 처리하고, 대기열을 주기적으로 승격시키기 위해 Celery를 활용한 백그라운드 작업을 구현합니다:
# config/celery.py
import os
from celery import Celery
from celery.schedules import crontab
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
app = Celery('ticket_system')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
# 주기적 작업 스케줄
app.conf.beat_schedule = {
'cleanup-expired-reservations': {
'task': 'tickets.tasks.cleanup_expired_reservations',
'schedule': 60.0, # 1분마다 실행
},
'promote-waiting-queue': {
'task': 'tickets.tasks.promote_waiting_users',
'schedule': 10.0, # 10초마다 실행
},
}
# config/__init__.py
from .celery import app as celery_app
__all__ = ('celery_app',)
# tickets/tasks.py
from celery import shared_task
from django.utils import timezone
from django.db import transaction
from .models import Reservation, Concert
from .services import ReservationService
from .queue_service import WaitingQueueService
import logging
logger = logging.getLogger(__name__)
@shared_task
def cleanup_expired_reservations():
"""
만료된 예약 정리 및 재고 복구
주기적으로 실행하여 시스템 건강성 유지
"""
now = timezone.now()
# 만료된 예약 조회
expired_reservations = Reservation.objects.filter(
status='PENDING',
expires_at__lt=now
).select_related('seat', 'seat__concert')
count = 0
service = ReservationService()
for reservation in expired_reservations:
try:
with transaction.atomic():
# 좌석 해제
service._release_seat(reservation)
# 예약 상태 업데이트
reservation.status = 'EXPIRED'
reservation.save()
count += 1
except Exception as e:
logger.error(f"Failed to cleanup reservation {reservation.id}: {str(e)}")
if count > 0:
logger.info(f"Cleaned up {count} expired reservations")
return count
@shared_task
def promote_waiting_users():
"""
대기열에서 사용자를 활성 상태로 승격
10초마다 실행하여 원활한 흐름 유지
"""
queue_service = WaitingQueueService()
# 판매 중인 모든 공연에 대해 처리
active_concerts = Concert.objects.filter(
sale_start_date__lte=timezone.now(),
available_seats__gt=0
)
total_promoted = 0
for concert in active_concerts:
try:
promoted = queue_service.promote_to_active(
concert_id=concert.id,
batch_size=100
)
total_promoted += promoted
except Exception as e:
logger.error(f"Failed to promote users for concert {concert.id}: {str(e)}")
if total_promoted > 0:
logger.info(f"Promoted {total_promoted} users from waiting queue")
return total_promoted
@shared_task
def send_reservation_reminder(reservation_id: int):
"""
예약 만료 임박 알림
예약 후 8분에 실행 (만료 2분 전)
"""
try:
reservation = Reservation.objects.get(id=reservation_id)
if reservation.status != 'PENDING':
return
# 실제로는 이메일/SMS/푸시 알림 발송
logger.info(f"Reminder sent for reservation {reservation_id}")
# 예시: 이메일 발송
# send_email(
# to=reservation.user.email,
# subject="예약 만료 임박",
# message=f"2분 내에 결제하지 않으면 예약이 취소됩니다."
# )
except Reservation.DoesNotExist:
logger.warning(f"Reservation {reservation_id} not found for reminder")
성능 최적화를 위한 추가 설정:
# config/settings.py
# 데이터베이스 커넥션 풀링
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'ticket_system',
'USER': 'postgres',
'PASSWORD': 'password',
'HOST': 'localhost',
'PORT': '5432',
'CONN_MAX_AGE': 600, # 커넥션 재사용
'OPTIONS': {
'connect_timeout': 10,
'options': '-c statement_timeout=30000' # 30초 타임아웃
}
}
}
# Celery 설정
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'Asia/Seoul'
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30분
CELERY_WORKER_PREFETCH_MULTIPLIER = 4 # 동시 처리 작업 수
# 캐싱 전략
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {
"max_connections": 50,
"retry_on_timeout": True
},
"SOCKET_CONNECT_TIMEOUT": 5,
"SOCKET_TIMEOUT": 5,
}
}
}
# 로깅 설정
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/ticket_system.log',
'maxBytes': 1024 * 1024 * 10, # 10MB
'backupCount': 5,
'formatter': 'verbose',
},
},
'loggers': {
'tickets': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
},
}
Django Ninja 미들웨어로 대기열 검증 추가:
# core/middleware.py
from ninja import NinjaAPI
from ninja.errors import HttpError
from tickets.queue_service import WaitingQueueService
def queue_verification_middleware(get_response):
"""
대기열 검증 미들웨어
특정 엔드포인트 접근 시 활성 사용자인지 확인
"""
queue_service = WaitingQueueService()
def middleware(request):
# 예약 생성 요청인 경우에만 검증
if request.path.startswith('/api/reservations') and request.method == 'POST':
# concert_id 추출 (실제 구현에 맞게 조정)
user_id = request.user.id if request.user.is_authenticated else None
# 활성 사용자 확인
# concert_id는 요청 바디에서 추출 필요
# if user_id and not queue_service.is_active(concert_id, user_id):
# raise HttpError(403, "대기열에서 대기 중입니다.")
response = get_response(request)
return response
return middleware
부하 테스트 및 모니터링
실제 대규모 트래픽을 처리하기 전에 시스템을 검증하는 것이 중요합니다. Locust를 사용한 부하 테스트 시나리오를 작성합니다:
# locustfile.py
from locust import HttpUser, task, between
import random
class TicketBuyerUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
"""사용자 로그인 (테스트 시작 시)"""
self.client.post("/api/auth/login", json={
"username": f"user_{random.randint(1, 10000)}",
"password": "testpass123"
})
@task(3)
def view_concerts(self):
"""공연 목록 조회"""
self.client.get("/api/concerts")
@task(2)
def view_seats(self):
"""좌석 조회"""
concert_id = random.randint(1, 10)
self.client.get(f"/api/concerts/{concert_id}/seats")
@task(5)
def join_queue(self):
"""대기열 진입"""
concert_id = random.randint(1, 10)
response = self.client.post(
f"/api/concerts/{concert_id}/queue",
name="/api/concerts/[id]/queue"
)
if response.status_code == 200:
data = response.json()
self.token = data.get('token')
self.concert_id = concert_id
@task(4)
def check_queue_status(self):
"""대기열 상태 확인"""
if hasattr(self, 'token') and hasattr(self, 'concert_id'):
self.client.get(
f"/api/concerts/{self.concert_id}/queue/status?token={self.token}",
name="/api/concerts/[id]/queue/status"
)
@task(1)
def reserve_seat(self):
"""좌석 예매 시도"""
seat_id = random.randint(1, 1000)
self.client.post(
"/api/reservations",
json={"seat_id": seat_id},
name="/api/reservations"
)
# 실행 명령어:
# locust -f locustfile.py --host=http://localhost:8000
# 웹 UI: http://localhost:8089
성능 테스트 시나리오:
# 1. 단계별 부하 증가 테스트
# - 사용자: 100 -> 1000 -> 5000 -> 10000
# - Ramp up: 각 단계 1분
# 2. 스파이크 테스트
# - 0초: 100 사용자
# - 10초: 10000 사용자 (순간 폭증)
# - 30초: 100 사용자 (정상화)
# 3. 지속성 테스트
# - 5000 사용자로 1시간 지속
# - 메모리 누수, 커넥션 풀 고갈 체크
모니터링 대시보드 구성:
# tickets/monitoring.py
from prometheus_client import Counter, Histogram, Gauge
import time
# 메트릭 정의
reservation_attempts = Counter(
'reservation_attempts_total',
'Total number of reservation attempts',
['status']
)
reservation_duration = Histogram(
'reservation_duration_seconds',
'Time spent processing reservation',
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
)
waiting_queue_size = Gauge(
'waiting_queue_size',
'Number of users in waiting queue',
['concert_id']
)
active_users_count = Gauge(
'active_users_count',
'Number of active users',
['concert_id']
)
def track_reservation_metrics(func):
"""예매 메트릭 추적 데코레이터"""
def wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
reservation_attempts.labels(status='success').inc()
return result
except Exception as e:
reservation_attempts.labels(status='failure').inc()
raise
finally:
duration = time.time() - start_time
reservation_duration.observe(duration)
return wrapper
# 사용 예시
# tickets/services.py의 reserve_seat 메서드에 적용
# @track_reservation_metrics
# def reserve_seat(self, user_id: int, seat_id: int) -> Reservation:
# ...
결론 및 추가 개선 사항
이번 포스트에서는 Django Ninja를 활용하여 대규모 공연 티켓 예매 시스템을 구현하는 전략을 살펴보았습니다. 핵심 내용을 정리하면:
구현한 주요 기능
- 동시성 제어: Redis 분산 락과 DB 낙관적 락의 조합으로 Race Condition 방지
- 재고 관리: 원자적 업데이트(
F()표현식)로 Overselling 방지 - 대기열 시스템: Redis Sorted Set을 활용한 FIFO 순서 보장
- 비동기 처리: Celery로 만료 예약 정리 및 대기열 승격 자동화
- 성능 최적화: 커넥션 풀링, 캐싱, 인덱싱 전략
성능 지표
적절히 튜닝된 시스템에서 기대할 수 있는 성능:
- 처리량: 초당 1,000+ 예매 요청 처리
- 응답 시간: P95 < 500ms, P99 < 1s
- 동시 사용자: 10,000+ 명 동시 접속 가능
- 정확성: 100% 재고 정합성 보장
추가 개선 사항
실제 프로덕션 환경에서는 다음 사항들을 추가로 고려해야 합니다:
# 1. 캐싱 전략 강화
from django.core.cache import cache
def get_concert_with_cache(concert_id: int):
"""공연 정보 캐싱"""
cache_key = f"concert:{concert_id}"
concert = cache.get(cache_key)
if concert is None:
concert = Concert.objects.get(id=concert_id)
cache.set(cache_key, concert, timeout=300) # 5분
return concert
# 2. 읽기 복제본 활용
class ReservationService:
def get_available_seats(self, concert_id: int):
"""읽기 전용 쿼리는 복제본 사용"""
return Seat.objects.using('replica').filter(
concert_id=concert_id,
status='AVAILABLE'
)
# 3. Rate Limiting
from django_ratelimit.decorators import ratelimit
@ratelimit(key='user', rate='10/m', method='POST')
def create_reservation(request, data: ReservationRequest):
"""사용자당 분당 10회 예매 시도 제한"""
pass
# 4. 서킷 브레이커 패턴
from circuitbreaker import circuit
@circuit(failure_threshold=5, recovery_timeout=60)
def call_payment_api(reservation_id: int):
"""결제 API 호출 시 서킷 브레이커 적용"""
pass
아키텍처 확장
더 큰 규모로 확장하기 위한 방향:
- 샤딩: 공연별로 DB 샤딩하여 부하 분산
- CDN: 정적 리소스(좌석 배치도 등) CDN으로 제공
- MSA: 예매, 결제, 알림을 별도 마이크로서비스로 분리
- Event Sourcing: 예매 과정의 모든 이벤트를 저장하여 추적성 향상
- CQRS: 읽기/쓰기 모델 분리로 성능 최적화
마치며
티켓 예매 시스템은 단순해 보이지만 동시성, 정합성, 성능을 모두 만족시켜야 하는 어려운 과제입니다. Django Ninja의 빠른 성능과 Redis, Celery를 조합하면 엔터프라이즈급 시스템을 구축할 수 있습니다.
중요한 것은 점진적 개선입니다. 초기에는 간단한 락 메커니즘으로 시작하고, 트래픽이 증가하면서 대기열, 캐싱, 샤딩 등을 단계적으로 추가하는 것이 현실적인 접근 방식입니다.
본 포스트의 전체 코드는 GitHub 저장소에서 확인할 수 있습니다.