Django 재고 동시성: atomic()만으로 과판매를 막을 수 있을까?

재고가 1개 남은 상품에 주문 요청 두 건이 거의 동시에 들어옵니다. 두 요청이 각각 stock == 1을 읽고 1을 뺀 값을 저장하면, 화면에는 주문 두 건이 성공한 듯 보이는데 재고는 0으로 끝날 수 있습니다. 문제는 뺄셈 자체보다 “아직 1개가 있는가”라는 판단과 차감이 서로 다른 작업이라는 데 있습니다.

여기서는 Django ORM과 PostgreSQL의 기본 READ COMMITTED 격리를 전제로, 한 상품의 재고를 예약하는 경로를 설계합니다. 결제 승인이나 배송 확정까지 보장하는 예제는 아닙니다. 아래 코드는 공식 문서에 맞춰 작성한 문서 검토 예제이며 실제 실행·부하 측정은 하지 않았습니다. 적용 전에는 사용하는 Django·PostgreSQL 버전과 마이그레이션 상태에서 직접 검증해야 합니다.

마지막 재고 상자 하나를 향한 두 주문 경로가 하나의 관문에서 만나는 AI 생성 개념도
AI 생성 설명 이미지. 두 주문이 마지막 재고를 경쟁하는 상황을 개념적으로 표현했으며 실제 시스템 화면이나 테스트 결과가 아닙니다.

두 요청의 사이에서 무엇이 잘못되나

순진한 구현은 product = Product.objects.get(pk=...)으로 수량을 읽고, Python에서 product.stock -= 1을 한 다음 save()합니다. 요청 A와 B가 모두 차감 전의 1을 읽었다면 둘 다 예약을 생성할 수 있습니다. 각각의 코드를 transaction.atomic()으로 감싸도, 읽은 뒤 판단하고 다시 쓰는 순서만으로는 두 트랜잭션의 판단을 한 번으로 합치지 못합니다. Django 기본값은 요청 단위 트랜잭션이 아니라 autocommit이며, ATOMIC_REQUESTS는 별도 선택 설정입니다. Django 트랜잭션 문서

단일 상품의 수량이라는 좁은 불변식에는 조건을 읽기 코드가 아니라 하나의 UPDATE 문장 안에 넣는 편이 명확합니다. Django의 F()는 현재 DB 값을 기준으로 계산하는 SQL 표현식이고, QuerySet.update()는 갱신 SQL을 즉시 실행합니다. PostgreSQL의 READ COMMITTED에서 경쟁하는 UPDATE는 먼저 잡힌 행 잠금을 기다린 뒤, 선행 트랜잭션이 커밋하면 갱신된 행 버전에 대해 조건을 다시 평가합니다. 뒤 요청은 남은 재고가 0인 행에서 stock >= 1을 만족하지 못합니다. 이는 단일 행 조건부 차감에 대한 설계 판단이지 주문 전체의 원자성을 자동으로 얻는다는 뜻은 아닙니다. Django F 표현식 · PostgreSQL 격리 수준

# 조건부 UPDATE 핵심 부분 발췌 — 문서 검토 예제, 미실행
# Product와 OutOfStock 정의는 아래 서비스 전체 예제 참조
from django.db.models import F

changed = Product.objects.filter(
    pk=product_id,
    stock__gte=qty,
).update(stock=F("stock") - qty)

if changed != 1:
    raise OutOfStock("상품이 없거나 재고가 부족합니다")

changed == 1은 조건에 맞은 재고 행 하나가 갱신 대상이었다는 뜻입니다. update()의 반환값은 실제 값이 바뀐 행의 수가 아니라 매칭된 행의 수일 수 있습니다. 또한 update()는 모델의 save()나 pre_save/post_save 신호를 호출하지 않습니다. 따라서 재고 변경에 필요한 다른 불변식을 모델 save()에만 숨겨 두었다면 이 경로를 그대로 도입해서는 안 됩니다. Django QuerySet.update()

차감과 예약 행은 함께 끝나야 한다

