Computer Engineering/Django

Django DB Transaction 2편 - 명시적으로 transaction 활용하기. (feat. savepoint)

jordan.bae 2022. 1. 17. 20:58

Transaction!

 


Introduction

안녕하세요. 1편 Django Transaction(트랜잭션) 1편 - Request와 DB Transaction 묶기(Feat. ATOMIC_REQUESTS)

 

Django Transaction(트랜잭션) 1편 - Request와 DB Transaction 묶기(Feat. ATOMIC_REQUESTS)

Introduction - Django DB Transaction 안녕하세요. 새해에는 Django와 관련된 글들을 많이 다뤄 보려고 합니다. 첫 번째로 Django에서 DB Transaction을 다루는 방법에 대해서 공부하고 글을 써보려고 합니다...

blog.doosikbae.com

에 이어서 2편으로 명시적으로 Django에서 DB transaction을 활용하는 방법에 대해서 포스팅해보려고 합니다.

1편에서는 전체적으로 Django에서 DB Transaction을 어떻게 사용하는지와 HTTP Request단위로 DB Transaction을 어떻게 묶는지를 살펴봤었습니다.

1편 내용을 조금 더 살펴보면 Django에서는 default로 autocommit=1 로 설정되어 있고, ATOMIC_REQUEST 변수나 django.db.transaction으로 transaction을 관리했었습니다. 그 중에서 ATOMIC_REQUEST가 True이면 HTTP Request Handler가 시작될 때 transaction이 시작되서 response를 성공적으로 보내고 Commit을 하게 되어 HTTP의 응답과 DB Transaction이 묶여서 동작하는 것을 살펴봤습니다.

2편에서는 Django 에서 DB Transaction을 명시적으로 시작하고, 트랜잭션 안에서 savepoint를 사용해서 좀 더 작은 작업 단위로 묶어서 처리하는 방법에 대해서 살펴볼 예정입니다. 특히, savepoint과 관련된 부분은 예제코드와 실제 Raw SQL이 어떻게 실행되는지 까지 살펴보도록 하겠습니다.

주의! 이 글에서 DB는 MySQL 5.7 기준입니다.


명시적으로 Transaction 활용하기

Django에서는 DB Transaction을 관리하기 위한 하나의 API를 제공합니다.

atomic(using=None, savepoint=True, durable=False)

 

atomic는 해당 block안에서 데이터베이스의 transaction을 시작하고 commit해주거나 exception이 발생하면 rollback 해주는 역할을 합니다.

매개 변수를 살펴보면 아래와 같습니다.

  • using: database를 선택하는 매개 변수. (명시하지 않으면 ‘default‘ db를 사용하게 됩니다.)
  • savepoint: transaction안에서의 일부분만 rollback이 가능하게 해주는 statement입니다. savepoint를 사용할지 여부. (이 글 아랫부분에서 다룹니다.)
  • durable: transaction이 가장 outer에 있어야 할 경우 사용합니다. durable=True인 경우 atomic block이 다른 atomic block안에 nested 되면 RuntimeError를 발생시켜서 가장시켜서 가장 바깥쪽에 있도록 보장합니다.

 

atomic을 사용하는 방법은 크게 두 가지 입니다.

  • context manager
  • decorator
# context manager 형식
		
def create(self, request, *args, **kwargs):
    serializer = self.get_serializer(data=request.data)
    serializer.is_valid(raise_exception=True)

    with transaction.atomic():
        serializer.save()
    raise Exception('데이터는 저장되었지만, Controller 에러')
    return Response('success', status=HTTP_201_CREATED)


# decoraotr 형식

@transaction.atomic()
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(django에서 view)나 service layer에서 처리해주는 것을 선호합니다. 왜냐하면 어떤 함수를 가져다 사용할 때 매번 체크하기 어렵기 때문에 service layer든 controller든 한 곳에서 관리하는 것을 선호하기 때문입니다.

 

예제를 통해 동작 살펴보기

context manager를 사용해서 controller에서 테스트를 해보겠습니다.

대부분의 코드는 1편에서 사용했던 코드들입니다. 혹시, 코드가 갑자기 나와서 이해가 안되신다면 1편을 가볍게 읽고 오시는 것을 추천드립니다.

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)

        with transaction.atomic():
            serializer.save()
        raise Exception('데이터는 저장되었지만, Controller 에러')
        return Response('success', status=HTTP_201_CREATED)

