Django race condition 처리 방법

Hoonkii·2022년 5월 15일
1

문제

특정 게시물의 조회수를 센다거나, 은행 계좌의 잔고를 관리하는 어플리케이션을 로직을 개발한다고 가정해보자. 이런 경우에 원래 모델의 값을 읽어서, +1 을 한다거나 특정 값을 더해서 새로운 값을 업데이트한다.

Django에서 race condition을 고려하지 않고 단순하게 코드를 짜면 다음과 같이 짤 수 있다.

post = Post.objects.get(id=1)
post.view_count = post.view_count + 1
post.save()

----------
account = BankAccount.objects.get(id=1)
account.balance = account.balance + remittance_ammount
account.save()

위 코드는 race condition이 발생했을 때 큰 문제가 생길 수 있다.

은행 송금 시나리오를 예로 들겠다. 내 계좌 잔고는 100만원이고, 내 친구 해송이가 나에게 50만원을 송금하고, 나는 엄마에게 30만원을 동시에 송금한다고 가정하자.

시나리오에서 해송이가 나에게 송금하는 로직은 Thread1에서 실행되고, 내가 엄마에게 송금하는 로직은 Thread2에서 실행된다. 이 로직들이 거의 동시에 실행된다고 가정했을 때 해송이가 step 5에서 커밋한 값을 기반으로 Thread2에서 읽어서 값을 업데이트 해야 하지만, Thread2에서는 이미 처음 account 잔고인 100만원을 기반으로 값을 처리하기 때문에 해송이가 보낸 50만원이 사라지는 문제가 생긴다.

오늘은 Django에서 race condition을 어떻게 처리할 수 있는지 다룰 것이다.

해결 방안

F() 객체의 사용

이런 상황을 방지하게 위해 Django에서 제공하는 F()를 쓸 수 있다. F()객체는 모델 필드의 값을 나타낸다. F()를 쓰면 데이터베이스에서 파이썬 메모리로 데이터를 가져오지 않고, 호출하는 시점에 DB에 있는 모델 필드 값을 참조한다.

다음과 같이 쓸 수 있다.

from django.db.models import F

post = Post.objects.get(id=1)
post.view_count = F('view_count') + 1
post.save()

----------
account = BankAccount.objects.get(id=1)
account.balance = F('balance') + remittance_ammount
account.save()

F()를 통해 쿼리가 호출되는 시점에 DB에 있는 view_count, balance 값을 가져온다.
앞의 시나리오에서 Thread2에서 해송이가 돈을 보낸 시점의 내 잔고인 150만원을 가져와 30만원을 뺀 120만원을 내 계좌의 잔액으로 업데이트하게 된다.

실제 호출 쿼리를 보면 save되는 시점에 DB에서 값을 가져옴을 알 수 있다.

In [73]: bank_account.balance
Out[73]: 0

In [74]: bank_account.score = F('balance') + 1

In [75]: bank_account.save()
UPDATE "core_bankaccount"
   SET "balance" = ("core_bankaccount"."balance" + 500000)
				...
 WHERE "core_bankaccount"."id" = 1

위에서 (“core_bankaccount”.”balance” + 1) 문을 보면 해당 모델의 현재 값을 쿼리가 호출되는 시점에 가져오게 된다.

select_for_update()의 사용

상황에 따라서는 모델 자체의 동시 접근을 막아야할 수도 있다.

그럴 때는 Django에서 select_for_update()를 사용할 수 있다. select_for_update는 트랜잭션이 끝날 때까지 특정 row(모델)에 대한 lock을 잡는다.

from django.db import transaction

bank_account = BankAccount.objects.select_for_update().get(id=1)
with transaction.atomic():
		account.balance = account.balance + remittance_ammount

위 예제에서 bank_account는 트랜잭션 안에서 호출될 때 row lock이 잡히게 되고, 다른 트랜잭션에서는 동일한 bank_account에 접근하지 못한다.

select_for_update() 함수에는 nowait 라는 옵션이 있는데, nowait를 설정하지 않으면 다른 트랜잭션이 row lock을 잡고 있던 트랜잭션이 끝날 때 까지 기다리고, nowait=True를 설정하면 DatabaseError를 내뿜는다.

결론

race condition의 처리는 애플리케이션 데이터의 정합성을 유지하기 위해서 반드시 고려되어야 한다. 애플리케이션을 개발하면서 race condtion이 일어날 때를 대비해 적절한 방법을 잘 선택해야할 것이다.

참고 자료

profile
개발 공부 내용 정리

0개의 댓글