Django TransactionTestCase 알아보기

조정환조정환
3 min read

한줄 요약: TestCase는 테스트 함수 전체를 트랜잭션으로 감싸기 때문에, 내부의 transaction.atomic()은 단지 세이브포인트일 뿐이고, 완전히 독립된 트랜잭션이 아니므로 트랜잭션이나 비관적 락 테스트에는 의미가 없다.

Django에서 데이터베이스 연관 테스트를 작성할 때 주로 사용하는 두 가지 클래스는 TestCaseTransactionTestCase입니다. 두 클래스의 근본적인 차이점을 실제 코드 예시와 함께 명확히 비교해 보겠습니다.

기본 개념 정리

  • TestCase: 각 테스트 메서드를 하나의 트랜잭션으로 감싸서 테스트 종료 후 롤백합니다. 이로 인해 테스트가 빠르고 데이터가 격리되지만, 트랜잭션 동작(예: 락 충돌)을 정확히 테스트하기 어렵습니다.

  • TransactionTestCase: 실제 트랜잭션을 사용하여 테스트하며, 매 테스트가 끝나면 데이터베이스를 완전히 초기화합니다. 트랜잭션 관련 동작을 정확히 테스트할 수 있으나 성능 부담이 있습니다.

TestCase에서 발생하는 문제 - 예시 코드 분석

from django.test import TestCase
from django.db import transaction
from django.contrib.auth import get_user_model

User = get_user_model()

class TestLockingWithTestCase(TestCase):
    def test_select_for_update(self):
        instance = User.objects.create(username="testuser", password="password")

        with transaction.atomic():
            locked_instance = User.objects.select_for_update().get(pk=instance.pk)
            try:
                with transaction.atomic():
                    User.objects.select_for_update(nowait=True).get(pk=instance.pk)
            except Exception as e:
                # 실제로 여기에 도달할 일은 없다. 😅
                self.fail(f"트랜잭션 내부 충돌이 제대로 발생하지 않음: {e}")

TestCase에서 비관적 락이 제대로 동작하지 않는 이유

위 코드에서 비관적 락(select_for_update)이 제대로 작동하지 않는 이유는 다음과 같습니다.

  • 트랜잭션 세이브포인트(Savepoint): Django의 중첩된 transaction.atomic()은 실제 독립된 트랜잭션이 아니라, 기존 트랜잭션의 세이브포인트입니다.

  • 동일 트랜잭션 컨텍스트 공유: 내부의 transaction.atomic()이 외부의 transaction.atomic()과 동일한 트랜잭션 컨텍스트와 데이터베이스 연결을 공유합니다.

  • 락 충돌 미발생: 같은 트랜잭션 컨텍스트 내에서는 이미 획득한 락이 있기 때문에 충돌이 발생하지 않고 중복으로 락을 획득할 수 있습니다.

TransactionTestCase를 활용한 올바른 테스트 방법 - 코드 예시

from django.test import TransactionTestCase
from django.db import transaction
from django.contrib.auth import get_user_model
from threading import Thread

User = get_user_model()

class TestLockingWithTransactionTestCase(TransactionTestCase):
    def test_select_for_update_conflict(self):
        instance = User.objects.create(username="testuser", password="password")

        with transaction.atomic():
            User.objects.select_for_update().get(pk=instance.pk)

            def conflicting_transaction():
                with transaction.atomic():
                    try:
                        User.objects.select_for_update(nowait=True).get(pk=instance.pk)

                        # 실제로 여기에 도달할 일은 없다. 😅
                        self.fail("락 충돌이 발생하지 않음")
                    except Exception:
                        pass  # 정상적인 락 충돌

            thread = Thread(target=conflicting_transaction)
            thread.start()
            thread.join()

TransactionTestCase가 제대로 동작하는 이유

위 코드가 제대로 동작하는 이유는 다음과 같습니다:

  • 별도 스레드 사용: 별도의 스레드를 사용하면 각 스레드는 별도의 데이터베이스 연결을 생성합니다. 이는 완전히 독립된 트랜잭션 컨텍스트를 만들어 메인 스레드의 트랜잭션과 실제 충돌을 유발할 수 있습니다.

  • 트랜잭션 격리: 두 트랜잭션이 완전히 독립된 컨텍스트에서 실행되어 락 충돌이 데이터베이스 수준에서 정확히 발생합니다.

  • TransactionTestCase 활용: 각 테스트마다 실제 트랜잭션이 새롭게 시작되고, 종료 시 실제 데이터베이스를 초기화하여 더 현실적인 운영 환경을 시뮬레이션할 수 있습니다.

TestCase와 TransactionTestCase 비교 요약

특징TestCaseTransactionTestCase
트랜잭션 동작하나의 큰 트랜잭션에서 롤백각 테스트마다 실제 트랜잭션 사용
비관적 락(select_for_update)정확한 충돌 재현 불가능정확한 충돌 재현 가능
데이터베이스 연결동일 연결 및 트랜잭션 컨텍스트 공유별도의 독립적인 연결 및 트랜잭션 컨텍스트
성능빠름 (롤백 방식)느림 (트랜잭션 및 truncate 방식)
추천 사용 사례일반 유닛 테스트트랜잭션 및 락 동작을 정확히 테스트할 때

결론

TestCase는 빠르고 효율적이지만, 트랜잭션과 락 충돌을 정확히 테스트하려면 TransactionTestCase를 사용해야 합니다. 비관적 락이나 동시성 문제를 테스트할 때는 별도의 스레드와 TransactionTestCase를 사용하는 방식이 필수적입니다.

0
Subscribe to my newsletter

Read articles from 조정환 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

조정환
조정환