서비스를 운영하다보면 여러 사용자가 동시에 하나의 자원을 다루는 경우가 생긴다.
예를 들어 상품을 구매해서 재고가 -n개 줄어든다거나, 자리 예매를 한다거나의 상황이 있을 수 있다.
티켓팅처럼 2명 이상의 유저가 하나의 자원을 두고 경쟁하는 상태를 경쟁 상태라 한다.
오늘은 Django ORM의 기능인 F 객체로 경쟁 상태를 해소하는 방법에 대해 정리해보았다.
F 객체란?
장고 공식문서에 소개되어있듯이, F 객체를 사용하면 데이터를 직접 어플리케이션 메모리에 올리지 않고도 사용할 수 있다.
예를 들어 상품을 구매하고 재고를 1개 차감할때, 재고 데이터를 메모리에 직접 불러오지 않고 값을 -1 할 수 있는것이다.
django shell_plus를 사용해서 테스트해보았다.
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=50, null=False, blank=False, verbose_name='상품명')
price = models.PositiveIntegerField(null=False, blank=False, verbose_name='판매가')
quantity = models.IntegerField(null=False, blank=False, default=0, verbose_name='수량')
class Meta:
db_table = 'product'
>>> product = Product.objects.get(id=1)
SELECT "product"."id",
"product"."name",
"product"."price",
"product"."quantity"
FROM "product"
WHERE "product"."id" = 1
LIMIT 21
Execution time: 0.000374s [Database: default]
>>> product.quantity = F('quantity') - 1
>>> product.save()
UPDATE "product"
SET "name" = '테스트 상품',
"price" = 1000,
"quantity" = ("product"."quantity" - 1)
WHERE "product"."id" = 1
Execution time: 0.002619s [Database: default]
쿼리를 확인해보면 where 조건에 바로 quantity값을 활용하는것을 볼 수 있다. 파이썬 애플리케이션은 값을 전혀 모르고, 오직 DB단에서 값을 다루는것이다.
만약, F 객체가 아니라 직접 값을 -1 해준다면 아래와 같은 쿼리를 수행할 것이다. product.quantity였던 2에서 1을 뺀 1로 quantity를 업데이트 해주는것이다.
>>> product = Product.objects.get(id=1)
SELECT "product"."id",
"product"."name",
"product"."price",
"product"."quantity"
FROM "product"
WHERE "product"."id" = 1
LIMIT 21
Execution time: 0.000289s [Database: default]
>>> product.quantity = product.quantity - 1
>>> product.save()
UPDATE "product"
SET "name" = '테스트 상품',
"price" = 1000,
"quantity" = 1
WHERE "product"."id" = 1
Execution time: 0.002727s [Database: default]
이를 활용해, F 객체로 경쟁상태를 회피할 수 있다.
지금은 한 명의 유저가 재고를 차감하는 환경이지만, 실제 운영환경에서는 유저 여러명이 하나의 상품의 재고를 차지하려 할 것이다. 그럼 이러한 문제가 생긴다. 분산환경에서 요청 A, B가 거의 동시에 들어왔을때 예상하지 못한 결과가 나올 수 있는 것이다.
- 요청 A) product를 메모리에 불러온다.
- 요청 B) product를 메모리에 불러온다.
- 요청 A) 재고를 -1 차감한다.
- 요청 B) 재고를 -1 차감한다.
- 요청 A) update 쿼리 수행
- 요청 B) update 쿼리 수행
A와 B가 모두 재고를 차감했으니 재고는 -2해서 quantity가 2가 되어야 하지만, 실제로 결과는 3이 된다.
A, B 2개의 요청이 하나의 자원을 사용하려는 예시이지만, 실제 서비스에서는 N명 이상이 하나의 자원을 사용하려는 경우가 발생할 수 있다.
따라서, 이 이슈를 어느정도 회피하기 위해서는 값을 메모리로 불러와서 사용하는것이 아닌 UPDATE 쿼리에 바로 사용할 수 있도록 F 객체를 사용하면 된다.
실제로 멀티 프로세스를 띄워 테스트 해보면 좋은데 코드로 어떻게 작성해야할지 잘 모르겠어서 최대한 비슷한 상황으로 테스트 코드를 작성해보았다.
from django.test import TestCase
from django.db.models import F
from product.models import Product
class ProductQuantityTest(TestCase):
@classmethod
def setUpTestData(cls):
Product.objects.create(name='상품1', price=10000, quantity=10)
Product.objects.create(name='상품2', price=12000, quantity=100)
Product.objects.create(name='상품3', price=5000, quantity=25)
Product.objects.create(name='상품4', price=100000, quantity=192)
Product.objects.create(name='상품5', price=16000, quantity=0)
def test_sub_quantity_v1(self):
productA = Product.objects.get(id=1)
productA.quantity -= 1 # 9
productB = Product.objects.get(id=1)
productB.quantity -= 3 # 7
productA.save() # UPDATE product SET quantity = 9
productB.save() # UPDATE product SET quantity = 7
result = Product.objects.get(id=1)
self.assertEqual(result.quantity, 7)
def test_sub_quantity_v2(self):
productA = Product.objects.get(id=1)
productA.quantity = F('quantity') - 1
productB = Product.objects.get(id=1)
productB.quantity = F('quantity') - 3
productA.save() # UPDATE product SET quantity = quantity - 1
productB.save() # UPDATE product SET quantity = quantity - 3
result = Product.objects.get(id=1)
self.assertEqual(result.quantity, 6)
참고
https://docs.djangoproject.com/en/5.0/ref/models/expressions/#f-expressions
Query Expressions | Django documentation
The web framework for perfectionists with deadlines.
docs.djangoproject.com
'Django,Python' 카테고리의 다른 글
[Django] Django에서는 HTTP 요청을 어떻게 처리할까? (0) | 2024.06.21 |
---|---|
[Django] 동시성 고려하기(2) - optimistic lock 적용하기 (0) | 2024.06.10 |
[Django] factory_boy (0) | 2024.04.16 |
[Python] Pickle (0) | 2024.04.06 |
[Django] Custom Command 만들기 (0) | 2024.04.06 |