같은 커밋을 아무것도 안 바꾸고 다시 돌렸는데 이번엔 초록불이 뜬다. 어제까지 멀쩡하던 테스트가 오늘 아침 PR에서 빨간불을 내고, “재시도(Re-run)” 버튼을 한 번 누르면 통과한다. 팀 채널엔 “그냥 다시 돌리세요”라는 말이 관용구처럼 돌아다니고, 어느새 CI 실패 알림을 보고도 아무도 진지하게 열어보지 않는다. 이것이 플래키 테스트(flaky test)가 조직을 좀먹는 전형적인 풍경이다.

플래키 테스트의 진짜 피해는 낭비된 CI 시간이 아니라 신뢰의 붕괴다. 빨간불이 진짜 버그인지 노이즈인지 구분할 수 없게 되면, 사람들은 모든 실패를 노이즈로 취급하기 시작한다. 그 순간 CI는 품질 게이트로서의 기능을 잃는다. 이 글에서는 플래키 테스트를 사람의 눈이 아니라 시스템으로 다루는 방법을 다룬다. 자동으로 감지하고, 파이프라인을 막지 않게 격리하고, 재시도의 함정을 피하면서 근본 원인을 고쳐 나가는 실전 흐름이다.

플래키는 왜 생기는가

플래키 테스트는 “같은 코드, 같은 입력인데 결과가 들쭉날쭉한 테스트”다. 원인은 대부분 테스트 코드가 명시적으로 통제하지 못한 비결정성에서 온다. 몇 가지로 나눠보면 대응 지점이 명확해진다.

  • 타이밍/경쟁 상태: sleep(0.5) 같은 고정 대기, 비동기 작업 완료를 폴링 없이 가정하는 경우
  • 외부 의존: 실네트워크 호출, 실시간 서버, 공유 DB — 지연이나 상태 잔존으로 결과가 흔들린다
  • 테스트 순서 의존: 앞 테스트가 남긴 전역 상태·임시 파일·DB 레코드에 뒤 테스트가 의존
  • 비결정 데이터: 현재 시각, 랜덤 시드, 딕셔너리/셋 순회 순서, 타임존
  • 리소스 경합: 병렬 실행 시 같은 포트·파일·테스트 계정을 놓고 다투는 경우

중요한 건 “플래키 = 테스트가 나쁨”이 아니라는 점이다. 종종 플래키는 실제 코드의 경쟁 상태를 드러내는 신호다. 프로덕션에서 1000번에 3번 터질 버그를 테스트가 먼저 잡아준 것일 수 있다. 그래서 무조건 재시도로 덮는 태도가 위험하다.

재시도로 자동 감지하기

플래키를 사람이 눈으로 찾는 건 지속 가능하지 않다. 가장 실용적인 감지 신호는 단순하다. 실패한 테스트를 같은 커밋에서 다시 돌렸을 때 통과하면 그 테스트는 플래키다. pytest에서는 pytest-rerunfailures로 실패 케이스만 재시도할 수 있다.

# 실패한 테스트만 최대 2번 재시도, 재시도 간 1초 대기
pytest --reruns 2 --reruns-delay 1 -q

# JUnit XML도 함께 뽑아 이력 집계에 사용
pytest --reruns 2 --reruns-delay 1 --junitxml=report.xml -q

여기서 끝내면 안 된다. “재시도로 통과했다”는 사실 자체를 기록해 두어야 이력이 쌓인다. JUnit XML은 재실행 정보를 <rerunFailure> 요소로 남기므로, 이걸 파싱하면 어떤 테스트가 첫 시도에 실패했다가 재시도로 통과했는지를 프로그램으로 뽑아낼 수 있다.

import xml.etree.ElementTree as ET

def find_flaky(report_path: str) -> list[str]:
    """첫 시도 실패 → 재시도 통과한 테스트(=플래키) 이름 목록."""
    tree = ET.parse(report_path)
    flaky = []
    for tc in tree.iter("testcase"):
        name = f'{tc.get("classname")}::{tc.get("name")}'
        # rerunFailure가 있으면서 최종적으로 failure/error가 없으면 플래키
        had_rerun = tc.find("rerunFailure") is not None
        final_fail = tc.find("failure") is not None or tc.find("error") is not None
        if had_rerun and not final_fail:
            flaky.append(name)
    return flaky

if __name__ == "__main__":
    for t in find_flaky("report.xml"):
        print(f"FLAKY {t}")

단발성 판정은 오탐이 많다. 진짜 신호는 이력 집계에서 나온다. 각 실행 결과를 테스트별로 누적하고, 최근 N회 실행 중 상태가 뒤집힌 비율로 판단한다. 이력은 별도 저장소(테이블 하나면 충분하다)에 append-only로 쌓는 게 관리가 쉽다.

import sqlite3, datetime as dt

