Published on

race condition 과 deadlock django에서 이해하고 해결방법 알아보기

웹 애플리케이션을 포함해 개발에 있어서 동시성 문제는 항상 중요하면서도 어려운 고려사항입니다.

특히 WAS(DRF)의 API를 개발할 때, race condition과 deadlock과 같은 문제를 만날 수 있습니다.

이 글에서는 이러한 문제들이 무엇인지, 어떤 상황에서 발생하는지, 그리고 어떻게 해결할 수 있는지 살펴보겠습니다.

1. Race Condition

Race condition은 여러 프로세스나 스레드가 공유 자원에 동시에 접근하려 할 때 발생하는 문제입니다.

(프로세스와 스레드가 접근-공유 하는 방식은 다르지만) 결과가 접근 순서에 따라 달라질 수 있어 예측 불가능한 동작을 초래할 수 있습니다.

1.1 Race Condition 예시

유저의 포인트 시스템을 예로 들어보겠습니다.

사실 += 는 개인적으로 선호하는 방식의 코드였는데, 이 방법은 여러 요청이 동시에 들어올 경우 race condition을 일으킬 수 있습니다.

class UserPointView(APIView):
    def post(self, request):
        user = request.user
        points_to_add = request.data['points']

        user.points += points_to_add
        user.save()

        return Response({"points": user.points})

1.2 Race Condition 해결 방법

Django의 F() 객체를 사용하여 이 문제를 해결할 수 있습니다.

F() 객체를 사용하면 연산 자체를 파이썬 메모리 단에서 수행하는 것이 아니라, 데이터베이스 수준에서 연산을 수행하게 되어 race condition을 방지할 수 있는 것 입니다.

from django.db.models import F

class UserPointView(APIView):
    def post(self, request):
        user = request.user
        points_to_add = request.data['points']

        User.objects.filter(id=user.id).update(points=F('points') + points_to_add)
        user.refresh_from_db()

        return Response({"points": user.points})

2. Deadlock

Deadlock은 여러개(두 개 이상)의 프로세스나 스레드가 서로가 가진 리소스를 기다리면서 내부적으로 데이터에 접근할 때 잠금기능이 동시에 발생하며 무한정 대기상태(주화입마)에 빠지게 됩니다.

데이터베이스에서의 '잠금(Lock)' 이 왜 일어나는지 그 이유를 알아야 합니다.

잠금은 데이터의 일관성과 무결성을 유지하기 위해서 일어나며, 한 트랜잭션이 데이터를 수정하는 동안 다른 트랜잭션이 동시에 같은 데이터를 수정하지 못하도록 '잠금'을 설정합니다.

잠금은 select_for_update()를 호출하는 시점에 이루어집니다. 즉, 데이터베이스에서 해당 레코드를 조회하면서 동시에 잠금을 설정합니다.

(참고로 잠금은 Django나 Python 레벨이 아닌, 실제 데이터베이스 시스템에서 잠금이 적용됩니다. → 행 단위 잠금 row-level locking 기능)

A 계좌에서 B 계좌로 송금 과 동시에 B 계좌에서 A 계좌로 송금 할때, Deadlock 이 걸리는 시나리오를 풀어서 설명하면 다음과 같습니다.

  1. 첫 번째 요청이 A 계좌를 잠급니다.
  2. 동시에, 두 번째 요청이 B 계좌를 잠급니다.
  3. 첫 번째 요청이 B 계좌를 잠그려 하지만, B는 이미 잠겨있어 대기합니다.
  4. 두 번째 요청이 A 계좌를 잠그려 하지만, A도 이미 잠겨있어 대기합니다.

두 요청 모두 서로가 잠근 계좌의 잠금이 풀리기를 서로 기다리게 됩니다. 이것이 Deadlock입니다.

2.1 Deadlock 예제

이 코드는 두 개의 동시 요청이 서로 다른 계좌를 먼저 접근하려고(잠그려고) 할 때 deadlock을 일으킬 수 있습니다.

class TransferView(APIView):
    def post(self, request):
        with transaction.atomic():
            account1 = Account.objects.select_for_update().get(id=request.data['from_id'])
            # 여기서 시간이 좀 걸리는 작업수행
            account2 = Account.objects.select_for_update().get(id=request.data['to_id'])

            # 송금 로직은 생략

        return Response({"message": "송금 완료"})

2.2 Deadlock 해결방법

계좌 ID를 정렬하여 항상 동일한 방식의 순서로 잠금을 진행해서 deadlock을 방지할 수 있습니다.

계좌를 정렬시켜줘서 로직적으로 잠기지 못하도록 한 것 입니다.

  1. 계좌 ID를 정렬하여 항상 낮은 ID의 계좌부터 잠급니다.
  2. 모든 송금 요청이 같은 순서로 계좌를 잠그므로 데드락이 발생하지 않습니다.
  3. 한 요청이 두 계좌를 모두 잠그는 동안, 다른 요청이 발생할 때 첫 번째 잠금상태에서 대기하여 Deadlock 을 피할 수 있는 것 입니다.
class TransferView(APIView):
    def post(self, request):
        with transaction.atomic():
            account_ids = sorted([request.data['from_id'], request.data['to_id']])
            account1 = Account.objects.select_for_update().get(id=account_ids[0])
            account2 = Account.objects.select_for_update().get(id=account_ids[1])

            # 송금 로직

        return Response({"message": "송금 완료"})

3. 데이터베이스 잠금 메커니즘 이해하기

Django의 select_for_update() 메서드는 내부적으로 데이터베이스의 SELECT ... FOR UPDATE 구문입니다.

with transaction.atomic():
    account = Account.objects.select_for_update().get(id=account_id)
    # 계좌 작업 수행

SQL 로 하면 다음과 같습니다.

BEGIN TRANSACTION;
SELECT * FROM accounts WHERE id = [account_id] FOR UPDATE;
-- 계좌 작업 수행
COMMIT;

SELECT ... FOR UPDATE는 다음과 같이 작동합니다

  1. 대기: 이미 다른 트랜잭션이 잠금을 보유 중이면 새 요청은 대기 큐에 들어갑니다.
  2. 해제: 트랜잭션이 완료(commit/rollback)되면 잠금이 해제되고, 대기 중인 다음 요청이 잠금을 획득합니다.

4. 도움이 되는 문서 링크

아래 링크들은 Django에서의 race condition과 deadlock 문제, 그리고 관련 데이터베이스 개념에 대해 직접적으로 다루고 있는 자료들입니다 :)

  1. Django 공식 문서
  2. DB단
  3. Django와 PostgreSQL에서의 명시적 테이블 잠금
  4. Race Condition 개념
  5. Deadlock 개념