재고만 줄이고 예약 행 생성이 실패하면 수량이 새어 나갑니다. 반대로 예약 행만 남기고 차감에 실패하면 팔 수 없는 예약이 됩니다. 다음 최소 모델은 두 변경을 하나의 트랜잭션에 넣고, 같은 사용자의 같은 요청 키가 두 번 주문을 만들지 못하도록 DB 유일성 제약을 둡니다. 재고에는 0 이상, 예약 수량에는 0 초과 제약을 둡니다. 실제 서비스에서는 계정 외래키, 취소·만료·복구 상태와 키 보관 기간을 추가 설계해야 합니다. Django 제약 조건

# stock/models.py — 문서 검토 예제, 미실행
from django.db import models
from django.db.models import Q


class Product(models.Model):
    stock = models.IntegerField(default=0)

    class Meta:
        constraints = [
            models.CheckConstraint(
                condition=Q(stock__gte=0), name="product_stock_nonnegative"
            ),
        ]


class Reservation(models.Model):
    account_id = models.BigIntegerField()
    idempotency_key = models.CharField(max_length=128)
    product = models.ForeignKey(Product, on_delete=models.PROTECT)
    qty = models.PositiveIntegerField()

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["account_id", "idempotency_key"],
                name="uniq_reservation_request",
            ),
            models.CheckConstraint(
                condition=~Q(idempotency_key=""),
                name="reservation_key_not_empty",
            ),
            models.CheckConstraint(
                condition=Q(qty__gt=0), name="reservation_qty_positive"
            ),
        ]

키를 단순히 get()으로 확인하고 없으면 생성하는 구현은 두 요청이 동시에 “없음”을 볼 수 있습니다. get_or_create()도 조회와 삽입 사이의 경합은 있지만, 조회 필드에 실제 DB 유일성 제약이 있을 때 동시 생성이 같은 행으로 수렴할 수 있습니다. 아래 함수는 키로 먼저 예약을 만들거나 기존 예약을 찾습니다. 동일 키로 다른 상품·수량이 들어오면 기존 성공으로 돌려주지 않고 충돌로 거절합니다. 다른 사용자의 키는 별도 범위입니다. Django get_or_create()의 원자성 전제

# stock/services.py — Django 6.1 문서와 PostgreSQL 18 문서 검토 예제, 미실행
from django.db import transaction
from django.db.models import F
from .models import Product, Reservation


class OutOfStock(Exception):
    pass


class RequestKeyConflict(Exception):
    pass


def reserve_stock(*, account_id, product_id, qty, key):
    if type(qty) is not int or qty <= 0:
        raise ValueError("수량은 양의 정수여야 합니다")
    if not isinstance(key, str) or not key.strip() or len(key) > 128:
        raise ValueError("요청 키를 확인해 주세요")

    with transaction.atomic():
        reservation, created = Reservation.objects.get_or_create(
            account_id=account_id,
            idempotency_key=key,
            defaults={"product_id": product_id, "qty": qty},
        )
        if not created:
            if reservation.product_id != product_id or reservation.qty != qty:
                raise RequestKeyConflict("이미 다른 내용으로 사용한 요청 키입니다")
            return reservation.pk

        changed = Product.objects.filter(
            pk=product_id,
            stock__gte=qty,
        ).update(stock=F("stock") - qty)
        if changed != 1:
            raise OutOfStock("상품이 없거나 재고가 부족합니다")

        return reservation.pk

새 예약을 먼저 만든 이유는 같은 키의 재시도를 DB 유일성으로 한곳에 모으기 위해서입니다. 재고가 부족해 OutOfStock이 블록 밖으로 나가면 새 예약과 수량 변경은 함께 롤백됩니다. 다만 예약 생성에 연결된 신호가 이메일 같은 외부 작업을 즉시 실행한다면 그 효과는 롤백되지 않습니다. 요청 키 재시도가 선행 트랜잭션을 기다린 뒤 기존 예약을 읽는 동작은 PostgreSQL 기본 격리와 DB 유일성 전제에 의존합니다. 주문이 결제 단계까지 확장된다면 “재고 예약 성공”과 “결제 성공”은 별도 상태로 다뤄야 합니다. Django는 atomic() 최외곽 블록을 커밋하거나 롤백하며, 내부 블록에는 저장점을 사용합니다. Django atomic() · PostgreSQL READ COMMITTED

여기서 예외를 삼킨 뒤 같은 깨진 atomic() 안에서 다시 조회하지 않는 것도 중요합니다. IntegrityError 같은 DB 오류를 처리해야 한다면 해당 atomic() 바깥에서 잡거나, 내부 저장점 경계를 명확히 분리합니다. 재고 부족처럼 예상 가능한 비즈니스 예외는 바깥 계층에서 적절한 응답으로 바꾸되, 무조건 재시도해서는 안 됩니다. Django의 atomic 예외 처리 안내

