초록불을 유지하던 CI가 어느 날부터 같은 커밋을 두 번 돌리면 한 번은 통과하고 한 번은 실패하기 시작하면, 팀은 조용히 위험한 습관을 들인다. 바로 “일단 재시도(retry) 버튼을 누른다”는 습관이다. 이렇게 확률적으로 통과와 실패를 오가는 테스트를 플레이키 테스트(flaky test)라고 부른다. 코드가 실제로 깨진 게 아닌데 파이프라인이 빨갛게 물들면, 개발자는 실패를 신뢰하지 않게 되고 결국 진짜 회귀(regression)마저 “또 플레이키겠지” 하고 지나쳐 버린다.
플레이키의 진짜 비용은 낭비된 CI 시간이 아니라 신호 대 잡음비의 붕괴다. 실패가 믿을 수 없는 신호가 되는 순간 테스트 스위트 전체가 안전망으로서의 가치를 잃는다. 이 글은 실무 전략을 세 단계로 정리한다. 재현(reproduce)으로 원인을 좁히고, 격리(isolate)로 오염원을 끊고, 그래도 남는 것은 쿼런틴(quarantine)으로 분리하되 반드시 추적한다. 재시도로 덮는 게 아니라 관측하고 수렴시키는 관점이다.
플레이키는 버그가 아니라 결정성의 부재다
플레이키를 “가끔 실패하는 나쁜 테스트”로만 보면 대응이 임기응변에 그친다. 본질은 테스트 결과가 대상 코드 외의 무언가(시간, 실행 순서, 스레드 스케줄링, 네트워크 지연, 공유 상태 등)에 의존한다는 것이다. 즉 플레이키는 결정성(determinism)이 깨진 지점을 알려주는 진단 신호다.
그래서 첫 원칙은 “실패를 통계로 취급한다”는 것이다. 100번 중 3번 실패인지 30번 실패인지에 따라 원인의 성격이 다르다. 낮은 빈도는 경쟁 상태(race)나 타이밍, 높은 빈도는 순서 의존이나 공유 상태 오염을 의심하게 한다.
# 단일 테스트를 반복 실행해 실패율을 측정 (pytest 예시)
# -x 없이 끝까지 돌리고, 실패 횟수만 집계한다
fail=0
for i in $(seq 1 100); do
# -p no:randomly 로 순서 랜덤화를 잠시 끄고 순수 반복만 본다
pytest tests/test_orders.py::test_settlement -q -p no:randomly >/dev/null 2>&1
|| fail=$((fail+1))
done
echo "실패율: ${fail}/100" # 예: 4/100 → 낮은 빈도, 타이밍/경쟁 의심
이 실패율이 이후 모든 판단의 기준선이다. 수정 뒤 다시 측정해 0으로 수렴했는지 확인해야 “고친 것 같다”가 아니라 “고쳤다”고 말할 수 있다.
재현: 무작위 시드를 고정하고 순서를 뒤흔든다
재현의 핵심은 재현 가능성(같은 조건이면 같은 실패)과 노출 확률(실패를 더 자주 튀어나오게 하기)을 동시에 다루는 것이다. 상충하는 듯하지만 시드(seed)를 고정하면 재현성을 확보하면서 “나쁜 순서”를 재사용할 수 있다.
순서 의존성은 가장 흔하면서 놓치기 쉬운 원인이다. 테스트 A가 전역 상태를 남기고 B가 그 상태에 우연히 기대면 실행 순서가 바뀌는 순간 B가 실패한다. 순서를 무작위화하되 실패한 순서의 시드를 로그에 남겨 정확히 재생한다.
# pytest-randomly: 매 실행마다 순서를 섞고, 사용한 시드를 출력한다
pytest -p randomly -q
# 출력 예: Using --randomly-seed=534126789
# 위에서 실패가 나면 그 시드를 그대로 재사용해 "나쁜 순서"를 재생
pytest -p randomly --randomly-seed=534126789 -q
# 이제 결정적으로 재현되므로 이분 탐색으로 오염원 테스트를 좁힐 수 있다
재현 확률을 높이려면 실행 환경을 CI보다 더 가혹하게 만든다. stress-ng --cpu "$(nproc)"로 코어를 점유한 상태에서 반복하면 스케줄링 지연이 유발되어 평소 숨어 있던 타이밍 결함이 표면으로 올라온다.
흔한 원인 1: 시간과 타임존 의존
단골 원인은 현재 시각에 대한 암묵적 의존이다. 코드가 datetime.now()나 Date.now()를 직접 호출하면 자정 경계·월말·윤년·서머타임 전환 같은 특정 시점에만 실패하는 폭탄이 된다. 해결은 시간을 주입 가능한 의존성으로 만드는 것이다. 코드가 시계(clock)를 인자로 받게 하고 테스트에서 고정 시각을 주입하면 이 확률성이 원천적으로 사라진다.
from datetime import datetime, timezone, timedelta
# 나쁜 예: 내부에서 now()를 직접 호출 → 실행 시각에 결과가 좌우됨
def is_expired_bad(token):
return token.expires_at < datetime.now(timezone.utc)
# 좋은 예: 시각을 인자로 주입 (기본값만 now)
def is_expired(token, at=None):
now = at or datetime.now(timezone.utc)
return token.expires_at < now
# 테스트: 시각을 고정하면 경계 조건이 결정적으로 검증된다
def test_expiry_boundary():
fixed = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
token = Token(expires_at=fixed + timedelta(seconds=1))
assert is_expired(token, at=fixed) is False # 만료 1초 전
assert is_expired(token, at=fixed + timedelta(seconds=2)) is True
타임존도 같은 부류다. CI 러너의 로컬 타임존이 개발자 머신과 다르면 타임존 미명시 코드가 지역에 따라 다른 결과를 낸다. 테스트 프로세스의 TZ를 고정하고 코드는 항상 타임존을 명시한 datetime을 다룬다.
흔한 원인 2: 공유 상태와 순서 의존
테스트 사이에 격리되지 않은 공유 상태(DB에 남은 레코드, 모듈 캐시, 싱글턴, 정적 카운터)가 있으면 순서에 따라 결과가 달라진다. 각 테스트가 깨끗한 상태로 시작하지 못하면 앞 테스트의 잔여물이 다음을 오염시킨다.
가장 견고한 격리는 테스트마다 상태를 트랜잭션으로 감싸 롤백하거나 격리된 스키마·컨테이너를 쓰는 것이다. 아래 픽스처는 각 테스트를 트랜잭션으로 감싸고 종료 시 롤백해 어떤 순서에도 서로 영향을 주지 않는다.
import pytest
@pytest.fixture
def db_session(engine):
# 테스트마다 별도 커넥션 + 트랜잭션을 열고, 끝나면 무조건 롤백
connection = engine.connect()
trans = connection.begin()
session = Session(bind=connection)
try:
yield session # 테스트는 이 세션 안에서만 데이터를 만짐
finally:
session.close()
trans.rollback() # 커밋 여부와 무관하게 전부 되돌림
connection.close()
# → 실행 순서를 무작위화해도 각 테스트는 빈 DB에서 시작한다
모듈 캐시나 싱글턴처럼 전역에 사는 상태는 트랜잭션으로 못 잡으므로, 종료 시 명시적 정리(teardown)를 붙이거나 전역 상태를 없애는 설계로 바꾼다. 순서 무작위화를 CI에 항상 켜두면 이런 숨은 의존성이 배포 전에 드러난다.
흔한 원인 3: 비동기와 고정 대기(sleep)
비동기 동작을 고정 시간 sleep으로 기다리는 코드는 플레이키의 온상이다. 200ms를 기다리지만 CI가 부하를 받으면 300ms가 걸려 실패하고, 5초로 늘리면 스위트 전체가 느려진다. 느림과 불안정 사이 최악의 절충이다.
올바른 접근은 시간이 아니라 조건을 기다리는 것이다. 원하는 상태가 될 때까지 짧은 간격으로 폴링하되 전체 타임아웃으로 무한 대기를 막는다. 조건이 만족되면 즉시 진행하니 빠르고, 느린 환경에서도 안정적이다.
// 나쁜 예: 고정 대기 — 빠르면 낭비, 느리면 실패
await new Promise(r => setTimeout(r, 200));
expect(await getStatus(jobId)).toBe('done');
// 좋은 예: 조건을 폴링 (짧은 간격 + 전체 타임아웃)
async function waitUntil(check, { timeout = 5000, interval = 50 } = {}) {
const deadline = Date.now() + timeout;
while (Date.now() setTimeout(r, interval));
}
throw new Error('조건이 타임아웃 내에 충족되지 않음');
}
await waitUntil(async () => (await getStatus(jobId)) === 'done');
외부 서비스 의존도 마찬가지다. 실제 네트워크를 타는 테스트는 상대 서버의 상태·지연에 좌우되어 본질적으로 플레이키하니, 단위·통합 레벨에서는 목(mock)으로 대체하고 실제 연동은 별도 계약(contract) 테스트로 분리한다.
쿼런틴: 파이프라인을 지키되 절대 잊지 않는다
원인을 즉시 못 고치는 테스트를 그대로 두면 신뢰 붕괴가 시작되고, 삭제하면 커버리지 구멍이 생긴다. 절충안이 쿼런틴이다. 의심 테스트를 메인에서 비차단(non-blocking)으로 옮기되 별도로 계속 실행하며 추적한다. 핵심은 “무시”가 아니라 “격리 후 관측”이다.
구현은 테스트에 마커를 달아 메인 잡에서 제외하되 별도 잡에서 실행해 결과를 기록하는 식이다. 아래는 flaky 마커 테스트를 메인에서 빼고 전용 잡에서는 실패해도 파이프라인을 막지 않는다.
# .gitlab-ci.yml — 쿼런틴 잡 분리
test:
stage: test
# flaky 마커가 붙은 테스트는 메인 게이트에서 제외 → 배포를 막지 않음
script:
- pytest -m "not flaky" --junitxml=report.xml
test-quarantine:
stage: test
allow_failure: true # 실패해도 파이프라인은 초록불 유지
script:
# 쿼런틴 테스트를 반복 실행해 현재 실패율을 산출
- pytest -m flaky --count=20 --junitxml=quarantine.xml
artifacts:
reports:
junit: quarantine.xml # 실패율 추이를 리포트로 축적
when: always
마커에는 @pytest.mark.flaky(reason="타이밍 경쟁 의심, 이슈 #4821")처럼 이유와 추적 이슈를 함께 붙여 왜 격리했는지가 코드에 남게 한다. 이유 없는 skip은 그 자체로 부채가 되어 영원히 방치된다.
쿼런틴에는 반드시 탈출 조건과 만료가 있어야 한다. 각 항목에 담당자와 기한을 부여해, 실패율이 일정 기간 0에 수렴하면 메인으로 복귀시키고 기한이 지나면 재작성이나 대상 코드 재설계를 결정한다.
재시도(retry)는 진통제일 뿐 치료가 아니다
많은 CI 도구가 실패 테스트를 자동 재시도하는 옵션을 제공한다. 유혹적이지만 위험하다. 무분별한 재시도는 플레이키를 숨겨 실패율을 관측 불가능하게 만들고, 그 사이 진짜 경쟁 상태 버그가 프로덕션으로 샌다.
재시도를 쓴다면 두 조건을 지킨다. 첫째, 재시도가 발생한 사실 자체를 기록해 실패율 지표로 남긴다. 둘째, 명확히 플레이키로 분류된 테스트에만 한정한다. 초록불이 떴어도 “3번 중 2번 실패했다”는 사실이 대시보드에 남아야 한다. 아래처럼 훅으로 재시도를 집계해 메트릭으로 방출하면 된다.
# pytest-rerunfailures 사용 시, 훅으로 재시도를 집계해 지표화
def pytest_runtest_logreport(report):
if report.when == "call" and getattr(report, "rerun", 0):
# 재시도 횟수를 메트릭으로 방출 → 실패율 추이 대시보드에 축적
emit_metric("test.rerun", tags={"nodeid": report.nodeid})
결국 재시도와 쿼런틴의 차이는 관측 가능성에 있다. 재시도는 결과를 덮어 신호를 지우고, 쿼런틴은 옆으로 옮겨 보존한다. 남기는 쪽을 택해야 나중에 고칠 수 있다.
마무리
플레이키는 게으른 테스트가 아니라 결정성이 깨진 지점을 가리키는 신호다. 반복 실행으로 실패율 기준선을 잡고, 시드 고정·순서 무작위화·부하 주입으로 재현한다. 원인은 대개 시간 의존·공유 상태·고정 대기 셋 중 하나이며, 각각 시각 주입·트랜잭션 격리·조건 폴링으로 근본 해결한다. 못 고치는 것은 쿼런틴으로 옮겨 비차단으로 추적하되 사유·담당자·기한을 붙인다.
가장 경계할 것은 자동 재시도로 문제를 눈에서 지우는 것이다. 성숙도는 “얼마나 빨리 재시도를 누르느냐”가 아니라 “얼마나 많은 실패를 데이터로 남겨 수렴시키느냐”로 판가름 난다. 테스트 스위트의 신뢰는 초록불의 개수가 아니라, 빨간불이 정확할 때 지켜진다.
자주 묻는 질문
Q. 플레이키 테스트를 그냥 삭제하면 안 되나요?
A. 검증하던 동작이 여전히 중요하다면 삭제는 커버리지 구멍을 남깁니다. 플레이키는 검증 대상이 아니라 검증 방식이 결정적이지 않다는 뜻인 경우가 많으므로, 쿼런틴으로 옮겨 원인을 조사하고 결정성을 회복시킨 뒤 메인으로 복귀시키는 것이 원칙입니다.