Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가

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 EC

driver.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) == 1

100번 중 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) == 1

def 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) == 1

def test_user_creation():
    unique_email = f"test_{uuid4()}@test.com"
    user = create_user(email=unique_email)
    assert user.email == unique_email

UUID로 유니크한 값을 만든다. 충돌이 없다.

픽스처로 패턴화했다.

@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와의 전쟁은 계속된다.