두 상품을 함께 잡아야 한다면

장바구니가 두 상품을 모두 확보해야만 성공하는 조건이라면 각 상품을 따로 커밋해서는 안 됩니다. 같은 트랜잭션 안에서 대상 상품을 항상 같은 PK 순서로 잠그고, 누락된 상품과 각 수량을 확인한 뒤 모두 차감합니다. select_for_update()는 쿼리셋을 실제 평가할 때 행 잠금을 얻고 트랜잭션 종료까지 유지합니다. 관련 행까지 조인해 잠글 필요가 없다면 잠금 범위도 살펴봐야 합니다. Django select_for_update()

# 다품목 핵심 절차 — 문서 검토용 의사코드, 그대로 실행하지 말 것
with transaction.atomic():
    # 요청에서 같은 상품이 중복되면 수량을 먼저 합산하고, 상품 PK를 정렬한다.
    ids = sorted(qty_by_product_id)
    products = list(
        Product.objects.select_for_update()
        .filter(pk__in=ids)
        .order_by("pk")
    )
    # 읽힌 상품 수가 ids 수와 같은지, 각 stock이 필요 수량 이상인지 확인한다.
    # 실패 시 예외를 블록 밖으로 보내 전체 트랜잭션을 롤백한다.
    # 모두 만족하면 각 상품을 조건부 UPDATE하고 예약 행을 함께 기록한다.

정렬은 여러 요청이 상품 A→B, B→A로 서로 다른 순서의 잠금을 잡는 데드락 위험을 줄이기 위한 규칙입니다. 다른 테이블이나 코드 경로가 다른 순서로 잠그면 데드락은 여전히 가능합니다. PostgreSQL은 데드락을 감지하면 한 트랜잭션을 중단합니다. 필요한 경우 **실패한 SQL 한 줄이 아니라 전체 트랜잭션**을 제한된 횟수만큼 재시도해야 하고, 그 블록 안에 외부 결제나 이메일 전송이 들어가면 재시도 때 중복 부작용이 생길 수 있습니다. PostgreSQL 데드락 안내

nowait=True는 기다리는 대신 충돌을 오류로 돌리고, skip_locked=True는 잠긴 행을 건너뜁니다. 후자는 작업 큐에는 쓸 수 있어도 판매 가능한 상품을 일시적으로 “없는 상품”처럼 처리할 수 있어 이 재고 예제의 기본 선택으로는 맞지 않습니다. 더 강한 SERIALIZABLE 격리 역시 직렬화 실패에 대한 전체 트랜잭션 재시도와 성능 검토가 필요합니다. 잠금 옵션 하나로 주문·결제·알림의 모든 정합성을 얻지는 못합니다. Django 잠금 옵션 · PostgreSQL 격리 문서

실행 전에 실패 경로부터 시험한다

다음은 실행 결과가 아니라 PostgreSQL에서 확인할 테스트 계획입니다. 한 스레드에서 함수를 두 번 부르는 테스트로는 마지막 재고의 경쟁을 재현할 수 없습니다. 서로 다른 DB 연결에서 두 요청을 동시에 시작하고, 커밋 뒤 수량과 예약 행을 함께 검사해야 합니다.

입력·상황문서상 기대할 상태추가 확인
재고 1, 서로 다른 키의 요청 2개예약 1건, 재고 0, 다른 요청은 재고 부족두 연결의 경쟁 구간과 최종 DB 상태
같은 키·같은 상품·같은 수량 재시도같은 예약 ID, 추가 차감 없음선행 요청 커밋 전·후 재시도
같은 키·다른 수량 또는 상품키 충돌, 기존 예약 불변API 응답과 클라이언트 재시도 정책
예약 생성 후 재고 부족예약 행 롤백, 재고 불변예외가 atomic 밖으로 전파되는지
두 상품을 반대 순서로 요청통일된 잠금 순서, 실패 시 전체 롤백다른 테이블 경로까지 포함한 데드락
커밋 뒤 알림 실패예약은 남고 알림은 별도 복구 대상중복·누락 전달 정책