위와 같이 코드를 변경해서 serializer.save() 이 부분만 transaction으로 묶어주었습니다.

djangoresestframework에서 지원하는 Web GUI를 이용해서 Request를 보냅니다.

예상대로 아래와 같이 exception이 raise되었습니다. 그리고 Response는 에러 메시지가 왔지만 데이터베이스에는 모두 잘 저장 되었습니다. 다행인 점은 3개의 쿼리가 하나의 트랜잭션으로 묶였기 때문에 각 테이블간의 데이터의 integrity는 깨지지 않았습니다.

 

trading_sharestransfer테이블

trading_shares 테이블

 

첫 번째 Posting글과는 다르게 Http Request의 handler자체를 트랜잭션으로 묶지 않아서 사용자에게 보여지는 integrity는 깨질 수 있습니다. 하지만, 최소한의 DB 테이블 간의 데이터 정합성은 지켰습니다. DB Transaction의 원래 목적은 데이터의 정합성을 지키는 일입니다. HTTP 요청 핸들러 자체를 트랜잭션으로 묶는것도 단점이 많이 있기 때문에(1편에서 다뤘었음) 이렇게 DB 테이블이나 다른 데이터 저장소간에 정합성을 지키면서 transaction을 작게 가져가는 방법도 있습니다. 사실 다른 부분에서 exception이 발생하는 경우는 테스트코드나 Sentry같은 에러 모니터링 서비스로 바로 발견해서 고칠 수 있기 때문에 상황에 맞게 사용하는 것이 좋다고 생각합니다.

 


Nested block으로 Savepoint 활용하기

transaction은 nested 방식으로도 활용할 수 있습니다.즉, transaction blcok에서 또 transaction block을 생성 할 수 있습니다.

예시 코드로 ATOMIC_REQUEST=True로 하고, controller안에서 transaction을 또 하나 사용합니다.

즉, ATOMIC_REQUEST로 인한 Transaction안에서 또 하나의 transaction block이 하나 더 있습니다.

# setting.py
DATABASES = {
	'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': os.environ.get('MYSQL_DJANGO_DB_NAME', 'djangosample'),
        'USER': os.environ.get('DJANGO_DB_USERNAME', 'root'),
        'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'example'),
        'HOST': os.environ.get('MYSQL_DJANGO_DB_HOST', 'localhost'),
        'PORT': os.environ.get('DJANGO_DB_PORT', '3306'),
        'ATOMIC_REQUESTS': True
    },
}
# views.py
    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        with transaction.atomic():
            serializer.save()
        raise Exception('데이터는 저장되었지만, Controller 에러')
        return Response('success', status=HTTP_201_CREATED)

 

이번에는 위에 요청에서 조금 변경하여 2번 account에서 1번 account로 11주를 전송해보는 테스트를 해보면 예상대로 에러가 발생합니다.

과연 데이터들은 변경이 되었을까요?

먼저, 실제 실행되는 SQL을 살펴보겠습니다. 

17 Query	SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED
17 Query	SET autocommit=0
17 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-01-14 13:18:56.430715' AND `django_session`.`session_key` = '7wp4h1d3porowvfdewfldqskiiucyh4r') LIMIT 21
17 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1 LIMIT 21
17 Query	SAVEPOINT `s140426989328128_x1`
17 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` - 11) WHERE (`trading_shares`.`account_id` = 2 AND `trading_shares`.`company_id` = 1)
17 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` + 11) WHERE (`trading_shares`.`account_id` = 1 AND `trading_shares`.`company_id` = 1)
17 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (2, 1, 1, 11, '2022-01-14 13:18:56.442145', '2022-01-14 13:18:56.442190')
17 Query	RELEASE SAVEPOINT `s140426989328128_x1`
17 Query	ROLLBACK
17 Query	SET autocommit=1

autocommit=0으로 Set되고 Commit이 되지 않았기 때문에 아무런 데이터의 변경도 적용되지 않습니다.

즉, outer에 있는 transaction이 끝나기 전에 exception이 발생하면 inner에 transaction block이 잘 수행되더라도 반영되지 않습니다. SAVEPOINT statement는 조금 뒤에서 살펴보겠습니다.

 

이번에는 ATOMIC_REQUESTS가 0이 었을 때를 먼저 한 번 살펴보면 아래와 같습니다.

SET automcommit=0 statement가 auth_user select 쿼리 밑에서 실행됩니다.