def record(db, test_id, status, commit, first_try_failed):
    db.execute(
        "INSERT INTO test_runs(test_id, status, commit_sha, first_try_failed, ts)"
        " VALUES (?,?,?,?,?)",
        (test_id, status, commit, int(first_try_failed),
         dt.datetime.utcnow().isoformat()),
    )

def flaky_rate(db, test_id, window=50):
    rows = db.execute(
        "SELECT first_try_failed FROM test_runs WHERE test_id=?"
        " ORDER BY ts DESC LIMIT ?", (test_id, window)
    ).fetchall()
    if not rows:
        return 0.0
    return sum(r[0] for r in rows) / len(rows)  # 첫 시도 실패 비율

격리(Quarantine): 파이프라인을 인질로 잡지 마라

플래키를 발견해도 그걸 고치는 데는 시간이 걸린다. 그동안 이 테스트가 계속 메인 파이프라인을 빨갛게 만들면, 관련 없는 PR들이 발이 묶이고 결국 “다들 무시하기” 문화로 회귀한다. 그래서 감지된 플래키는 즉시 격리한다. 격리란 테스트를 삭제하는 게 아니라, 계속 실행하되 그 실패가 파이프라인을 막지 못하게 분리하는 것이다.

pytest에서는 마커로 격리군을 표시하고, 별도 잡에서 non-blocking으로 돌린다. allowlist(격리 목록)는 코드가 아니라 데이터로 관리하면 PR 없이도 등록/해제가 쉽다.

# conftest.py — quarantine.txt에 적힌 테스트에 자동으로 마커 부착
from pathlib import Path

QUARANTINED = set(
    Path("quarantine.txt").read_text().split()
) if Path("quarantine.txt").exists() else set()

def pytest_collection_modifyitems(config, items):
    for item in items:
        if item.nodeid in QUARANTINED:
            item.add_marker("quarantine")
# CI: 격리 테스트는 별도 잡, 실패해도 파이프라인 안 막음
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # 격리된 것 제외하고만 게이트로 사용 (여기 실패 = 진짜 실패)
      - run: pytest -m "not quarantine" --junitxml=main.xml -q

  quarantine:
    runs-on: ubuntu-latest
    continue-on-error: true          # 실패해도 워크플로 성공 처리
    steps:
      - uses: actions/checkout@v4
      # 격리군은 재시도까지 붙여 계속 관찰만 함
      - run: pytest -m quarantine --reruns 3 --junitxml=quar.xml -q

격리는 “임시 대피소”여야 한다. 여기서 반드시 지켜야 할 규율이 있다.

  • 격리에는 소유자와 만료일을 붙인다. 주인 없는 격리는 영원히 방치된다
  • 격리 목록에 총량 상한을 둔다(예: 전체의 1%). 넘으면 신규 격리를 막고 정리를 강제한다
  • 격리된 테스트도 계속 돌려 이제 안정됐는지를 데이터로 확인하고, 안정되면 다시 게이트로 복귀시킨다

재시도 전략의 함정

재시도는 감지 도구이자 임시 완충재지만, 남용하면 독이 된다. 가장 위험한 시나리오는 진짜 버그를 재시도가 은폐하는 경우다. 코드에 3%의 확률로 터지는 경쟁 상태가 있고 테스트가 이를 정확히 잡아냈는데, “재시도 3회”로 자동 통과시키면 실패 확률이 0.003%로 떨어져 아무도 눈치채지 못한다. 버그는 그대로 프로덕션에 나간다.

그래서 재시도는 몇 가지 원칙 아래에서만 써야 한다.

  • 재시도 횟수를 작게(1~2회) 유지한다. 5회, 10회는 감지가 아니라 은폐다
  • 재시도로 통과한 케이스는 초록불이어도 반드시 기록한다. 통과했다고 잊으면 이력이 안 쌓인다
  • 새로 추가·수정된 테스트에는 재시도를 적용하지 않는다. 갓 만든 테스트가 플래키하면 그건 감춰선 안 될 신호다
  • 재시도는 격리군에 더 후하게, 게이트군에는 인색하게 준다
# 재시도 통과를 소리 없이 넘기지 않기 — 훅에서 경고 로깅
def pytest_runtest_logreport(report):
    # rerunfailures가 붙인 속성으로 재시도 통과 여부 판별
    if report.when == "call" and getattr(report, "outcome", "") == "passed":
        if getattr(report, "rerun", 0):  # rerun 횟수 > 0 이면 원래 실패했던 것
            print(f"[FLAKY-PASS] {report.nodeid} passed after "
                  f"{report.rerun} retries")

근본 원인 수정

격리와 재시도는 시간을 벌어줄 뿐 문제를 해결하진 않는다. 결국은 고쳐야 한다. 원인 유형별로 정석 처방이 있다.

  • 타이밍: 고정 sleep을 조건 폴링으로 교체한다. “0.5초 기다림”이 아니라 “이 조건이 참이 될 때까지, 최대 5초”
  • 외부 의존: 네트워크·시간·랜덤을 모두 주입 가능한 인터페이스로 감싸고 테스트에서 mock/fake로 대체한다
  • 순서 의존: pytest-randomly로 순서를 매번 섞어 돌려 숨은 의존을 강제로 드러낸다
  • 상태 잔존: fixture의 teardown에서 임시 리소스를 확실히 정리하고, 테스트마다 격리된 DB 트랜잭션을 롤백한다
