Computer Engineering/Django

Django DB Transaction 1편 - Request와 DB Transaction 묶기(Feat. ATOMIC_REQUESTS)

jordan.bae 2022. 1. 1. 00:43

 

Introduction - Django DB Transaction

안녕하세요. 새해에는 Django와 관련된 글들을 많이 다뤄 보려고 합니다.

 

첫 번째로 Django에서 DB Transaction을 다루는 방법에 대해서 공부하고 글을 써보려고 합니다. 

총 아래와 같이 4편으로 나눠서 작성할 생각입니다. 

1편 Request와 DB Transction 묶기

2편 명시적으로 DB Transaction 활용하기
3편 Savepoint 살펴보기

4편 Transaction과 관련된 테스트 케이스 작성하기

 

대부분의 내용은 공식문서의 내용이고 약간의 이해를 돕기위해서 코드 샘플을 만들어서 진행해 보려고 합니다.

부족한 글이지만 도움이 되셨으면 좋겠습니다.

 

Django DB Transaction

Django에서는 데이테베이스의 트랜잭션을 control할 수 있는 몇 가지 방법을 제공합니다.

트랜잭션을 따로 control하는 설정이나 기능을 사용하지 않으면 기본적으로 autocommit mode로 동작합니다.

autocommit이 True인 경우는 각 쿼리에서 명시적으로 transaction을 시작하고 commit하지 않아도 됨을 의미합니다.

# autocommit=true 

delete from {table_name} where id=1


# 위의 쿼리는 아래처럼 동작합니다.

start transaction;

delete from {table_name} where id=1

commit;

 

Django에서 한 개이상의 쿼리를 트랜잭션으로 handling하는 방법은 크게 두 가지 입니다.

  • ATOMIC_REQUESTS 를 이용하여 request단위로 transaction을 묶는 방법
  • django.db.transaction 을 사용하여 명시적으로 transaction을 핸들링 하는 방법.

 

이번 글에서는 ATOMIC_REQUESTS를 이용해서 Request단위로 DB Transaction을 묶는 것에 대해서 알아보겠습니다.

 

 

Sample Code

예시 코드로는 간단하게 주식을 전달 할 수 있는 API를 만들었습니다. 1편 뿐만 아니라 2,3편에 쓰일 코드이니 한 번 보고 넘어가겠습니다.

아래 코드는 오직 transaction을 설명하기 위한 예시 코드로 인증이나 데이터의 validation등 대부분의 서비스에 필요한 로직은 모두 생략했습니다.

 

models.py은 아래와 같습니다.

from django.db import models
from django.contrib.auth.models import User


class Company(models.Model):
    ticker = models.CharField(max_length=16)
    name = models.CharField(max_length=64)


class Account(models.Model):
    owner = models.ForeignKey(User, on_delete=models.PROTECT, db_constraint=False)

    created_dt = models.DateTimeField(auto_now_add=True)
    updated_dt = models.DateTimeField(auto_now=True)

    def __repr__(self):
        return f"{self.owner} Account"


class Shares(models.Model):
    account = models.ForeignKey(Account, on_delete=models.PROTECT, db_constraint=False)
    company = models.ForeignKey(Company, on_delete=models.CASCADE, db_constraint=False)
    amount = models.PositiveBigIntegerField()

    created_dt = models.DateTimeField(auto_now_add=True)
    updated_dt = models.DateTimeField(auto_now=True)

    class Meta:
        unique_together = [['account', 'company']]


class SharesTransfer(models.Model):
    account_from = models.ForeignKey(
        Account, on_delete=models.PROTECT, db_constraint=False, related_name='account_from'
    )
    account_to = models.ForeignKey(
        Account, on_delete=models.PROTECT, db_constraint=False, related_name='account_to'
    )
    company = models.ForeignKey(Company, on_delete=models.PROTECT, db_constraint=False)
    amount = models.PositiveBigIntegerField()

    created_dt = models.DateTimeField(auto_now_add=True)
    updated_dt = models.DateTimeField(auto_now=True)

간단한게 django에서 제공하는 User 모델, Account, Company, Shares, SharesTransfer 테이블이 있습니다.

SharesTransfer은 어떤 계좌에서 어떤 계좌로 어떤 회사의 주식이 얼만큼 전송됐는지의 데이터를 저장하는 테이블입니다.

 

 

다음은view에서 사용하는 serializers.py의 코드입니다. 데이터를 받아서 저장하는 코드를 가지고 있습니다.

from django.db.models import F
from rest_framework import serializers

from .models import Shares, SharesTransfer


