[Hot Item] #8. 선착순 구매 API v1 — 순수 RDBMS, 그리고 의도된 실패
이 글에서 다루는 내용
- 왜 v1부터 시작하는지 — 성능 개선을 수치로 증명하는 전략
- ItemSerializer, OrderSerializer 작성하기
- ItemListView, OrderCreateView 구현하기
- shop/urls.py와 config/urls.py 연결하기
- v1의 구조적 한계 — Race Condition이란 무엇인가
1. 왜 처음부터 Redis를 쓰지 않나요?
이 프로젝트의 목적 중 하나는 성능 개선 과정을 수치로 보여주는 것입니다.
처음부터 Redis와 Celery를 모두 적용한 완성형 코드를 작성하면 "얼마나 좋아졌는가"를 비교할 기준이 없습니다. 그래서 아래와 같은 단계를 의도적으로 나눠 진행합니다.
v1: 순수 RDBMS 조회 → 초과 판매 발생 (Race Condition 확인)
v2: DB 락(Pessimistic Locking) 적용 → 정합성 확보, 성능 측정
v3: Redis 대기열 + Lua Script → 처리량 개선, 성능 비교
지금은 v1입니다. 일부러 문제가 생기도록 만들고, 이후 단계에서 하나씩 개선합니다.
2. shop/serializers.py
from rest_framework import serializers
from .models import *
class ItemSerializer(serializers.ModelSerializer):
class Meta:
model = Item
fields = ('id', 'name', 'price', 'stock')
class OrderSerializer(serializers.ModelSerializer):
item_id = serializers.PrimaryKeyRelatedField(
queryset=Item.objects.all(),
source='item',
write_only=True
)
class Meta:
model = Order
fields = ('id', 'item_id', 'status', 'created_at')
read_only_fields = ('id', 'status', 'created_at')
ItemSerializer
상품 목록 조회에 사용합니다. id, name, price, stock 네 필드만 노출합니다.
OrderSerializer
주문 생성 요청과 응답을 처리합니다. item_id 필드 설계를 자세히 살펴봅니다.
item_id = serializers.PrimaryKeyRelatedField(
queryset=Item.objects.all(),
source='item',
write_only=True
)
- PrimaryKeyRelatedField: 클라이언트가 Item 객체 전체가 아닌 item_id(숫자 하나)만 보내도 Django가 해당 Item 인스턴스를 자동으로 조회해줍니다.
- source='item': 필드 이름은 item_id지만 내부적으로는 모델의 item 필드에 매핑됩니다.
- write_only=True: 요청 바디에서는 받지만 응답 JSON에는 포함하지 않습니다.
read_only_fields = ('id', 'status', 'created_at')으로 이 세 필드는 응답에만 포함되고, 클라이언트가 임의로 값을 넣어도 무시됩니다.
3. shop/views.py
from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.views import APIView
from django.db import transaction
from .models import *
from .serializers import *
class ItemListView(generics.ListAPIView):
queryset = Item.objects.all()
serializer_class = ItemSerializer
permission_classes = [AllowAny]
class OrderCreateView(APIView):
permission_classes = [IsAuthenticated]
from drf_spectacular.utils import extend_schema
@extend_schema(request=OrderSerializer, responses={201: OrderSerializer})
def post(self, request, *args, **kwargs):
serializers = OrderSerializer(data=request.data)
serializers.is_valid(raise_exception=True)
item = serializers.validated_data['item']
# v1: 일반적인 RDBMS 조회
# 예상 효과: 수천 명이 동시에 실행하면 모두가 stock > 0 이라고 판단 → 초과 판매 발생
if item.stock > 0:
item.stock -= 1
item.save()
order = Order.objects.create(
user=request.user,
item=item,
status=Order.Status.COMPLETED
)
return Response(
{"message": "주문이 완료되었습니다.", "order_id": order.id, "remain_stock": item.stock},
status=status.HTTP_201_CREATED
)
else:
return Response(
{"error": "재고가 소진되었습니다."},
status=status.HTTP_400_BAD_REQUEST
)
ItemListView
generics.ListAPIView를 상속하면 GET 요청으로 전체 상품 목록을 반환하는 기능이 자동으로 구현됩니다. AllowAny로 비로그인 사용자도 상품 목록을 조회할 수 있습니다.
OrderCreateView
주문 생성 핵심 로직입니다. 현재 v1의 흐름은 단순합니다.
1. 요청에서 item_id를 받아 Item 조회
2. stock > 0 이면 stock을 1 줄이고 저장
3. Order 레코드 생성 후 응답
@extend_schema는 drf-spectacular가 이 View의 요청/응답 스키마를 명확하게 인식하도록 도와주는 데코레이터입니다. APIView는 ModelSerializer를 자동으로 인식하지 못하기 때문에 명시해줍니다.
4. shop/urls.py와 config/urls.py 연결
shop/urls.py
from django.urls import path
from .views import *
urlpatterns = [
path('items/', ItemListView.as_view(), name='item-list'),
path('orders/', OrderCreateView.as_view(), name='order-create'),
]
config/urls.py
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/accounts/', include('accounts.urls')),
path('api/shop/', include('shop.urls')), # 추가
]
최종 API 엔드포인트는 다음과 같습니다.
메서드 URL 설명 인증
| GET | api/shop/items/ | 상품 목록 조회 | ✗ |
| POST | api/shop/orders/ | 주문 생성 | ✓ |
5. v1의 구조적 한계 — Race Condition
코드를 보면 주문 처리 흐름이 세 단계로 나뉩니다.
① DB에서 stock 읽기 (SELECT)
② stock > 0 확인 (Application 레벨 판단)
③ stock 감소 후 저장 (UPDATE)
단일 사용자 환경에서는 문제가 없습니다. 하지만 수천 명이 동시에 요청을 보내면 이런 상황이 생깁니다.
사용자 A: ① stock = 1 읽음
사용자 B: ① stock = 1 읽음 ← A가 아직 저장하기 전
사용자 A: ② stock > 0 → 구매 진행
사용자 B: ② stock > 0 → 구매 진행 ← 둘 다 통과
사용자 A: ③ stock = 0 저장
사용자 B: ③ stock = -1 저장 ← 초과 판매 발생
두 요청이 거의 동시에 stock = 1을 읽고, 둘 다 stock > 0이라고 판단해 구매를 진행합니다. 결과적으로 재고가 1개인 상품이 2개 팔리는 초과 판매(Overselling) 가 발생합니다.
이것이 Race Condition(경쟁 조건) 입니다. 두 스레드가 공유 자원(재고)을 동시에 읽고 수정하려 할 때 발생하는 고전적인 동시성 문제입니다.
v1은 이 문제를 의도적으로 내포하고 있습니다. 다음 포스트에서 부하 테스트 도구로 동시 요청을 발생시켜 실제로 초과 판매가 일어나는 것을 확인하고, DB 락으로 이를 해결하는 과정을 다룹니다.
6. 정리
오늘 한 작업을 요약하면:
- 성능 개선 과정을 수치로 비교하기 위해 순수 RDBMS 방식의 v1을 먼저 구현했습니다.
- PrimaryKeyRelatedField로 클라이언트가 item_id만 보내도 Item 인스턴스를 자동으로 조회하도록 설계했습니다.
- OrderCreateView는 DB 조회 → 재고 확인 → 저장의 단순한 흐름으로 구현했습니다.
- v1은 동시 요청 시 Race Condition으로 인한 초과 판매가 발생하는 구조적 한계를 가지고 있으며, 이를 다음 단계에서 개선합니다.
다음 포스트에서는 부하 테스트로 v1의 문제를 수치로 확인하고, select_for_update()를 이용한 Pessimistic Locking으로 동시성 문제를 해결한 v2를 구현합니다. 🚀