Django의 트랜잭션 Lock - SW의 Lock과 DB의 Lock 정리
1. SW의 Lock과 DB Lock 정의
- Software의 Lock은 Django 기준으로 Database에 Query를 요청하기 전, 후로 Lock을 사용해 race condition의 문제를 방지한다.
- 단점으로 문제 발생으로 인한 종료 및 재시작시 Lock 상태가 손실된다.
- Lock 상태를 계속 추적하여 지속적인 처리를 유지해야 하는 경우는 적절하지 않다.
- 게시판에 글을 쓰는 작업 등의 문제 발생시 다시 처리를 요구해도 문제가 없는 경우 사용할 수 있다.
- 기능적인 문제보다 정책적인 문제로 중요성이 다뤄질 수 있으며, 중간 저장 등의 방법으로 사용자의 문제를 개선할 수 있다.
- Databse의 Lock의 Django 기준으로 Databse에 요청한 Query가 도착한 시점부터 Lock이 실행되어 race condition의 문제를 방지한다.
- 장점으로 문제가 발생하여 Django가 종료 및 재실행 되더라도 Lock 상태가 유지된다.
- 단점으로 Databse에서 제공하는 Lock 중 어떤 것을 사용하는지에 따라 Lock 상태가 계속 유지되어 Databse의 row, table 단위의 지속적인 접근 제한이 유지되어 심각한 문제가 발생될 수 있다.
- SW Lock보다 유연하지 못하고 처리속도도 낮다. 하지만 장비의 스케일업과 config 설정으로 개선될 수 있다.
- 결제와 같이 처리 과정의 원자적 보호가 유지되어야 하는 경우 사용이 적합하다.
2. Django의 ORM 함수 중 Lock을 사용하는 함수들
1) select_for_update()
- select_for_update() 메서드는 트랜잭션 내에서 행(row)에 대한 잠금을 얻어, 선택된 행이 트랜잭션이 완료될 때까지 다른 트랜잭션에서 그 행을 수정하지 못하게 한다. 이는 "SELECT ... FOR UPDATE" SQL 문을 사용하여 구현된다.
from django.db import transaction
# 트랜잭션 블록 안에서 사용
with transaction.atomic():
# select_for_update()를 사용해 특정 객체 잠금
my_object = MyModel.objects.select_for_update().get(id=1)
# 이제 my_object를 안전하게 수정할 수 있음
my_object.some_field = '새로운 값'
my_object.save()
2) select_for_update(skip_locked=True)
- select_for_update()는 skip_locked 옵션도 제공한다. 이 옵션은 현재 다른 트랜잭션에 의해 잠긴 행을 건너뛰고, 잠금을 획득할 수 있는 행만 선택한다.
with transaction.atomic():
# 잠긴 행을 건너뛰고 잠금을 획득할 수 있는 행만 선택
rows = MyModel.objects.select_for_update(skip_locked=True).filter(some_field='value')
for row in rows:
# 처리 로직
pass
3) select_for_update(nowait=True)
- select_for_update(nowait=True) 옵션을 사용하면, 잠금을 얻을 수 없을 때 즉시 실패하고 django.db.utils.OperationalError 예외를 발생시킨다. 이는 잠금 대기 시간을 피하고자 할 때 유용할 수 있다.
with transaction.atomic():
try:
obj = MyModel.objects.select_for_update(nowait=True).get(pk=1)
# 업데이트 로직
except django.db.utils.OperationalError:
# 잠금을 얻을 수 없는 경우의 처리 로직
4) F() 객체
- F() 객체를 사용하면, 데이터베이스 레벨에서 필드 값을 직접적으로 수정할 수 있다. 이 방법은 레이스 컨디션을 방지하는 데 도움이 될 수 있다.
from django.db.models import F
# F() 객체를 사용해 데이터베이스 레벨에서 필드 값을 업데이트
MyModel.objects.filter(id=1).update(some_field=F('some_field') + 1)
5) update()
- update() 메서드는 쿼리셋의 모든 객체에 대해 지정된 필드 값을 업데이트한다. 이 메서드는 단일 SQL 문을 사용하여 대량의 업데이트를 수행하기 때문에 효율적이다. 하지만, update()는 모델의 save() 메서드를 호출하지 않기 때문에, pre_save나 post_save 시그널을 트리거하지 않는다.
# update()를 사용해 모든 객체에 대한 특정 필드 업데이트
MyModel.objects.filter(some_field='기존 값').update(some_field='새로운 값')
6) version or updated_at field 사용으로 낙관적 접근으로 동시성 해결 방법
- version 필드 혹은 updated_at 필드를 사용하여 update시 해당 필드가 변경 사항이 없다면, 수행하는 방식으로 동시성을 해결할 수 있다.
- django-concurrency 라이브러리에서 다양한 version field를 제공하고 있다.
- filter를 이용해 처음 필드 정보를 가져올 때 확인한 version을 다시 넣어 update할 필드에 접근 시도를 하면 version이 같을 경우 필드가 검출이되어 update가 가능해지고 version이 다르다면 update가 불가능해진다.
def reserve(self):
updated = Aircraft.objects.filter(
id=self.id,
version=self.version,
).update(
remaining_seat=remaining_seat-1,
version=self.version+1,
)
return updated > 0
3. 잠금과 동시성 처리를 위한 추가 팁
- 잠금을 사용할 때는 항상 transaction.atomic() 컨텍스트 매니저를 사용하여 변경 사항을 트랜잭션 안에서 처리하는 것이 좋다.
- select_for_update()는 transaction.atomic() 블록 내에서만 사용해야 한다.
- 트랜잭션 내에서 commit 또는 rollback으로 데이터 무결성 작업을 수행할 수 있지만, 트랜잭션 외부에서는 작업이 완료되지 않았음에도 레코드 잠금이 즉시 해제되어 다른 프로세스에서 해당 레코드를 수정할 수 있게 된다. 이로 인해 무결성 문제가 발생될 수 있다.
- 잠금이 필요 없는 간단한 업데이트에는 F() 객체와 update() 메서드를 사용하는 것이 좋다.
4. django-lockmgr(Software Lock) django-db-lock(Database Lock)
- Software Lock : 메모리 상에서 락을 관리하는 방식으로, 프로세스 간 락 공유가 가능하다. 대표적인 예로 django-lockmgr, django-db-locking 등이 있다.
- django-lockmgr url : https://pypi.org/project/django-lockmgr/
- 사용 방법 :
- 시나리오: 사용자가 게시글을 작성하는 동안 다른 사용자가 동일한 게시글을 편집하지 못하도록 락을 건다.
pip install django-lockmgr
# models.py
from django.db import models
from django_lockmgr.lockmgr import LockMgr
class Post(models.Model):
title = models.CharField(max_length=100)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# 락 관리를 위한 LockMgr 인스턴스 생성
lock_mgr = LockMgr()
# views.py
from django.shortcuts import get_object_or_404, redirect, render
from .models import Post
def edit_post(request, post_id):
post = get_object_or_404(Post, id=post_id)
# 락 획득
with post.lock_mgr.lock():
if request.method == 'POST':
# 게시글 수정 로직
post.title = request.POST['title']
post.content = request.POST['content']
post.save()
return redirect('post_detail', post_id=post.id)
else:
return render(request, 'edit_post.html', {'post': post})
- with post.lock_mgr.lock() : 블록 내에서 락이 획득되며, 블록 종료 시 자동으로 락이 해제된다.
- 락이 이미 획득된 경우 : LockMgr는 블록 진입을 차단하여 다른 사용자의 편집을 방지한다.
- DB Lock : 데이터베이스 수준에서 락을 관리하는 방식으로, 데이터베이스 트랜잭션을 활용한다. 대표적인 예로 django-db-lock, django-mysql 등이 있다.
- django-db-lock url : https://pypi.org/project/django-db-lock/
- 사용 방법 :
- 시나리오: 사용자가 주문을 생성할 때 동시에 다른 사용자가 동일한 상품을 주문하지 못하도록 락을 건다.
pip install django-db-lock
# settings.py
INSTALLED_APPS = [
# ...
'django_db_lock',
]
# models.py
from django.db import models
from django_db_lock.models import NonBlockingLock
class Product(models.Model):
name = models.CharField(max_length=100)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.PositiveIntegerField()
# 락 관리를 위한 NonBlockingLock 상속
class Lock(NonBlockingLock):
pass
# views.py
from django.shortcuts import get_object_or_404, redirect, render
from .models import Product
from django_db_lock.models import lock
def create_order(request, product_id):
product = get_object_or_404(Product, id=product_id)
# 락 획득
with lock(Product.Lock, product.id):
if product.stock > 0:
# 주문 생성 로직
product.stock -= 1
product.save()
return redirect('order_list')
else:
return render(request, 'out_of_stock.html')
- with lock(Product.Lock, product.id) : 블록 내에서 락이 획득되며, 블록 종료 시 자동으로 락이 해제된다.
- 락이 이미 획득된 경우 : NonBlockingLock는 블록 진입을 차단하여 다른 사용자의 주문을 방지한다.
- reference :
- transaction에 대한 정리
https://velog.io/@combi_jihoon/Transaction-django-transaction.atomic-%EC%82%AC%EC%9A%A9
https://chrisjune-13837.medium.com/django-row-lock-%EB%8F%99%EC%9E%91%EB%B0%A9%EC%8B%9D-a2e05bb0eb90