Django의 TestCase는 테스트를 트랜잭션으로 감싸므로 select_for_update()가 명시적인 atomic() 없이도 통과하는 착시가 생길 수 있습니다. 잠금 동작을 확인하려면 TransactionTestCase와 PostgreSQL 백엔드, 별도 연결을 사용합니다. SQLite의 select_for_update()는 같은 잠금을 구현하지 않으므로 SQLite 통과를 PostgreSQL 동시성 증거로 삼을 수 없습니다. Django의 테스트·백엔드 주의사항

예를 들어 아래 테스트 뼈대는 두 작업자가 같은 출발점에서 서비스를 호출하게 합니다. CI의 PostgreSQL 테스트 DB에서 실행해야 하며, 여기서는 실행하지 않았습니다. 장벽은 출발 시점을 맞출 뿐 특정 SQL의 잠금 획득 순서를 보장하지 않습니다. 성공·실패 개수와 마지막 DB 상태를 함께 검사하는 이유가 여기에 있습니다.

# stock/tests/test_concurrency.py — 문서 검토 예제, 미실행
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from django.db import close_old_connections, connection, connections
from django.test import TransactionTestCase
from stock.models import Product, Reservation
from stock.services import OutOfStock, reserve_stock


class StockRaceTest(TransactionTestCase):
    def test_only_one_request_gets_last_unit(self):
        if connection.vendor != "postgresql":
            self.skipTest("PostgreSQL 잠금 경로만 검증")

        product = Product.objects.create(stock=1)
        start = Barrier(2)

        def attempt(key):
            close_old_connections()  # 각 스레드는 자신의 DB 연결을 사용
            try:
                start.wait(timeout=10)
                try:
                    reserve_stock(
                        account_id=1, product_id=product.pk, qty=1, key=key
                    )
                    return "reserved"
                except OutOfStock:
                    return "out_of_stock"
            finally:
                connections.close_all()

        with ThreadPoolExecutor(max_workers=2) as pool:
            results = list(pool.map(attempt, ("request-A", "request-B")))

        self.assertCountEqual(results, ["reserved", "out_of_stock"])
        product.refresh_from_db()
        self.assertEqual(product.stock, 0)
        self.assertEqual(Reservation.objects.count(), 1)

별도 테스트에는 같은 키 동시 재시도의 반환 ID 일치, 다른 내용의 키 충돌, 다품목 순서 역전, 예약 생성 후 강제 예외의 롤백도 넣어야 합니다. 실패를 모두 “재고 부족”으로 뭉개지 말고 DB 데드락·직렬화 실패·제약 위반을 구분해 기록합니다. PostgreSQL의 더 강한 격리 또는 여러 잠금이 들어가면 재시도 정책의 상한과 중복 외부 효과까지 검증 범위가 넓어집니다. PostgreSQL 격리 문서

재고 차감과 주문 레코드가 DB 안에서 함께 끝나는 것과, 커밋 후 작업이 반드시 전달되는 것은 다른 문제입니다. transaction.on_commit()은 커밋 후 콜백을 실행하고 롤백하면 버리지만, 콜백 실패가 이미 끝난 커밋을 되돌리지는 못합니다. 알림·작업 발행이 꼭 필요한 흐름은 기존 Django on_commit·아웃박스 글에서 설명한 전달 누락의 경계까지 따로 검토해야 합니다. Django on_commit()

어디까지 이 설계를 가져갈까

단일 행의 재고 수량만 지키려면 조건부 UPDATE와 변경 대상 행 수 분기가 출발점입니다. 요청 재전송이 있다면 DB 유일 키와 “같은 키·다른 내용” 처리까지 필요합니다. 여러 상품이나 여러 테이블의 불변식을 함께 지켜야 할 때에야 잠금 순서와 더 넓은 트랜잭션을 검토합니다. 반면 주문이 외부 결제·장시간 작업을 기다린다면 그 대기 시간을 DB 잠금 안에 넣는 설계는 보류해야 합니다.

첫 적용 전 확인할 것은 하나입니다. 실제 쓰기 경로가 모두 같은 재고 조건과 DB 제약을 통과하는지 추적해 보세요. 관리자 수정·취소 복구·배치 작업 하나가 stock을 무조건 덮어쓴다면, 주문 API의 동시성 코드만 고쳐서는 불변식이 완성되지 않습니다.

댓글