Showing Posts From
데이터
- 26 Dec, 2025
테스트 데이터 관리: 공유 DB를 쓸 때의 난장판
새벽 2시, 슬랙 알림 새벽 2시에 슬랙이 울렸다. CI 파이프라인이 깨졌다. 또다. 테스트 실패율 60%. 어제까지 다 돌아갔는데. 로그 봤다. 데이터베이스 충돌. 예상했다. "같은 이메일이 이미 존재합니다." 50개 테스트가 똑같은 에러. test_user@example.com. 우리 팀 모두가 쓰는 그 이메일. 잠이 확 깼다. 노트북 켰다.공유 DB 쓰면서 병렬 테스트 돌리는 거. 이게 문제였다. 8개 테스트가 동시에 돌아간다. 각자 똑같은 이메일로 회원가입 시도. 하나만 성공. 나머지 7개는 실패. 더 웃긴 건, 성공한 그 하나도 결국 실패한다. 다른 테스트가 그 유저를 지워버리니까. 테스트 A가 유저 만들고 → 테스트 B가 그 유저로 로그인 시도 → 테스트 C가 유저 삭제 → 테스트 B 실패. 타이밍 게임이다. 누가 먼저 뭘 하느냐에 따라 결과가 달라진다. Flaky 테스트의 전형. 로컬에선 잘 돌아간다. CI에서만 죽는다. 아침 9시, 데일리 스탠드업 "어제 CI 왜 깨졌어요?" 백엔드 개발자 준수가 물었다. "공유 DB 문제예요. 병렬 테스트 환경에서." "아, 또?" 또. 맞다. 이번 달만 세 번째.우리 QA 환경은 이렇다.개발 서버 1대 공유 MySQL DB 1개 Jenkins에서 8개 워커로 병렬 실행 테스트 스위트 200개 실행 시간: 순차 80분 → 병렬 12분속도는 좋다. 안정성은 최악. "격리된 DB 환경 만들면 되지 않아요?" 준수가 말했다. 쉽게. "Docker 컨테이너로 DB 띄우면요." 알고 있다. 그런데. "컨테이너 8개 띄우면 메모리 부족해요. 서버 스펙이." "아..." 예산 없다. 서버 증설도 없다. 있는 걸로 해결해야 한다. 오전, 문제 분석 커피 들고 책상 앞에 앉았다. 문제를 정리했다. 데이터 충돌 패턴:같은 식별자 사용 (이메일, 전화번호) 순서 의존성 (A 테스트가 B 테스트 데이터 필요) 클린업 실패 (테스트 끝나도 데이터 남음) 타이밍 이슈 (동시 INSERT)우리 테스트 코드를 봤다. def test_user_signup(): email = "test_user@example.com" # 하드코딩 response = signup(email, "password123") assert response.status_code == 200def test_user_login(): email = "test_user@example.com" # 같은 이메일 response = login(email, "password123") assert response.status_code == 200답이 없다. 모든 테스트가 똑같은 데이터. 병렬 실행하면 당연히 깨진다.슬랙에 QA 채널에 물어봤다. "다른 회사는 테스트 데이터 어떻게 관리해요?" 대답이 왔다. "UUID 쓰세요." - 민지 "테스트마다 DB 스키마 분리." - 현우 "우리는 그냥 순차 실행해요." - 수진 각자 다르다. 정답은 없다. 우리 상황에 맞는 걸 찾아야 한다. 점심 후, 전략 세우기 전략을 3개 생각했다. 1. 데이터 고유화 테스트마다 고유한 데이터. UUID나 타임스탬프. import uuiddef test_user_signup(): email = f"test_{uuid.uuid4()}@example.com" response = signup(email, "password123") assert response.status_code == 200장점: 간단. 충돌 없음. 단점: DB에 쓰레기 데이터 쌓임. 클린업 필요. 2. 트랜잭션 롤백 테스트를 트랜잭션으로 감싸고 끝나면 롤백. @pytest.fixture(autouse=True) def db_transaction(): connection.begin() yield connection.rollback()장점: 깔끔. 데이터 안 남음. 단점: API 테스트엔 못 씀. DB 직접 접근만 가능. 우리는 E2E 테스트다. HTTP 요청으로 테스트. 트랜잭션 제어 안 됨. 3. 테스트 격리 환경 각 테스트마다 독립된 DB 스키마나 컨테이너. 장점: 완벽한 격리. 단점: 복잡. 느림. 리소스 많이 먹음. 우리 서버론 무리. 결론: 1번과 2번 혼합.E2E 테스트는 데이터 고유화 + 클린업 유닛 테스트는 트랜잭션 롤백오후, 구현 시작 프레임워크부터 고쳤다. conftest.py에 픽스처 추가했다. import pytest import uuid from datetime import datetime@pytest.fixture def unique_email(): """고유한 테스트 이메일 생성""" timestamp = datetime.now().strftime("%Y%m%d%H%M%S") unique_id = str(uuid.uuid4())[:8] return f"test_{timestamp}_{unique_id}@example.com"@pytest.fixture def unique_phone(): """고유한 테스트 전화번호 생성""" timestamp = datetime.now().strftime("%H%M%S") return f"010{timestamp}{random.randint(1000, 9999)}"@pytest.fixture def test_data_cleanup(request): """테스트 종료 후 데이터 정리""" created_ids = [] def register_for_cleanup(resource_type, resource_id): created_ids.append((resource_type, resource_id)) yield register_for_cleanup # 테스트 끝나면 역순으로 삭제 for resource_type, resource_id in reversed(created_ids): try: cleanup_resource(resource_type, resource_id) except Exception as e: print(f"Cleanup failed: {resource_type} {resource_id} - {e}")테스트 코드를 수정했다. def test_user_signup(unique_email, test_data_cleanup): # 고유 이메일 사용 response = signup(unique_email, "password123") assert response.status_code == 200 user_id = response.json()["user_id"] # 클린업 등록 test_data_cleanup("user", user_id)def test_user_profile_update(unique_email, test_data_cleanup): # 테스트용 유저 생성 user_id = create_test_user(unique_email) test_data_cleanup("user", user_id) # 프로필 수정 테스트 response = update_profile(user_id, {"name": "테스터"}) assert response.status_code == 200200개 테스트 다 고쳐야 한다. 한숨 나왔다. 2시간 후, 첫 번째 벽 테스트 50개 고쳤다. 돌려봤다. 새로운 문제. 외래 키 제약. 유저 삭제하려는데 주문 데이터가 남아있다. 삭제 실패. IntegrityError: Cannot delete user - foreign key constraint fails삭제 순서가 중요하다. 주문 → 장바구니 → 결제수단 → 유저. 이 순서로. cleanup_resource 함수를 고쳤다. def cleanup_resource(resource_type, resource_id): """리소스 타입별 정리 로직""" cleanup_order = { "order": ["payment", "order_item", "order"], "user": ["order", "cart", "payment_method", "user"], "product": ["order_item", "cart_item", "product"] } if resource_type in cleanup_order: for dep_type in cleanup_order[resource_type]: delete_dependent_resources(dep_type, resource_id) delete_resource(resource_type, resource_id)복잡하다. DB 스키마를 다 알아야 한다. 의존성 그래프 그렸다. A4 용지 3장. 4시, 두 번째 벽 테스트 100개 고쳤다. 돌려봤다. 또 실패. 이번엔 타임아웃. 클린업에 시간이 너무 오래 걸린다. 각 테스트가 끝날 때마다 5초씩. 200개면 1000초. 16분 추가. 병렬 실행 효과가 없다. 최적화가 필요했다. 방법 1: 배치 삭제 테스트마다 지우지 말고 모아서 한 번에. @pytest.fixture(scope="session") def global_cleanup(): """전체 테스트 세션 종료 후 일괄 정리""" cleanup_list = [] yield cleanup_list # 타입별로 그룹핑 by_type = {} for resource_type, resource_id in cleanup_list: by_type.setdefault(resource_type, []).append(resource_id) # 타입별 배치 삭제 for resource_type, ids in by_type.items(): batch_delete(resource_type, ids)문제: 테스트 실패하면 데이터가 남는다. 다음 실행에 영향. 방법 2: 타임스탬프 기반 정리 오래된 테스트 데이터만 주기적으로 삭제. def cleanup_old_test_data(): """1시간 이상 된 테스트 데이터 삭제""" cutoff_time = datetime.now() - timedelta(hours=1) # 이메일에 타임스탬프 포함되어 있으면 파싱 old_users = find_users_by_email_pattern( "test_%@example.com", created_before=cutoff_time ) for user in old_users: delete_user_cascade(user.id)Jenkins 크론잡 추가. 매시간 실행. 이게 더 현실적이다. 5시, 세 번째 벽 테스트 150개 고쳤다. 새로운 패턴 발견. 순서 의존성. test_user_login이 test_user_signup에 의존한다. 병렬 실행하면 순서 보장 안 됨. 로그인 테스트가 먼저 실행돼서 실패. pytest 마커로 해결했다. @pytest.mark.dependency() def test_user_signup(unique_email): # 회원가입 테스트 pass@pytest.mark.dependency(depends=["test_user_signup"]) def test_user_login(unique_email): # 로그인 테스트 pass근데 이건 순차 실행이다. 병렬의 의미가 없다. 테스트를 다시 짰다. 독립적으로. def test_user_login(unique_email, test_data_cleanup): # 로그인 테스트용 유저를 여기서 생성 user_id = create_test_user(unique_email, "password123") test_data_cleanup("user", user_id) # 로그인 테스트 response = login(unique_email, "password123") assert response.status_code == 200각 테스트가 필요한 전제 조건을 스스로 만든다. Setup이 길어진다. 대신 독립적이다. 트레이드오프다. 6시, 전체 테스트 실행 200개 다 고쳤다. 전체 실행. 숨 참고 봤다. Jenkins 로그가 올라간다. Test Session Summary 200 tests collected 8 workers Execution time: 15m 23s성공률 95%. 10개가 여전히 실패한다. 로그 확인. 여전히 데이터 충돌. 상품 테스트. 같은 상품명 쓰고 있었다. def test_product_create(): product_name = "테스트 상품" # 하드코딩 response = create_product(product_name, 10000) assert response.status_code == 200이것도 고유화 필요. def test_product_create(unique_product_name, test_data_cleanup): response = create_product(unique_product_name, 10000) assert response.status_code == 200 product_id = response.json()["product_id"] test_data_cleanup("product", product_id)unique_product_name 픽스처 추가. 다시 실행. 성공률 98%. 4개 남았다. 진짜 버그들이다. API에 동시성 문제가 있었다. 같은 상품을 동시에 두 번 생성하면 둘 다 성공한다. 버그 티켓 올렸다. 저녁 7시, 회고 준수한테 슬랙 보냈다. "CI 이제 안정적이에요. 성공률 98%." "오, 뭘 했어요?" "테스트 데이터 격리. 고유화랑 클린업 전략." "시간은 얼마나 걸려요?" "15분. 전보다 3분 늘었어요." "그 정도면 괜찮네요." 실제로 한 것들 정리했다. 데이터 고유화 전략:UUID 기반 이메일/전화번호 생성 타임스탬프 조합 테스트 실행 ID 포함클린업 전략:픽스처 기반 자동 정리 의존성 순서 고려한 삭제 타임아웃 방지 위한 배치 처리 크론잡으로 오래된 데이터 정리격리 전략:테스트 간 데이터 공유 최소화 각 테스트가 독립적으로 실행 가능 Setup에서 필요한 데이터 생성 Teardown에서 확실한 정리아직 남은 문제:클린업 실패 시 복구 로직 부족 DB 커넥션 풀 고갈 가능성 테스트 데이터 증가로 DB 용량 이슈 격리 환경 구축은 여전히 숙제밤 9시, 집에서 퇴근하고 생각했다. 완벽한 해결책은 없다. 컨테이너 기반 격리가 이상적이지만 현실적으로 불가능. 우리처럼 공유 DB 쓰는 곳 많다. 비용 때문에. 인프라 제약 때문에. 그럼 최선을 찾아야 한다. 핵심은 3가지:데이터 고유성: 절대 충돌하지 않게 독립성: 테스트가 다른 테스트에 의존하지 않게 정리: 쓰레기 안 남기게이것만 지켜도 80%는 해결된다. 나머지 20%는 비즈니스 로직의 동시성 문제다. 테스트가 찾아낸 진짜 버그. 오히려 좋다. 테스트의 본질이니까. 다음 스프린트엔 Docker Compose 로컬 환경 구축 시도해볼 것. 개발자들 로컬에서라도 격리된 DB 쓰게. CI는... 예산 나올 때까지 지금 방식으로. 슬랙에 QA 채널에 썼다. "테스트 데이터 관리 가이드 문서 작성했어요. 컨플루언스에 올렸습니다." 후배들이 같은 삽질 안 했으면 좋겠다. 테스트 자동화는 코드만의 문제가 아니다. 데이터, 환경, 인프라 모두 고려해야 한다. 그게 SDET의 영역이다. 내일은 Flaky 테스트 리포트 만들어야지. 무작위로 실패하는 것들 분류하고. 노트북 닫았다. 충분히 했다.공유 DB 쓰면서 병렬 테스트 돌리는 거, 생각보다 복잡하다. 데이터 격리만 잘해도 절반은 해결되는데.