- 03 Dec, 2025
Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가
Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가 새벽 3시, 슬랙 알림 새벽 3시. 슬랙 알림음에 눈이 떠졌다. "CI Build Failed - 17 tests failed" 저녁에 분명 다 통과했던 테스트들이었다. 아침 출근해서 다시 돌렸다. 전부 통과. 이게 벌써 이번 주에 세 번째다. 팀 슬랙에 개발자가 물었다. "J님, 테스트 불안정한 것 같은데 확인 가능하세요?" 확인 가능하다. 불안정한 거 맞다. 그게 문제다. Flaky 테스트. QA 자동화의 숙적. 같은 코드, 같은 환경인데 결과가 다르다. 오늘은 통과, 내일은 실패. 아침엔 성공, 저녁엔 실패. 이거 해결하려고 지난 한 달을 썼다.Flaky 테스트가 뭔데 Flaky 테스트는 비결정적 테스트다. 동일한 조건인데 결과가 달라진다. 예를 들면 이렇다. def test_user_login(): driver.get("https://example.com/login") driver.find_element(By.ID, "username").send_keys("test@test.com") driver.find_element(By.ID, "password").send_keys("password123") driver.find_element(By.ID, "login-btn").click() # 여기서 가끔 실패함 assert driver.find_element(By.ID, "dashboard").is_displayed()10번 중 7번은 통과한다. 3번은 실패한다. 실패 이유가 매번 다르다."Element not found" "Timeout" "Stale element reference"코드는 안 바뀌었다. 테스트 대상도 안 바뀌었다. 그런데 결과가 다르다. 우리 팀 자동화 테스트 1,247개. 이중 Flaky 테스트가 43개였다. 3.4%다. 적어 보인다? 절대 아니다. 43개가 랜덤하게 실패하면, 한 번 돌릴 때마다 5~10개씩 빨간불. 개발자들이 CI 결과를 신뢰 안 한다. "아 그거 또 Flaky 테스트 아니에요?" 자동화의 신뢰도가 무너진다. 원인 1: 타이밍 이슈 가장 흔한 원인. 타이밍. 웹 페이지는 로딩 시간이 들쭉날쭉하다. 네트워크 상태, 서버 부하, 브라우저 렌더링. 테스트는 기다려주지 않는다. # 문제 있는 코드 driver.find_element(By.ID, "submit-btn").click() assert driver.find_element(By.ID, "success-message").is_displayed()클릭하고 바로 체크한다. 성공 메시지가 나타나는 데 0.5초 걸린다면? 실패한다. 내가 처음 작성한 테스트 중 30%가 이랬다. 로컬에선 통과. CI에선 실패. 로컬은 내 맥북 프로 M1이다. CI 서버는 2코어 가상머신이다. 속도가 다르다. 당연히 타이밍이 안 맞는다. 해결책은 명시적 대기. from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ECdriver.find_element(By.ID, "submit-btn").click()# 최대 10초 대기 wait = WebDriverWait(driver, 10) element = wait.until( EC.visibility_of_element_located((By.ID, "success-message")) ) assert element.is_displayed()명시적으로 조건을 기다린다. "이 요소가 보일 때까지" "이 텍스트가 나타날 때까지" 모든 테스트에 적용했다. Flaky 비율이 43개에서 28개로 줄었다. 하지만 충분하지 않았다.원인 2: 상태 관리 문제 테스트들이 서로 영향을 준다. 테스트 A가 데이터를 만든다. 테스트 B가 그 데이터를 쓴다. 테스트 C가 그 데이터를 지운다. 순서대로 실행되면 문제없다. Pytest는 병렬 실행한다. C → A → B 순서로 돌면? B가 실패한다. 데이터가 없으니까. 실제 있었던 케이스다. # test_user_creation.py def test_create_user(): user = create_user(email="test@test.com") assert user.id == 12345# test_user_login.py def test_login_existing_user(): # test_create_user가 먼저 돌았다고 가정 response = login(email="test@test.com") assert response.status_code == 200테스트 간 의존성이 있다. 독립적이지 않다. 첫 실행엔 순서가 맞아서 통과. 두 번째 실행엔 순서가 바뀌어서 실패. 해결은 격리다. @pytest.fixture(scope="function") def test_user(): # 각 테스트마다 새 유저 생성 user = create_user(email=f"test_{uuid4()}@test.com") yield user # 테스트 끝나면 삭제 delete_user(user.id)def test_login_existing_user(test_user): response = login(email=test_user.email) assert response.status_code == 200각 테스트가 자기 데이터를 만든다. 끝나면 정리한다. 순서에 상관없이 독립적으로 돌아간다. 이것도 적용했다. 28개에서 19개로 줄었다. 하지만 아직 19개가 남았다. 원인 3: 네트워크 불안정성 외부 API 호출하는 테스트들. 우리 서비스는 결제 API를 쓴다. PG사 테스트 서버에 요청 보낸다. def test_payment_processing(): response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"이 테스트는 외부 의존성이 있다. PG사 테스트 서버 상태에 달렸다. 서버 점검: 실패 네트워크 지연: 타임아웃 레이트 리밋: 429 에러 우리 코드는 멀쩡한데 실패한다. 처음엔 재시도 로직을 넣었다. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2)) def test_payment_processing(): response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"3번까지 재시도한다. 그래도 가끔 실패한다. 근본적 해결책은 모킹이다. @patch('payment_gateway.charge') def test_payment_processing(mock_charge): mock_charge.return_value = PaymentResponse(status="success") response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"외부 API를 가짜로 대체한다. 항상 예측 가능한 응답을 준다. "그럼 진짜 API 연동은 안 테스트해요?" E2E 테스트 몇 개만 실제 API 쓴다. 나머지는 모킹한다. 통합 테스트와 E2E를 분리했다. 통합 테스트는 빠르고 안정적이다. E2E는 느리지만 실제 환경이다. 19개에서 11개로 줄었다.원인 4: 동시성 문제 가장 디버깅하기 어려운 케이스. 우리 앱은 웹소켓으로 실시간 알림을 보낸다. 테스트에서 알림이 오는지 체크한다. def test_notification_received(): send_notification(user_id=123, message="Test") # 알림이 올 때까지 대기 notifications = wait_for_notification(user_id=123, timeout=5) assert len(notifications) == 1100번 중 95번은 통과한다. 5번은 알림이 안 온다. 왜일까? 웹소켓 연결 타이밍 문제였다.테스트가 시작된다 웹소켓 연결을 시작한다 알림을 보낸다 웹소켓이 아직 연결 안 됐다 알림을 못 받는다순서가 꼬인다. 레이스 컨디션이다. 해결책은 연결 확인. def test_notification_received(): # 웹소켓 연결 대기 wait_for_websocket_connection(user_id=123) # 연결 확인 후 알림 전송 send_notification(user_id=123, message="Test") notifications = wait_for_notification(user_id=123, timeout=5) assert len(notifications) == 1연결이 완료된 걸 확인하고 진행한다. 비슷한 문제가 또 있었다. 데이터베이스 트랜잭션이다. def test_user_update(): update_user(user_id=123, name="New Name") # 바로 조회 user = get_user(user_id=123) assert user.name == "New Name"가끔 실패한다. 이전 이름이 나온다. 왜? 트랜잭션 커밋이 비동기다. 업데이트 요청하고 바로 읽으면, 아직 커밋 안 된 상태를 읽는다. def test_user_update(): update_user(user_id=123, name="New Name") # 커밋 대기 wait_for_transaction_commit() user = get_user(user_id=123) assert user.name == "New Name"명시적으로 커밋을 기다린다. 11개에서 6개로 줄었다. 원인 5: 테스트 데이터 충돌 여러 테스트가 같은 데이터를 쓴다. def test_user_search(): results = search_users(query="test@test.com") assert len(results) == 1def test_user_creation(): user = create_user(email="test@test.com") assert user.email == "test@test.com"첫 번째 테스트는 1개를 기대한다. 두 번째 테스트가 먼저 돌면? 같은 이메일로 유저가 생긴다. 첫 번째 테스트가 실패한다. 2개가 검색되니까. 병렬 실행하면 더 복잡하다. A와 B가 동시에 같은 이메일로 유저 생성. 둘 다 실패할 수 있다. 중복 키 에러. 해결책은 고유한 데이터. def test_user_search(): unique_email = f"test_{uuid4()}@test.com" create_user(email=unique_email) results = search_users(query=unique_email) assert len(results) == 1def test_user_creation(): unique_email = f"test_{uuid4()}@test.com" user = create_user(email=unique_email) assert user.email == unique_emailUUID로 유니크한 값을 만든다. 충돌이 없다. 픽스처로 패턴화했다. @pytest.fixture def unique_email(): return f"test_{uuid4()}@test.com"def test_user_search(unique_email): create_user(email=unique_email) results = search_users(query=unique_email) assert len(results) == 1모든 테스트에 적용했다. 6개에서 3개로 줄었다. 마지막 3개 3개가 남았다. 하나는 브라우저 캐시 문제였다. 이전 테스트의 쿠키가 남아있어서, 로그아웃 테스트가 실패했다. 해결: 각 테스트마다 브라우저 재시작. @pytest.fixture(scope="function") def driver(): driver = webdriver.Chrome() yield driver driver.quit()하나는 날짜/시간 의존 테스트. def test_daily_report(): report = generate_daily_report(date=today()) assert report.date == today()자정을 넘기면 실패한다. 테스트 시작할 때는 11시 59분. 체크할 때는 0시 1분. 해결: 고정된 날짜 사용. def test_daily_report(): test_date = datetime(2024, 1, 15) report = generate_daily_report(date=test_date) assert report.date == test_date마지막 하나는 메모리 누수. 테스트가 100개씩 돌면, 70번째쯤에서 브라우저가 느려진다. 80번째쯤 타임아웃. 해결: 주기적 재시작. test_count = 0@pytest.fixture(scope="function") def driver(): global test_count test_count += 1 if test_count % 50 == 0: # 50개마다 브라우저 재시작 restart_browser() driver = webdriver.Chrome() yield driver driver.quit()3개를 다 잡았다. Flaky 테스트 0개. 지금 우리 시스템 Flaky 테스트를 추적하는 대시보드를 만들었다. class FlakyTracker: def __init__(self): self.test_history = {} def record_result(self, test_name, passed): if test_name not in self.test_history: self.test_history[test_name] = [] self.test_history[test_name].append(passed) # 최근 100번 실행 중 성공률 recent = self.test_history[test_name][-100:] success_rate = sum(recent) / len(recent) if success_rate < 0.95: alert_flaky_test(test_name, success_rate)성공률이 95% 미만이면 알림. 자동으로 슬랙에 리포트. "test_user_login의 성공률이 92%입니다." 즉시 확인하고 고친다. CI 설정도 바꿨다. # .github/workflows/test.yml - name: Run Tests run: pytest --reruns 2 --reruns-delay 1실패한 테스트는 자동으로 한 번 더 돌린다. 진짜 버그면 두 번 다 실패한다. Flaky면 두 번째는 통과한다. 완벽한 해결책은 아니다. 하지만 실용적이다. 테스트 실행 시간도 최적화했다.빠른 테스트: 단위 테스트, 모킹 사용 중간 테스트: 통합 테스트, 일부 모킹 느린 테스트: E2E, 실제 환경빠른 테스트는 PR마다. 중간 테스트는 머지 전. 느린 테스트는 배포 전. 각각 역할이 다르다. 결과 Flaky 테스트 비율: 3.4% → 0.1% CI 신뢰도가 올라갔다. 개발자들이 테스트 결과를 믿는다. "CI 실패했는데 확인 부탁드려요." 이제 이 메시지가 오면, 진짜 버그다. 배포 전 불안감이 줄었다. 테스트가 통과하면 배포한다. 의심 안 한다. 자동화 테스트의 가치가 올라갔다. 하지만 유지보수 시간은 늘었다. 명시적 대기, 픽스처, 모킹. 코드가 복잡해졌다. 테스트 하나 작성하는 시간:전: 15분 후: 30분두 배가 됐다. 하지만 디버깅 시간은 줄었다.전: 주당 10시간 후: 주당 2시간트레이드오프다. 초반에 시간 더 쓰고, 나중에 시간을 아낀다. 교훈 Flaky 테스트는 기술 부채다. 쌓이면 감당 못 한다. 처음엔 몇 개 괜찮아 보인다. "이거 가끔 실패하는데 뭐 괜찮겠지." 아니다. 절대 괜찮지 않다. 하나가 두 개 되고, 두 개가 열 개 된다. 어느 순간 자동화를 신뢰 못 한다. 자동화의 의미가 없어진다. 발견 즉시 고쳐야 한다. '나중에'는 없다. 두 번째. 근본 원인을 찾아야 한다. 재시도 로직으로 때우지 마라. # 이렇게 하지 마라 @retry(stop=stop_after_attempt(5)) def test_something(): # flaky test증상만 감춘다. 문제는 남아있다. 왜 실패하는지 알아야 한다. 타이밍인지, 상태인지, 네트워크인지. 세 번째. 테스트는 독립적이어야 한다. 서로 영향 주면 안 된다. 각 테스트는 자기 데이터를 만든다. 끝나면 정리한다. 순서에 상관없이 돌아간다. 네 번째. 외부 의존성을 최소화한다. 모킹 가능한 건 모킹한다. E2E는 최소한으로. 다섯 번째. 모니터링한다. 어떤 테스트가 불안정한지 추적한다. 성공률을 기록한다. 데이터로 관리한다. 여전히 어려운 것들 완벽한 해결책은 없다. 새 기능 테스트를 작성할 때, Flaky 될지 안 될지 예측 못 한다. 돌려봐야 안다. 프론트엔드 E2E는 특히 어렵다. 브라우저, 렌더링, 애니메이션. 변수가 너무 많다. 모바일 앱 테스트는 더 심하다. 디바이스마다 다르다. OS 버전마다 다르다. 완전히 안정적인 자동화는, 아직 먼 이야기다. 하지만 계속 나아진다. 3.4%에서 0.1%로 왔다. 다음 목표는 0%다. 불가능해 보인다. 시도는 해볼 거다.새벽 3시 알림은 이제 안 온다. CI가 안정적이니까. 그래도 가끔 확인한다. 혹시 모르니까. Flaky와의 전쟁은 계속된다.
- 02 Dec, 2025
밤새 돌린 테스트가 새벽 3시에 깨졌을 때
새벽 3시 알람, 그리고 깨진 파이프라인 어제 저녁 8시에 큰 기능 PR을 머지했다. 전체 테스트 스위트를 도는 건 보통 4시간 걸린다. 자동화 엔지니어라면 알 거다. 밤샘 CI는 숙명이다. 침대에 누웠는데 핸드폰이 울렸다. Jenkins 알람이다. 새벽 3시 정각. 빌드는 FAILED. 화면을 봤을 때 느낌이 왔다. 이건 단순한 실패가 아니다. 뭔가 깊은 거다. 일어나 앉았다. 잠은 포기했다. 아무도 지금 연락할 수 없다. 개발팀은 자고 있고, 나만 깨어 있다. 자동화의 밤은 혼자 버티는 거다.노트북을 켰다. 모니터 세 개를 돌렸다. 한쪽에는 Jenkins 로그, 한쪽에는 테스트 리포트, 한쪽에는 IDE를 띄웠다. 새벽 3시의 나는 이미 자동화 엔지니어가 아니라 범죄 수사관이다. 파이프라인 사건의 현장 FAILED: test_checkout_with_coupon FAILED: test_user_profile_update FAILED: test_notification_badge_count ERROR: Connection timeout at step_wait_for_element보자. 셀레늄 테스트 세 개가 떴다. 패턴이 있다. 모두 사용자 액션이 들어가는 테스트들이다. 그리고 마지막 줄. Connection timeout. 이게 핵심이다. CI 환경을 봤다. 새벽에도 파이프라인은 도는데, 리소스 사용량이 이상했다. 다른 배치 작업이랑 겹쳤나보다. 메모리 70%, CPU 50%. 정상은 아니다. 여기서 대부분의 자동화 엔지니어가 실수한다. "아, 환경 문제구나" 하고 재실행한다. 그리고 다음날 또 깨진다. 반복한다. 나는 다르게 생각했다. 환경 문제가 맞으면, 이 테스트는 프로덕션에서도 깨질 가능성이 높다. 즉 내 테스트가 너무 민감하다는 뜻이다.그래서 코드를 뜯어봤다. wait_for_element(locator, timeout=10) 10초다. 10초 wait는 신뢰할 수 없다. CI 환경이 느릴 땐 평정심이 필요하다. 내가 쓴 코드를 봤다. def wait_for_element(self, locator, timeout=10): try: WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) except TimeoutException: raise AssertionError(f"Element not found: {locator}")이 코드의 문제는 뭔가? Retry 로직이 없다. 한 번 깨지면 끝이다. Flaky 테스트의 정의다. 새벽 3시, 나는 이 코드를 고쳤다. def wait_for_element(self, locator, timeout=10, retries=3): for attempt in range(retries): try: WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) return True except TimeoutException: if attempt == retries - 1: raise AssertionError(f"Element not found after {retries} attempts: {locator}") time.sleep(2) # 2초 대기 후 재시도재시도 로직을 넣었다. 3번까지 시도한다. 2초 간격으로. 그 다음은 뭔가? 테스트 순서다. 새벽 파이프라인에선 병렬 실행도 있다. 리소스가 줄어드니까 어떤 테스트가 먼저 끝날지 모른다. 그럼 상태 관리가 문제가 된다. @pytest.fixture(autouse=True) def setup_and_teardown(): # 각 테스트마다 클린 상태에서 시작 driver.delete_all_cookies() driver.execute_script("window.sessionStorage.clear();") yield # 테스트 후 정리 if driver: driver.quit()이건 이미 있었다. 좋다. 그럼 다음은? 왜 3시에 깨졌는가 파이프라인을 분석했다. 같은 시간대에 돌고 있던 게 뭐였나?01:00 - 앱 빌드 (30분) 01:30 - 데이터베이스 마이그레이션 테스트 (90분) 02:10 - 내 자동화 테스트 (4시간)아. DB 마이그레이션이 끝나는 게 2시간 40분 즈음이다. 거기서 뭔가 리소스를 남기고 있었나? 로그를 더 들었다. [02:47] Database migration completed [02:48] Automation tests started [02:49] Chrome driver spawned (port 4444) [03:02] Connection pool exhausted아하. 크롬 인스턴스가 정상 종료가 안 되고 있었다. 이전 빌드에서 고아 프로세스가 남아있었다. 메모리를 점점 먹고 있었고, 결국 3시쯤에 터진 거다.이건 내 코드 문제가 아니다. CI 환경 설정 문제다. 하지만 내 책임이다. 왜냐면 내가 테스트를 견고하게 만들지 못했으니까. 새벽 4시, 나는 두 가지를 했다. 1. Dockerfile 수정 # 이전 RUN apt-get install -y chromium-browser# 이후 RUN apt-get install -y chromium-browser RUN echo "pkill -f chrome || true" > /cleanup.shCI 시작 전에 고아 프로세스를 모두 정리하는 스크립트를 넣었다. 2. 테스트 타임아웃 늘림 @pytest.mark.timeout(300) # 5분으로 늘림 def test_checkout_with_coupon(): ...느린 CI 환경을 고려했다. 10초는 너무 짧다. 3. 로그 레벨 상향 logging.basicConfig(level=logging.DEBUG) logger.debug(f"Waiting for {locator}, timeout={timeout}") logger.debug(f"Attempt {attempt+1}/{retries}")다음에 같은 일이 생기면 더 빨리 디버깅할 수 있게. 아침 6시, 결론 다시 실행했다. 이번엔 통과했다. 모든 테스트. 파이프라인이 초록색이 됐다. 아침 9시, 팀 미팅에서 뭐라고 할까 고민했다. 아무것도 안 하기로 했다. 개발팀에게 "야간 파이프라인에 환경 문제가 있었어요"라고 하면 뭐 하냐. 나 혼자 밤새 고칠 수 있는 거다. 대신 테스트 리포트에는 이렇게 적었다. [FIX] Improved wait_for_element stability - Added retry mechanism (3 attempts, 2s interval) - Extended timeout for CI environment - Added debug logging for failed attempts[INFRA] Cleaned up Docker initialization - Added pre-cleanup for orphaned Chrome processes - Improved resource allocation개발팀은 읽지 않을 거다. 하지만 다음 누군가 밤샘할 때 필요한 정보다. 자동화 엔지니어라는 게 이런 거다. 야간 파이프라인의 짐을 혼자 지는 거. 그 대신 그 파이프라인이 믿을 수 있는 파이프라인이 되는 거. 새벽 3시 깨진 테스트는 나한테 뭘 줬나? 2시간의 수면 부채? 아니다. 안정적인 자동화 프레임워크의 법칙 하나를 줬다. 테스트는 최악의 환경을 고려해서 만들어야 한다. 왜냐면 CI 환경은 항상 최악이니까. 오늘따라 커피가 여섯 잔이다.밤샘은 버티는 거지만, 버티는 방법을 배우는 게 진짜 자동화 엔지니어다.[IMAGE_1] [IMAGE_2] [IMAGE_3]