트랜잭션과 Django의 transaction.atomic에 대해 알아보자

King of Seoul·2022년 9월 20일
0

Django

목록 보기
1/1
post-thumbnail
post-custom-banner

백엔드 개발을 하면 언젠가는 트랜잭션과 마주하게 된다.
장고에서 트랜잭션을 어떻게 관리하는지에 대해 알아보자.
급하다면 6. Django ORM의 트랜잭션부터 ...

1. 트랜잭션(transaction)이 무엇일까.

트랜잭션은 DB의 상태를 변화시키기 위해 수행하는 작업의 단위이다.
SQL을 이용한 CRUD가 그 작업들이다.

하지만 하나의 쿼리가 하나의 트랜잭션인 것은 아니며, 여러 쿼리를 묶어 하나의 트랜잭션으로 처리할 수 있다.
아래와 같은 상황을 가정을 해보겠다.

서비스 내 재화를 소비하여 물품을 구매하는 로직을 구현해야 한다.
1. cash 테이블에서 잔액을 조회한다.
2. 재화를 소비하고 잔액을 update한다.
3. 구매 내역을 생성한다.
4. 소비 이후 잔액을 조회한다.

위와 같은 로직을 구현했을 때 아래와 같은 순서로 쿼리가 발생한다.
(실제 서비스라면 더 많은 쿼리들이 발생하겠지만, 예시이니 간단하게만)

이 때 발생할 수 있는 문제들을 생각해보면,

재화를 소비하는 로직이 동시에 발생하면 어떡하지?
잔액 부족이나 특정 이유로 재화를 소비할 수 없는 상황이 발생하면 어떡하지?
잔액과 구매 내역이 같지 않으면 어떡하지?
...
문제가 발생했을 때 어떻게 되돌리지...

2. 어떻게 되돌리지...

아래와 같이 하나의 트랜잭션으로 처리하면 가능하다.

하지만 어떻게?
트랜잭션의 특성, commitrollback에 대해 먼저 간단히 알아보겠다.

3. 트랜잭션의 특성

트랜잭션은 아래 4가지 특성을 가진다.

  1. 원자성 (Actomicity)
    트랜잭션의 수행 결과는 전부 반영되거나, 전부 반영되지 않아야 한다.

  2. 일관성 (Consistency)
    트랜잭션 작업의 결과는 항상 일관되어야 한다.

  3. 독립성 (Isolation)
    각각의 트랜잭션은 서로 간섭할 수 없이 독립적이다.

  4. 영구성 (Durability)
    트랜잭션이 성공적으로 처리되면 결과는 영구적으로 반영되어야 한다.

혹시 감이 오는지?
이번 포스트에서는 원자성, 영구성에 대해 다룬다.

4. commit과 rollback

트랜잭션이 종료될 때 아래와 같은 연산들을 한다.

  1. commit
    트랜잭션이 성공적으로 끝나서 결과에 대해 영구성을 보장하도록 한다.

  2. rollback
    트랜잭션이 원자성을 보장할 수 없을 때 수행하여 재시도하거나 되돌릴 수 있도록 한다.

5. 그렇다면

성공적으로 수행되면 commit하고, 문제가 발생하면 rollback하여 상황을 해결할 수 있다!
너무 간단한데...
Django에서는 어떨까?

6. Django ORM의 트랜잭션

Django는 기본적으로 autocommit이다.
각 ORM(쿼리)가 실행되는 즉시 DB에 commit을 해버리는 방식이다.
명시적으로 트랜잭션을 제어하기 위해선 atomic을 사용해야 한다.

atomic

이름에서부터 알 수 있듯이 atomic을 사용해 원자성을 보장하는 코드 블록을 만들 수 있다.
atomic으로 묶여있는 코드 블록은 하나의 트랜잭션으로 동작한다.
해당 코드 블록이 성공적으로 수행되면 commit, 그렇지 않으면 rollback한다.

atomic을 사용하여 1장에서 정의된 상황에 하나의 요구사항을 추가해 간단하게 구현해보겠다.

서비스 내 재화를 소비하여 물품을 구매하는 로직을 구현해야 한다.
1. cash 테이블에서 잔액을 조회한다.
2. 재화를 소비하고 잔액을 update한다.
3. 상품이 구매 가능한 상태라면 구매 내역을 생성한다.
4. 그렇지 않다면 구매를 취소한다.
5. 구매 이후 잔액을 조회한다.

from django.db import transaction

def 잔액_확인(유저):
	return Cash.objects.get(유저=유저).잔액

def 구매내역_생성(유저, 상품):
	if 상품.구매_가능:
        purchase_history = PurchaseHistory.objects.create(
            유저=유저,
            상품=상품
        )

        return purhcase_history

	raise Exception(f"현재 {상품.이름}을 구매할 수 없습니다.")

@transaction.atomic
def 구매(유저, 상품):
    잔액 = 잔액_확인(유저)
    if 잔액 < 상품.가격:
        raise Exception("잔액이 부족합니다.")
    
    재화_소비(상품.가격)
    구매내역_생성(상품)
    
    return 잔액_확인(유저)

먼저 현재 잔액이 부족한 경우와 상품이 구매가 가능하지 않은 경우 의도적으로 raise를 해주었다.
그렇다면 트랜잭션은 rollback되고 유저의 잔액은 정상적으로 유지가 될 것이다.
문제 없이 마지막 코드까지 실행되면 결과는 commit되어 생성된 구매내역과 잔액은 영구성이 보장 될 것이다.

with로 사용하기

atomicdecorator 이외에도 with와 함께 사용할 수 있다.

def 구매(유저, 상품):
	with transaction.atomic():
        잔액 = 잔액_확인(유저)
        if 잔액 < 상품.가격:
            raise Exception("잔액이 부족합니다.")

        재화_소비(상품.가격)
        구매내역_생성(상품)

	    return 잔액_확인(유저)

상황에 맞게 사용하면 되겠지만, with를 사용하면 indent가 늘어나 나는 decorator를 선호한다.

on_commit

트랜잭션이 정상적으로 commit될 때 수행할 내용도 지정할 수 있다.
구매에 성공하면 유저에게 구매 완료 이메일을 발송하는 셀러리 태스크를 호출한다고 해보자.

from functools import partial

def 구매(유저, 상품):
	with transaction.atomic():
        잔액 = 잔액_확인(유저)
        if 잔액 < 상품.가격:
            raise Exception("잔액이 부족합니다.")

        재화_소비(상품.가격)
        구매내역_생성(상품)
        
        transaction.on_commit(partial(구매_완료_이메일_발송.delay, 유저.이메일))

	    return 잔액_확인(유저)

아... 초간단.

7. 맺는 말

쉽다.
다음번엔 lock에 대해서 다뤄볼까 한다.
자세한 내용은 https://docs.djangoproject.com/en/4.1/topics/db/transactions/ 에서 확인 가능하다.

그럼, Au revoir 🤠

profile
"그는 천재야"
post-custom-banner

0개의 댓글