Hot Item

[Hot Item] #8. 선착순 구매 API v1 — 순수 RDBMS, 그리고 의도된 실패

devjingood 2026. 3. 25. 18:00

이 글에서 다루는 내용

  • 왜 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를 구현합니다. 🚀