-> 참고로 맨위에는 장고에서 connection을 생성할 때 transaction isolation level 을 설정 하는 부분입니다. (Django 에서 connection을 맺을 때 마다 실행됩니다.)

13 Query	SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED
13 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-01-14 13:13:06.559760' AND `django_session`.`session_key` = '7wp4h1d3porowvfdewfldqskiiucyh4r') LIMIT 21
13 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1 LIMIT 21
13 Query	SET autocommit=0
13 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` - 5) WHERE (`trading_shares`.`account_id` = 2 AND `trading_shares`.`company_id` = 1)
13 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` + 5) WHERE (`trading_shares`.`account_id` = 1 AND `trading_shares`.`company_id` = 1)
13 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (2, 1, 1, 5, '2022-01-14 13:13:06.578168', '2022-01-14 13:13:06.578217')
13 Query	COMMIT
13 Query	SET autocommit=1

 

두 개의 상황에서의 각각 Raw SQL을 살펴보면 가장 바깥에 있는 transaction block은 SET autocommit=0을 실행하지만, inner transaction block은 SAVEPOINT statement를 시작하는 부분이라는 것을 알 수 있습니다.

 

SAVEPOINT에 대해서 더 자세히 알아보기 위해서 공식 문서의 예제인 outer 부분이 아닌 inner부분에서 exception이 발생했을 때 inner block에서 try, exception구문을 사용해서 outer block의 다른 부분들을 잘 반영할 수 있는 예제를 살펴보겠습니다.

from django.db import IntegrityError, transaction

@transaction.atomic
def viewfunc(request):
    create_parent()

    try:
        with transaction.atomic():
            generate_relationships()
    except IntegrityError:
        handle_exception()

    add_children()

여기서 inner transaction block에서 예외가 발생하지만 create_parent()와 add_children()는 잘 DB에 반영이 됩니다. 

어떻게 안쪽에 있는 transaction block에 있는 SQL들만 Rollback이 될 수 있을까요? 그건 바로 저희가 Raw SQL을 살펴봤을 때 봤던 savepoint statement를 사용하였기 때문입니다. 자세히 살펴본 분들은 파악하셨을 수도 있겠지만 atomic block nested되어 있을 때는 savepoint statement가 실행됩니다.

17 Query	SAVEPOINT `s140426989328128_x1`
17 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` - 11) WHERE (`trading_shares`.`account_id` = 2 AND `trading_shares`.`company_id` = 1)
17 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` + 11) WHERE (`trading_shares`.`account_id` = 1 AND `trading_shares`.`company_id` = 1)
17 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (2, 1, 1, 11, '2022-01-14 13:18:56.442145', '2022-01-14 13:18:56.442190')
17 Query	RELEASE SAVEPOINT `s140426989328128_x1`

savepoint를 통해 전체 transaction이 아닌 savepoint 이후의 부분만 rollback을 할 수 있습니다. 위와 같은 예를 보기 위해서 예시를 하나 들어보겠습니다. view 코드를 아래처럼 변경했습니다.

 

# ATOMIC_REQUESTS
'ATOMIC_REQUESTS': True

# view 코드
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)

        with transaction.atomic():
            serializer.save()
        try:
            with transaction.atomic():
                # dummy query for example
                SharesTransfer.objects.create(amount=1, account_from_id=1, account_to_id=2, company_id=1)
                raise Exception('savepoint를 이용해서 일부분은 반영이 되었음!')
        except Exception as e:
            print(e)
        return Response('success', status=HTTP_201_CREATED)

그리고 1번 account에서 2번 account로 1번 company주식을 1주 보내겠습니다.


저희의 예상대로면 아래와 같은 순서로 쿼리가 실행될 것 입니다.

  1. request handler가 시작되면 새로 connection을 맺으면서 isolation level 설정.
  2. ATOMIC_REQUESTS = True이기 때문에 SET autocoomit=0실행
  3. 기타 인증 관련된 쿼리
  4. 첫번 째 atomic block에서 savepoint 구문이 실행.
  5. 2개의 업데이트문과 1개의 insert문이 실행.
  6. release savepoint 구문 실행
  7. 두번 째 atomic block에서 savepoint 구문이 실행.
  8. dummy 쿼리 실행
  9. ROLLBACK TO SAVEPOINT 구문 실행
  10. 두번 째 savepoint에 대한 release구문 실행.
  11. commit
  12. set commit=1

 


