🚨 이런 상황, 겪어보셨나요?
"테스트를 하나씩 돌리면 통과하는데, 전체를 돌리면 랜덤하게 실패합니다."
"분명히 어제까지 통과하던 테스트가 오늘 갑자기 실패합니다. 코드를 안 바꿨는데요."
"CI에서만 테스트가 실패하고, 로컬에서는 항상 통과합니다."
"테스트 실행 순서를 바꾸면 결과가 달라집니다."
Django 테스트를 작성하다 보면, 테스트 하나만 돌리면 당연히 통과하는데 여러 테스트를 함께 돌리면 예측 불가능하게 실패하는 경험을 하게 됩니다.
원인은 대부분 테스트 DB 오염 — 이전 테스트가 만든 데이터가 다음 테스트에 영향을 주는 것입니다.
🔥 실제 상황: 통과했다 실패했다 들쪽날쪽 테스트
E커머스 프로젝트에서 테스트를 작성하고 있었습니다.
class ProductTest(TestCase):
def test_create_product(self):
"""상품 생성 테스트"""
Product.objects.create(name="스니커즈", price=89000)
self.assertEqual(Product.objects.count(), 1)
def test_product_list(self):
"""상품 목록 테스트"""
Product.objects.create(name="러닝화", price=129000)
Product.objects.create(name="샌들", price=59000)
self.assertEqual(Product.objects.count(), 2) # 통과할까?
각각 돌리면 둘 다 통과합니다. 그런데 함께 돌리면?
test_create_product가 먼저 실행되면서 상품 1개를 만들고, test_product_list가 실행될 때 그 데이터가 남아 있으면? count()가 2가 아니라 3이 되어 실패합니다.
Django의 TestCase는 트랜잭션 롤백으로 이를 방지하지만, 잘못 사용하면 여전히 오염이 발생합니다. 어떤 경우에 문제가 되는지, 하나씩 살펴보겠습니다.
🎯 테스트 DB 오염이 뭔데, 왜 위험할까?
요리 실습실 비유로 이해하기
테스트 DB 오염을 이해하려면 요리 실습실을 떠올려보세요.
요리 실습실에서 각 수강생이 자기 요리를 완성한 후에는 조리대를 깨끗이 정리해야 다음 사람이 사용할 수 있습니다. 이전 수강생이 쓴 양파가 남아 있고, 소스 팔이 엉질러져 있고, 가스렌지에 손님 요리가 타고 있다면? 다음 수강생은 "내가 이걸 쓴 건가, 이전 사람이 쓴 건가?" 혼란에 빠집니다.
DB 테스트도 마찬가지입니다. 각 테스트가 만든 데이터를 깨끗이 치우지 않으면, 다음 테스트는 "내가 만든 데이터인가, 이전 테스트가 만든 데이터인가?" 구분할 수 없게 됩니다.
오염이 위험한 이유
단순히 테스트가 실패하는 것 이상으로 심각한 문제를 일으킵니다.
첫째, 테스트를 믿을 수 없게 됩니다. 테스트가 때때로 실패하면 팀은 "그건 바람이니까 무시해"라는 태도를 취하게 되고, 진짜 버그가 있어도 "또 바람이겠지"하고 넘기게 됩니다. 둘째, CI/CD 파이프라인이 마비됩니다. 테스트가 랜덤하게 실패하면 배포가 차단되고, "다시 돌리면 될 거야"하면서 재실행을 반복합니다.
🔍 Django 테스트 도구 이해하기: TestCase vs TransactionTestCase
Django는 두 가지 테스트 클래스를 제공하는데, DB 정리 방식이 다릅니다.
TestCase — 트랜잭션 롤백 방식
from django.test import TestCase
class ProductTest(TestCase):
def test_create(self):
Product.objects.create(name="스니커즈", price=89000)
# 테스트 끝나면 자동 롤백 — DB에 아무것도 남지 않음
TestCase는 각 테스트 메소드를 트랜잭션으로 감싸고, 테스트가 끝나면 롤백합니다. 마치 게임에서 세이브 포인트로 되돌아가는 것처럼, DB가 테스트 시작 전 상태로 원립니다.
장점: 빠릅니다. 실제로 COMMIT하지 않으므로 DB I/O가 적습니다.
단점: 트랜잭션 안에서 동작하므로, transaction.atomic(), on_commit() 같은 트랜잭션 관련 코드를 테스트할 수 없습니다.
TransactionTestCase — TRUNCATE 방식
from django.test import TransactionTestCase
class OrderTransactionTest(TransactionTestCase):
def test_concurrent_order(self):
# 실제로 COMMIT되므로 트랜잭션 테스트 가능
with transaction.atomic():
order = Order.objects.create(total=50000)
# 테스트 끝나면 TRUNCATE로 DB 초기화
TransactionTestCase는 실제로 DB에 COMMIT하고, 테스트 후에 TRUNCATE로 테이블을 비운다. 실제 DB 동작을 테스트할 수 있지만, 느립니다.
항목 TestCase TransactionTestCase
| DB 정리 방식 | 트랜잭션 롤백 | TRUNCATE |
| 속도 | 빠름 | 느림 |
| 트랜잭션 테스트 | 불가능 | 가능 |
| 기본 사용 | 대부분의 테스트 | 트랜잭션 테스트만 |
원칙: 기본적으로 TestCase를 사용하고, 트랜잭션 동작을 테스트해야 할 때만 TransactionTestCase를 사용합니다.
🛠️ 실전 적용: 테스트 DB 오염 방지 전략
전략 1: setUp / tearDown 제대로 사용하기
가장 기본적인 방법입니다. 각 테스트 메소드 실행 전/후에 데이터를 준비하고 정리합니다.
class ProductTest(TestCase):
def setUp(self):
"""각 테스트 메소드 실행 전마다 호출"""
self.product = Product.objects.create(
name="공유 상품",
price=10000
)
def test_product_name(self):
self.assertEqual(self.product.name, "테스트 상품")
def test_product_price(self):
self.assertEqual(self.product.price, 10000)
# setUp에서 만든 상품 1개만 있음을 보장
self.assertEqual(Product.objects.count(), 1)
setUp은 각 테스트 메소드마다 실행됩니다. test_product_name과 test_product_price는 각각 독립적인 setUp을 가지므로, 서로에게 영향을 주지 않습니다.
전략 2: setUpTestData로 성능 최적화
setUp은 간단하지만, 테스트마다 데이터를 새로 만들어서 느릴 수 있습니다.
class ProductTest(TestCase):
@classmethod
def setUpTestData(cls):
"""테스트 클래스 전체에서 한 번만 실행"""
cls.product = Product.objects.create(
name="공유 상품",
price=10000
)
def test_product_name(self):
self.assertEqual(self.product.name, "공유 상품")
def test_product_price(self):
self.assertEqual(self.product.price, 10000)
setUpTestData는 클래스 레벨에서 딱 한 번 실행됩니다. 테스트가 100개여도 데이터는 1번만 생성됩니다. 속도가 훨씬 빠릅니다.
단, 주의사항이 있습니다. setUpTestData에서 만든 데이터를 테스트 메소드에서 수정하면 안 됩니다. 다른 테스트에 영향을 줍니다.
# ❌ setUpTestData의 데이터를 수정하는 실수
def test_update_price(self):
self.product.price = 20000
self.product.save() # 다른 테스트에 영향!
# ✅ 수정이 필요하면 setUp에서 별도로 생성
def setUp(self):
self.mutable_product = Product.objects.create(
name="수정용", price=10000
)
def test_update_price(self):
self.mutable_product.price = 20000
self.mutable_product.save() # 이건 안전
규칙: 읽기 전용 데이터는 setUpTestData, 수정해야 하는 데이터는 setUp.
전략 3: Factory Boy로 테스트 데이터 관리
테스트가 많아지면 setUp에서 데이터 생성 코드가 점점 복잡해집니다. Factory Boy를 사용하면 테스트 데이터 생성을 깔끔하게 관리할 수 있습니다.
# factories.py
import factory
from .models import Product, Order, User
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f'user_{n}')
email = factory.LazyAttribute(lambda obj: f'{obj.username}@test.com')
class ProductFactory(factory.django.DjangoModelFactory):
class Meta:
model = Product
name = factory.Sequence(lambda n: f'상품_{n}')
price = factory.Faker('random_int', min=1000, max=100000)
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = Order
customer = factory.SubFactory(UserFactory)
product = factory.SubFactory(ProductFactory)
# tests.py
class OrderTest(TestCase):
def test_order_total(self):
order = OrderFactory(product__price=50000) # 필요한 값만 지정
self.assertEqual(order.product.price, 50000)
def test_bulk_orders(self):
orders = OrderFactory.create_batch(10) # 10개 한 번에
self.assertEqual(Order.objects.count(), 10)
Factory Boy의 Sequence가 자동으로 유니크한 값을 생성하므로, 데이터 충돌 걸정 없이 각 테스트에 필요한 데이터를 만들 수 있습니다.
전략 4: 파일/캐시 등 DB 외부 상태 정리
테스트 DB 오염은 데이터베이스만의 문제가 아닙니다. 테스트 중 생성된 파일, 캐시, 외부 상태도 오염의 원인이 됩니다.
import shutil
import tempfile
from django.test import TestCase, override_settings
# 테스트용 임시 미디어 폴더 사용
TEMP_MEDIA_ROOT = tempfile.mkdtemp()
@override_settings(MEDIA_ROOT=TEMP_MEDIA_ROOT)
class FileUploadTest(TestCase):
@classmethod
def tearDownClass(cls):
# 테스트 클래스 종료 시 임시 폴더 삭제
shutil.rmtree(TEMP_MEDIA_ROOT, ignore_errors=True)
super().tearDownClass()
def test_upload_image(self):
# 테스트 중 업로드된 파일은 임시 폴더에 저장
# 실제 MEDIA_ROOT를 오염하지 않음
pass
Redis 캐시를 사용하는 테스트라면:
class CachedViewTest(TestCase):
def setUp(self):
from django.core.cache import cache
cache.clear() # 각 테스트 전에 캐시 초기화
def test_cached_product_list(self):
# 캐시가 비어있는 상태에서 시작
pass
🐛 자주 발생하는 실수와 디버깅
실수 1: setUpTestData에서 변경 가능한 객체 공유
앞서 설명했지만 가장 흔한 실수입니다. setUpTestData에서 만든 객체를 테스트에서 수정하면, 다른 테스트에서 수정된 상태를 보게 됩니다. DB는 롤백되지만 Python 객체는 클래스 변수로 공유되기 때문입니다.
Django 4.2 이상에서는 setUpTestData의 객체를 자동으로 복사해주지만, 그래도 DB를 수정하는 테스트라면 setUp을 사용하는 것이 안전합니다.
실수 2: 테스트 간 순서 의존성
# ❌ 테스트 A의 결과에 의존하는 테스트 B
class OrderTest(TestCase):
def test_a_create_order(self):
"""주문 생성"""
Order.objects.create(id=1, total=50000)
def test_b_cancel_order(self):
"""주문 취소 — test_a의 데이터에 의존!"""
order = Order.objects.get(id=1) # test_a가 만든 데이터!
order.cancel()
# ✅ 각 테스트가 독립적으로 데이터 준비
class OrderTest(TestCase):
def test_create_order(self):
order = Order.objects.create(total=50000)
self.assertEqual(order.total, 50000)
def test_cancel_order(self):
order = Order.objects.create(total=50000) # 직접 만듬
order.cancel()
order.refresh_from_db()
self.assertEqual(order.status, 'cancelled')
원칙: 각 테스트는 독립적으로 실행될 수 있어야 합니다. 순서가 바뀌어도, 하나만 실행해도 결과가 같아야 합니다.
실수 3: Fixture의 함정
# ❌ Fixture로 데이터 로드 — 모델 변경 시 깨짐
class ProductTest(TestCase):
fixtures = ['products.json'] # 모델 변경하면 JSON도 수정해야
Fixture(JSON/YAML 파일)는 모델이 변경될 때마다 함께 수정해야 합니다. 모델에 필드가 추가되면 Fixture가 깨지고, 그러면 테스트도 깨집니다.
# ✅ Factory Boy로 대체 — 모델 변경에 유연
class ProductTest(TestCase):
def setUp(self):
self.product = ProductFactory() # 모델이 바뀌면 Factory만 수정
실수 4: 로컬과 CI 환경 차이
로컬에서는 SQLite, CI에서는 PostgreSQL을 사용하는 경우, DB 동작 차이로 테스트 결과가 달라질 수 있습니다.
# ✅ 테스트에서도 프로덕션과 같은 DB 사용
# settings/test.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'test_myproject',
'USER': 'postgres',
'PASSWORD': 'postgres',
'HOST': 'localhost',
'PORT': '5432',
}
}
또는 --keepdb 옵션으로 테스트 DB를 재사용하면 속도도 빨라집니다.
python manage.py test --keepdb
✅ 베스트 프랙티스 체크리스트
설계 단계
- [ ] 기본적으로 TestCase를 사용하고 있는가?
- [ ] 트랜잭션 테스트가 필요한 경우에만 TransactionTestCase를 사용하는가?
- [ ] Factory Boy를 도입하여 테스트 데이터를 관리하고 있는가?
- [ ] Fixture 대신 Factory를 사용하고 있는가?
개발 단계
- [ ] 각 테스트가 독립적으로 실행 가능한가?
- [ ] setUpTestData의 데이터를 테스트에서 수정하지 않는가?
- [ ] 테스트 간 순서 의존성이 없는가?
- [ ] 파일/캐시 등 DB 외부 상태도 정리하고 있는가?
운영 단계
- [ ] CI에서 로컬과 같은 DB 엔진을 사용하고 있는가?
- [ ] 테스트가 랜덤하게 실패하는 현상이 없는가?
- [ ] --shuffle 옵션으로 테스트 순서 독립성을 검증하고 있는가?
- [ ] 테스트 실행 속도를 주기적으로 모니터링하고 있는가?
🎯 결론: 독립적인 테스트가 믿을 수 있는 테스트입니다
테스트 DB 오염은 테스트에 대한 신뢰를 무너뜨리는 문제입니다. 테스트를 믿을 수 없으면 테스트를 작성하는 의미 자체가 사라집니다.
기억해야 할 핵심 원칙
- 각 테스트는 독립적이어야 합니다: 순서를 바꿔도, 하나만 실행해도 결과가 같아야 합니다.
- TestCase를 기본으로 사용하세요: 트랜잭션 롤백이 자동으로 DB를 정리해줍니다.
- Factory Boy를 활용하세요: Fixture보다 유연하고, 모델 변경에 강합니다.
- DB 외부 상태도 정리하세요: 파일, 캐시, 외부 상태도 오염의 원인입니다.
💼 Django 테스트 환경 컨설팅
안정적인 테스트 환경은 개발 속도와 코드 품질을 동시에 높입니다.
이런 고민이 있으시다면:
- 테스트가 때때로 실패해서 CI가 막힌다면
- 테스트 실행 속도가 느려서 개발 흐름이 끊긴다면
- 테스트 코드의 구조를 잡고 싶다면
크몽에서 Django 전문 컨설팅을 제공합니다.
'프로그래밍 > Python' 카테고리의 다른 글
| 📝 로그 설정 문제: 에러 추적이 어려운 경우 (0) | 2026.02.18 |
|---|---|
| 🎭 Mock 처리 부족: 외부 의존성 때문에 테스트가 불안정한 경우 (0) | 2026.02.17 |
| 📨 응답 형식 불일치: 프론트엔드에서 예상하는 JSON 구조와 다름 (0) | 2026.02.15 |
| 🔀 API 버전 관리 부족: 기존 클라이언트와 호환성 문제 (0) | 2026.02.14 |
| 📄 페이지네이션 누락: 대량 데이터 조회 시 성능 저하 (0) | 2026.02.13 |