# 나쁜 예: 고정 대기 → 느린 CI에서 간헐 실패
def test_job_done_bad(client):
    client.start_job()
    time.sleep(0.5)                      # 운 나쁘면 아직 안 끝남
    assert client.status() == "done"

# 좋은 예: 조건을 폴링, 명확한 타임아웃
def wait_until(fn, timeout=5.0, interval=0.05):
    end = time.monotonic() + timeout
    while time.monotonic() < end:
        if fn():
            return True
        time.sleep(interval)
    return False

def test_job_done_good(client):
    client.start_job()
    assert wait_until(lambda: client.status() == "done"), "job did not finish in 5s"

지표와 대시보드

플래키 문제는 “느낌”으로 관리하면 반드시 방치된다. 숫자로 봐야 한다. 핵심 지표는 플래키 비율(flaky rate)이다. 전체 테스트 실행 중 첫 시도에 실패했다가 재시도로 통과한 비율로 정의하면, 팀 전체의 CI 신뢰도를 한 눈에 볼 수 있다.

{
  "period": "2026-08-04 ~ 2026-08-10",
  "total_runs": 8420,
  "flaky_passes": 118,
  "flaky_rate": 0.014,
  "quarantined": 6,
  "quarantine_budget": 12,
  "top_offenders": [
    { "test": "tests/api/test_payment.py::test_webhook", "flaky_rate": 0.21, "owner": "@payments" },
    { "test": "tests/e2e/test_checkout.py::test_flow",   "flaky_rate": 0.09, "owner": "@web" }
  ]
}

이 데이터를 팀별로 쪼개 주간 리포트로 돌리고, top offenders에 소유팀을 붙여 책임 소재를 분명히 한다. 실용적인 운영 기준은 이렇다. 전체 플래키 비율 1% 이하를 목표로 하고, 단일 테스트가 최근 50회 실행 중 5% 넘게 첫 시도 실패를 내면 자동으로 격리 후보로 올려 소유팀에 알림을 보낸다. 감지·격리·알림·복귀가 사람의 기억이 아니라 파이프라인에 붙어 자동으로 돌아가야 지속된다.

마무리

플래키 테스트와의 싸움은 개별 테스트를 하나하나 고치는 일이 아니라, 플래키를 다루는 시스템을 갖추는 일이다. 재시도로 자동 감지하고, 격리로 파이프라인을 지키고, 이력과 지표로 우선순위를 정해 근본 원인을 순차적으로 없앤다. 이때 재시도는 감지 수단일 뿐 해결책이 아니며, 격리는 삭제가 아니라 만료일 있는 임시 대피소라는 점을 잊으면 안 된다. 이 규율이 없으면 재시도는 진짜 버그를 덮고, 격리는 방치된 죽은 테스트의 무덤이 된다. 궁극적인 목표는 단순하다. 빨간불이 뜨면 팀이 그것을 믿고 멈추는 것. CI에 대한 신뢰를 회복하는 것이야말로 플래키 대응의 진짜 성과다.

자주 묻는 질문

Q. 그냥 실패한 테스트를 재시도로 통과시키면 편한데 왜 문제인가요?
A. 재시도는 실제 코드의 경쟁 상태처럼 진짜 버그가 만든 실패까지 덮어버릴 수 있기 때문입니다. 3%로 터지는 버그를 3회 재시도로 자동 통과시키면 실패 확률이 0.003%로 떨어져 아무도 못 봅니다. 재시도는 감지와 시간 벌기 용도로만 쓰고, 통과했더라도 반드시 기록해 이력으로 남겨야 합니다.

Q. 격리한 테스트는 언제, 어떻게 다시 게이트로 되돌리나요?
A. 격리군도 CI에서 계속 실행하며 이력을 쌓습니다. 근본 원인을 고친 뒤 최근 N회(예: 50회) 연속으로 첫 시도 성공을 유지하면 안정됐다고 보고 격리 목록에서 제거해 게이트로 복귀시킵니다. 격리에 만료일과 소유자를 반드시 붙여, 주인 없이 영원히 격리에 남는 테스트가 생기지 않게 해야 합니다.

Q. 테스트가 플래키한 건 항상 테스트 코드 잘못인가요?
A. 아닙니다. 상당수는 프로덕션 코드에 숨은 경쟁 상태나 타이밍 버그를 테스트가 먼저 잡아낸 신호입니다. 그래서 플래키를 발견하면 “테스트를 어떻게 안정화할까”만이 아니라 “실제 코드에 비결정성이 있는가”를 함께 따져야 합니다. 원인이 프로덕션 쪽이라면 그건 반드시 고쳐야 할 진짜 버그입니다.