결과를 확인해보면 아래와 같습니다.

45 Query	SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED
45 Query	SET autocommit=0
45 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-01-17 03:03:11.610777' AND `django_session`.`session_key` = 'tckamdxe8wb18ckng212s66lwu9sae23') LIMIT 21
45 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1 LIMIT 21
45 Query	SAVEPOINT `s139971393275648_x1`
45 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` - 1) WHERE (`trading_shares`.`account_id` = 1 AND `trading_shares`.`company_id` = 1)
45 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` + 1) WHERE (`trading_shares`.`account_id` = 2 AND `trading_shares`.`company_id` = 1)
45 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (1, 2, 1, 1, '2022-01-17 03:03:11.620751', '2022-01-17 03:03:11.620872')
45 Query	RELEASE SAVEPOINT `s139971393275648_x1`
45 Query	SAVEPOINT `s139971393275648_x2`
45 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (1, 2, 1, 1, '2022-01-17 03:03:11.623168', '2022-01-17 03:03:11.623208')
45 Query	ROLLBACK TO SAVEPOINT `s139971393275648_x2`
45 Query	RELEASE SAVEPOINT `s139971393275648_x2`
45 Query	COMMIT
45 Query	SET autocommit=1

또, 2번째 Inner atomic block은 실패했지만 바깥부분의 query와 첫번째 atomic block 쿼리는 잘 수행된 것을 확인할 수 있습니다.

혹시, savepoint와 관련된 부분이 더 궁금하신 분은 mysql 공식문서를 한 번 읽어보시는 것을 추천드립니다.

https://dev.mysql.com/doc/refman/5.7/en/savepoint.html

 

MySQL :: MySQL 5.7 Reference Manual :: 13.3.4 SAVEPOINT, ROLLBACK TO SAVEPOINT, and RELEASE SAVEPOINT Statements

13.3.4 SAVEPOINT, ROLLBACK TO SAVEPOINT, and RELEASE SAVEPOINT Statements SAVEPOINT identifier ROLLBACK [WORK] TO [SAVEPOINT] identifier RELEASE SAVEPOINT identifier InnoDB supports the SQL statements SAVEPOINT, ROLLBACK TO SAVEPOINT, RELEASE SAVEPOINT an

dev.mysql.com

 



Atomic block안에서 Exception을 Catch하지 않기.

공식문서에서는 atomic block 안에서 exception을 catch하는 것을 피하라고 안내합니다.

# x 추천 하지 않음.
with transaction.atomic():
  try:
      # dummy query for example
      SharesTransfer.objects.create(amount=1, account_from_id=1, account_to_id=2, company_id=1)
      raise Exception('savepoint를 이용해서 일부분은 반영이 되었음!')
  except Exception as e:
	    print(e)

# O 추천하는 방법.
try:
    with transaction.atomic():
        # dummy query for example
        SharesTransfer.objects.create(amount=1, account_from_id=1, account_to_id=2, company_id=1)
        raise Exception('savepoint를 이용해서 일부분은 반영이 되었음!')
except Exception as e:
    print(e)

그 이유는 block하나가 어떤 하나의 데이터베이스의 동작을 수행하는데 그 사이에 다른 코드들이 들어감으로써 Django에서는 모르는 동작이 추가된다는 것을 의미합니다. 소개해주는 예시를 살펴보면 atomic block 안에 exception을 catch해서 쿼리를 시도한다면 TransactionManagementError가 발생되게 됩니다. (rollback을 할 건데 쿼리를 시도했기 때문이라고 생각됩니다.)


마무리

이것으로 Django 에서 DB Transaction을 다루는 방법 중 하나인 transacton.atomic에 대한 포스팅을 마칩니다. 간단한 코드부터 savepoint의 역할 실제 Raw SQL까지 어떻게 실행되는지 살펴봤습니다. 읽어 주셔서 감사합니다. 도움이 되셨다면 좋아요와 응원 댓글 부탁드려요! 감사합니다!

 

다음 글

3편 -Django DB Transaction 3편 - DB Transaction Test 코드 작성하기.


Reference

https://dev.mysql.com/doc/refman/5.7/en/savepoint.html
https://docs.djangoproject.com/en/4.0/topics/db/transactions/


 

Database transactions | Django documentation | Django

Django The web framework for perfectionists with deadlines. Overview Download Documentation News Community Code Issues About ♥ Donate

docs.djangoproject.com

 

반응형