class SharesTransferSerializer(serializers.Serializer):
    # validation 은 생략
    account_from = serializers.IntegerField()
    account_to = serializers.IntegerField()
    company_id = serializers.IntegerField()
    amount = serializers.IntegerField()

    def create(self, validated_data, *args, **kwargs):
        # 보낸 사람의  주식을 줄인다.
        Shares.objects.filter(account_id=validated_data.get('account_from'), company_id=validated_data.get('company_id'))\
            .update(amount=F('amount') - validated_data.get('amount'))
        # 받은 사람의 주식을 늘린다.
        Shares.objects.filter(account_id=validated_data.get('account_to'), company_id=validated_data.get('company_id'))\
            .update(amount=F('amount') + validated_data.get('amount'))
        # 전송내역을 저장한다.
        transfer = SharesTransfer.objects.create(
            account_from_id=validated_data.get('account_from'),
            account_to_id=validated_data.get('account_to'),
            company_id=validated_data.get('company_id'),
            amount=validated_data.get('amount')
        )
        return transfer

 

controller인 view 코드는 아래와 같습니다.

from rest_framework import viewsets, permissions
from rest_framework.response import Response
from rest_framework.status import HTTP_201_CREATED

from .serializers import SharesTransferSerializer


class ShareTransferViewSet(viewsets.GenericViewSet):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = SharesTransferSerializer
    lookup_field = 'id'

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        serializer.save()
        return Response('success', status=HTTP_201_CREATED)

어려운 부분은 거의 없어서 자세한 설명은 생략할 생각입니다. 혹시, 궁금한 부분이 있으면 언제든지 편하게 댓글로 알려주세요!

API 요청을 받아서 serializer.save() 코드로 만들어준 create()함수를 호출합니다.(serializer의 save 메서드가 내부에서 create 메서드를 호출합니다.)

 

 

대표적인 transaction의 예시인 은행 송금 처럼 주식 전송 기능 또한 제 계좌에서 나갔으면 반드시 누군가의 계좌에 들어가야 하고 또한 기록이 남아야 합니다.

그래서 serializer의 create()함수는 꼭 하나의 transaction으로 묶여서 동작해야 합니다.

그럼 이제 위의 예시로 코드로 위에서 얘기한 Django에서 제공하는 transaction을 사용하는 두 가지 방법을 살펴보겠습니다. (다시 한 번 보시죠! 저희는 기억력이 짧으니깐요..ㅠ)

  • Django에서 한 개이상의 쿼리를 트랜잭션을 handling하는 방법은 크게 두 가지 입니다.
    • ATOMIC_REQUESTS 를 이용하여 request단위로 transaction을 묶는 방법
    • django.db.transaction 을 사용하여 명시적으로 transaction을 핸들링 하는 방법.

 

HTTP 요청과 transaction을 묶기.

이 방법은 꽤나 큰 범위로 transaction을 묶었다고 생각할 수도 있지만 어떻게 보면 가장 깔끔합니다. 예를 들어서, 마지막 response를 return하기 바로 전 라인의 코드에서 에러가 나서 사용자에게 500 Error를 return했는데 실제 주식 전송이 되었다면? 조금 많이 당황스러울 수 있습니다. 그래서 조금 무겁긴 하지만 web service에서 아주 신뢰성이 중요한 작업에서는 request하나를 atomic하게 다뤄야 할 수 있습니다.

각 Request를 DB Transaction으로 핸들링 하기 위해서 하는 가장 흔한 방법은 ATOMIC_REQUESTS를 True로 설정하는 방법입니다.

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DJANGO_DB_NAME', 'djangosample'),
        'USER': os.environ.get('DJANGO_DB_USERNAME', 'sampleuser'),
        'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'samplesecret'),
        'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'),
        'PORT': os.environ.get('DJANGO_DB_PORT', '5432'),
				 # 요기요. (저는 요기요를 자주 사용합니다. 쿠폰 후원좀..)
        'ATOMIC_REQUESTS': True
    }
}

위와 같이 database설정을 합니다.

현재 DB 데이터를 보면 아래와 같습니다. 1번 account는 1번 회사(amazon)의 주식을 98주 가지고 있고, 2번 account는 1번 회사(amazon)의 주식을 2주를 가지고 있습니다.

 

시원하게 1번이 2번에게 10주를 보내보겠습니다.

예상대로 주식 수가 잘 변경 됐고, transfer 내역도 잘 생성됩니다.

 

이제 한 번 ATOMIC_REQUESTS 설정이 잘 동작하는지 확인하기 위해서 아래처럼 raise()를 발생시켜봅니다.

class SharesTransferSerializer(serializers.Serializer):
    # validation 은 생략
    account_from = serializers.IntegerField()
    account_to = serializers.IntegerField()
    company_id = serializers.IntegerField()
    amount = serializers.IntegerField()

    def create(self, validated_data, *args, **kwargs):
        # 보낸 사람의  주식을 줄인다.
        Shares.objects.filter(account_id=validated_data.get('account_from'), company_id=validated_data.get('company_id'))\
            .update(amount=F('amount') - validated_data.get('amount'))
        # 받은 사람의 주식을 늘린다.
        Shares.objects.filter(account_id=validated_data.get('account_to'), company_id=validated_data.get('company_id'))\
            .update(amount=F('amount') + validated_data.get('amount'))
		
        # 테스트를 위해서 예외 발생시키기.
        raise('전송내역 저장 전에 에러 발생했지만 우린 request단위로 transaction을 묶었지!')
        
        # 전송내역을 저장한다.
        transfer = SharesTransfer.objects.create(
            account_from_id=validated_data.get('account_from'),
            account_to_id=validated_data.get('account_to'),
            company_id=validated_data.get('company_id'),
            amount=validated_data.get('amount')
        )
        return transfer

 

5주를 추가로 보냅니다.

5주 전송

예상대로 에러가 발생합니다.


하지만, 여전히 account1 은 88주를 보유 중입니다. transaction으로 묶어준 보람이 있네요!


아까 추가 해준 'ATOMIC_REQUESTS': True 주석 처리해보고 테스트 해봅니다.

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DJANGO_DB_NAME', 'djangosample'),
        'USER': os.environ.get('DJANGO_DB_USERNAME', 'sampleuser'),
        'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'samplesecret'),
        'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'),
        'PORT': os.environ.get('DJANGO_DB_PORT', '5432'),
        # 'ATOMIC_REQUESTS': True
    }
}


똑같은 request를 했을 때.. 저희가 예상한 것 처럼 account 1는88주에서 83주로 5주가 줄고, account 2는 12 주에 17주로 5주 늘었습니다. 그리고 예상대로 transfer내역은 쌓이지 않았습니다.. 실제 서비스라면 아주 큰 문제입니다.

지금 보신것 처럼 DB의 transaction은 정말 중요한 개념입니다. 방금 처럼 동작한다면 저의 주식 전송 서비스는 바로 신뢰를 잃고 망할 것 입니다. 아주 간단한 설정으로 request에 대한 handler의 response를 성공적으로 return하고 아니냐에 따라서 DB의 transaction을 처리할 수 있습니다. 

 

조금 더 구체적으로 동작 과정을 살펴보면

  1. view function을 호출하기 전에 DB에 “start transaction” statement를 실행 합니다.
  2. view function이 성공적으로 response를 만들면 DB에 “commit” statement 실행합니다.그렇지 않고 Exception이 발생하면 “rollback” statement를 실행합니다.

 

만약에 모든 view가 아닌 일부 view에만 적용하고 싶다면

-> ATOMIC_REQUEST를 False로 하고,. view에 @transaction.atomic() 데코레이터를 추가하면 됩니다.

 

반대로 일부 view만 적용하지 않는 것을 원하면

-> ATOMIC_REQUEST를 True로 하고,. view에 @transaction.non_atomic_requests() 데코레이터를 추가하면 됩니다.

 

주의 할 점

처음에 이야기 나눈것 처럼 Request단위로 DB Transaction을 다루는 것은 큰 단위입니다. 특히 다른 서비스(redis, rabbitmq 등등)의 장애가 DB에 영향을 줄 수도 있습니다. DB의 transaction을 오래 열고 있으면 DB에 많은 부하가 발생합니다. isolation level에 따라서 차이가 있지만 MySQL의 innoDB 같은 경우에는 많은 버젼의 view를 관리하기 위해서 undo log를 purge할 수 없습니다. 이에 따란 DB의 overhead를 주의해야 합니다.

 


정리

이렇게 Request 단위로 DB Transaction을 관리하는 방법을 살펴봤습니다. 이해를 돕기위해서 예제 코드를 통해서 살펴봤습니다. 다음편에서도 예제 코드는 사용될 예정입니다. 생각보다 글이 길어졌습니다. 여기 까지 봐주셔서 감사합니다. 위에서 살펴본 것 처럼 Transaction을 제대로 관리하지 않으면 서비스의 특성에 따라 큰 사고를 불러올 수 있습니다. 

 

Reference

https://docs.djangoproject.com/en/4.0/topics/db/transactions/

 

 



반응형