Flaky Test를 추적하기 위해 사용하는 5가지 Claude Code 패턴
요약
간헐적으로 실패하는 Flaky Test를 디버깅하기 위해 Claude Code를 활용하는 5가지 실전 패턴을 소개합니다. 테스트 실패를 재현하고, 로그를 분석하며, 근본 원인을 파악하는 구체적인 프롬프트와 워크플로우를 다룹니다.
핵심 포인트
- Flaky Test는 단순 재시도가 아닌 근본 원인 해결이 필요함
- Claude Code를 이용해 실패할 때까지 테스트를 반복 실행하고 로그를 캡처함
- 통과한 실행과 실패한 실행의 로그 차이를 분석하여 동작의 차이를 식별함
- 재시도 로직(retry)은 버그를 해결하는 것이 아니라 숨기는 것임을 인지해야 함
요약 (TL;DR)
Flaky Test(간헐적 테스트 실패)는 최악의 버그 유형입니다. 시간을 낭비하게 만들고, CI(지속적 통합)에 대한 신뢰를 떨어뜨리며, 보통 "그냥 다시 실행해봐"라는 임시방편 아래 실제 Race Condition(경쟁 상태)이나 Shared-state(공유 상태) 버그를 숨기고 있습니다. 저는 지난 몇 달 동안 Claude Code를 Flaky Test 추적 파트너로 사용해 왔으며, "가끔 실패함"을 "정확한 원인과 해결책"으로 일관되게 바꿔주는 5가지 패턴을 찾아냈습니다. 이 포스트에서는 실제 프롬프트와 터미널 출력 결과와 함께 각 패턴을 살펴봅니다.
문제점
모든 팀에는 몇 개의 테스트가 20번 실행 중 1번꼴로 실패하고, 아무도 그 이유를 모르며, "그냥 재실행해"라는 불문율이 존재하는 테스트 스위트가 하나씩 있습니다. 저도 얼마 전 그런 스위트를 물려받았습니다. CI에서는 간헐적으로 실패하지만 로컬에서는 거의 매번 통과하는 약 15개의 테스트가 있었습니다. 전형적인 증상은 다음과 같습니다:
- CI의 더 느리고 경합이 심한 CPU 환경에서만 테스트 타임아웃(Timeout)이 발생함
- 두 테스트가 데이터베이스 행(Row)을 암묵적으로 공유하여 병렬 실행 시 서로 간섭함
- "랜덤" UUID 생성기에 시드(Seed)가 설정되지 않아, 수백 번의 실행 중 한 번꼴로 충돌이 발생함
async호출이 이벤트 루프(Event-loop) 타이밍에 따라 다른 순서로 해결됨
Flaky Test의 어려운 점은 수정하는 것이 아니라, 디버깅을 시작할 수 있을 만큼 신뢰성 있게 이를 "재현(Reproduce)"하는 것입니다. 버그가 5%의 확률로만 나타난다면, 한 시간 동안 코드를 뚫어지게 쳐다봐도 아무런 잘못을 발견할 수 없습니다. 저는 또 다른 "sleep(2000)을 추가하고 기도하기" 식의 해결책이 아닌, 반복 가능한 프로세스를 원했습니다.
이 과정을 공식화하게 만든 구체적인 사건은 다음과 같습니다. 결제(Checkout) 테스트가 CI에서 하루에 한 번꼴로 실패하기 시작했는데, 항상 다른 Assertion(단언)에서 실패했고 로컬에서는 전혀 재현되지 않았습니다. 세 명의 엔지니어가 각각 오후 시간을 할애하여 이를 조사했지만, 재시도(Retry) 로직만 추가하고 넘어갔습니다. 제 차례가 되었을 때 그 테스트에는 이미 retry: 2가 조용히 포함되어 있었음에도 여전히 가끔 실패하고 있었습니다. 재시도가 버그를 해결한 것이 아니라 숨기고 있었던 것입니다. 이는 보통 아무도 아직 근본 원인을 찾지 못했다는 신호입니다.
해결 방법
1. 의심되는 테스트가 실패할 때까지 루프를 돌리고, 모든 실패를 캡처하세요
첫 번째 단계는 항상 Flakiness (불안정한 테스트)를 일회성 사건으로 취급하지 않는 것입니다. 저는 Claude Code에게 특정 테스트를 N번 재실행하고, 실패할 때마다 (단순히 통과/실패 여부만이 아니라) 전체 출력 내용을 덤프(dump)하는 타이트한 루프를 작성하도록 요청합니다:
for i in $(seq 1 100); do
echo "=== run $i ==="
npx jest src/orders/checkout.test.ts -t "applies discount" --silent 2>&1 | tee -a /tmp/flaky-run.log
...
의심되는 테스트에 대해 이를 실행하면 두 가지를 빠르게 알 수 있습니다. 실제 실패율(1/100 대 1/5은 디버깅 전략을 완전히 바꿉니다)과, 결정적으로 — 가상의 실패가 아닌 실제 실패로부터 얻은 Stack Trace (스택 트레이스)입니다. 저는 최소 3개의 실제 실패 로그를 손에 쥐기 전에는 절대 가설을 세우기 시작하지 않습니다.
2. 통과한 실행과 실패한 실행을 Diff (차이 분석) 하세요
통과와 실패가 모두 포함된 로그를 확보하면, 두 로그를 모두 Claude Code에게 전달하고 코드(code)가 아닌 _동작(behavior)_의 실행 경로를 Diff (차이 분석) 하도록 요청합니다:
"여기 이 테스트를 100번 실행한 로그가 있고, 그 중 6번 실패했습니다. 통과한 실행과 실패한 실행의 Setup/Teardown (설정/정리) 훅의 타이밍과 순서를 비교해 주세요. 무엇이 다른가요?"
이 지점이 에이전트(Agent)가 수동 디버깅보다 가치를 증명하는 부분입니다. 에이전트는 길고 노이즈가 많은 두 개의 로그를 동시에 파악하고 Divergence point (분기점, 예: "실패한 실행에서는 이전 테스트의 afterEach가 DB Lock을 해제하기 전에 이 테스트의 beforeEach가 시작되었습니다")를 찾아낼 수 있습니다. 이는 Shared-state (공유 상태) 버그이며, 일단 발견하고 나면 사후에는 명백해 보이지만 400줄짜리 로그를 눈으로 직접 확인하기에는 정말 어려운 문제입니다.
앞서 언급한 checkout 테스트의 경우, 실제 차이가 발생하는 원인은 실행 순서에 의존하는 문제(order-dependent)로 밝혀졌습니다. 테스트 스위트가 하드코딩된 테스트 픽스처(test fixture) ID를 통해 동일한 orders 테이블 행에 쓰는 두 개의 checkout 테스트를 연속해서 실행했던 것입니다. 빠른 머신에서는 첫 번째 테스트의 정리(cleanup) 작업이 두 번째 테스트의 설정(setup) 작업이 실행되기 전에 완료되었습니다. 하지만 CI의 더 무거운 부하 환경에서는 이 순서가 약 5%의 확률로 뒤바뀌었고, 두 번째 테스트가 가끔 첫 번째 테스트로부터 절반만 커밋된(half-committed) 행을 읽게 되었습니다. 두 테스트가 수백 줄 떨어진 서로 다른 파일에 존재했기 때문에 아무도 이를 발견하지 못했습니다. 이는 실행 간의 훅(hook) 타이밍을 구체적으로 비교하지 않는 한 보이지 않는 종류의 문제입니다.
3. 단순히 고장 난 것이 아니라, Flakiness를 git bisect 하세요
git bisect는 보통 "예전에는 통과했는데 지금은 실패한다"는 상황을 위해 제안됩니다. 하지만 "예전에는 1% 확률로 flaky(불안정)했는데, 지금은 20% 확률로 flaky하다"는 상황에서도 똑같이 잘 작동합니다. 즉, 불리언(boolean) 값이 아니라 실패율(failure rate)을 이분 탐색(bisecting)하는 것입니다. 저는 커밋당 테스트를 30번 실행하고 실패율이 임계값을 넘으면 해당 커밋을 'bad'로 표시하는 작은 bisect 스크립트를 작성했습니다:
git bisect start HEAD HEAD~40
git bisect run bash -c '
fails=0
...
이를 통해 두 개의 미들웨어(middleware) 함수 순서를 변경한 커밋을 찾아냈습니다. 이는 테스트 파일 자체와는 아무런 관련이 없었지만, 조용히 레이스 컨디션(race condition)을 유발했습니다. 테스트 코드를 읽는 것만으로는 절대 찾아낼 수 없었을 것입니다.
4. 에이전트에게 단순히 "고쳐줘"라고 하지 말고, 실패 모드를 분류하라고 요청하세요
초기에는 Claude Code에게 "이 테스트가 실패하지 않게 해줘"라고 요청하곤 했습니다. 당연하게도, 이는 때때로 원인(cause)을 제거하는 대신 증상(symptom)만 드물게 만드는 수정 방식(더 긴 타임아웃 설정, 추가 재시도 등)을 내놓았습니다. 이제 저는 명시적으로 먼저 분류하도록 요청합니다:
"수정안을 제안하기 전에: 이것이 타이밍/레이스(timing/race) 문제인가요, 공유 상태(shared-state) 문제인가요, 시드되지 않은 무작위성(unseeded-randomness) 문제인가요, 아니면 로컬과 CI 사이의 환경 차이인가요? 당신의 분류를 뒷받침하는 특정 코드 라인을 인용하세요."
분류 단계를 소리 내어(out loud) 강제하면 돌아오는 수정 사항이 달라집니다. "공유 상태 (Shared state)"는 테스트 데이터를 격리하는 수정안을 가져오고, "타이밍 (timing)"은 고정된 지연 시간 대신 실제 신호를 기다리는 수정안을 가져옵니다. 저는 에이전트가 sleep()을 사용하려고 시도하는 것을 여러 번 목격했는데, 먼저 분류를 요청함으로써 이를 사전에 차단할 수 있었습니다.
5. 수정을 단순한 패치가 아닌 회귀 방지책 (regression guard)으로 전환하기
마지막 패턴입니다. 실제 버그가 발견되고 수정되면, 단순히 원래의 불안정한 테스트(flaky test)가 한 번 통과하는지 확인하는 것에 그치지 않고, 원래의 버그를 잡아낼 수 있는 표적화된 (targeted) 스트레스 테스트를 요청합니다. 패턴 #1의 더 강화된 버전과 같은 방식으로, 모든 PR(Pull Request)마다 실행하기보다는(모든 커밋에서 테스트를 100번 실행하는 것은 메인 파이프라인에 너무 느립니다) CI의 야간 작업(nightly job)으로 체크인하여 실행합니다:
# .github/workflows/flaky-guard.yml (매 PR이 아닌 야간 실행용)
- run: |
for i in $(seq 1 200); do
...
만약 근본적인 레이스 컨디션 (race condition)이 다시 발생한다면 (예를 들어, 누군가 미들웨어 순서를 다시 건드리는 경우), 아무도 몇 달 동안 알아차리지 못하는 200번 중 1번꼴의 불안정함(flakiness)을 조용히 재도입하는 대신, 이 방식을 통해 야간 실행에서 이를 잡아낼 수 있습니다.
flowchart LR
A[의심스러운 테스트] --> B[N번 반복 루프, 전체 로그 캡처]
B --> C[통과한 실행과 실패한 실행의 차이 비교 (Diff)]
...
교훈 (Lessons Learned)
- 재현할 수 없는 것은 디버깅할 수 없습니다. 루프 및 캡처(loop-and-capture) 단계(패턴 #1)는 화려하지는 않지만 가치의 80%를 차지합니다. 예전의 제 "Flaky Test 디버깅" 시간 대부분은 눈앞에 실제 실패 데이터가 전혀 없는 상태에서 코드만 멍하니 바라보는 데 소비되었습니다.
- 단순한 불리언(Boolean) 값이 아니라 발생률을 이분법적으로 나누세요. Flaky 발생률이 1%에서 20%로 증가한 테스트는 실제 원인이 되는 커밋(commit)이 존재하는 명백한 회귀(regression)입니다. 이를 실제 회귀 문제처럼 취급하세요.
- 수정하기 전에 분류 단계를 강제하면 수정 방식이 달라집니다. "어떻게(how)"를 묻기 전에 "왜(why)"를 묻는 과정은 에이전트(그리고 저 자신)가 타임아웃(timeout) 값을 높이거나 sleep을 추가하는 것을 첫 번째 해결책으로 선택하는 것을 막아주었습니다.
- 공유 상태(Shared state)는 압도적으로 가장 흔한 근본 원인입니다. 이 방식으로 해결한 약 15개의 Flaky Test 중 최소 10개는 "진짜" 타이밍 버그가 아니라, 테스트들이 DB 행(row), 파일, 또는 전역 변수(global)를 조용히 공유하고 있었던 문제였습니다.
- 회귀 방지책(regression guard)이 없는 수정은 다시 돌아오는 수정입니다. 매일 밤 수행하는 스트레스 테스트(stress-test) 단계는, 관련 없는 변경 사항으로 인해 정확히 동일한 레이스 컨디션(race condition)이 3개월 후에 다시 나타나기 전까지는 과하다고 느껴질 수 있습니다.
다음 단계 (What's Next)
저는 이제 사람이 패턴을 알아차리고 수동으로 찾아 나설 때까지 기다리는 대신, CI(지속적 통합)가 재실행 시 통과 후 실패한 테스트를 감지할 때마다 패턴 #1의 루프를 자동으로 실행하는 방안을 검토하고 있습니다. 이러한 트리거는 이미 대부분의 CI 시스템의 재시도 로그(retry logs)에 존재하지만, 현재는 어디에도 연결되어 있지 않을 뿐입니다. 또한, 매일 밤 진행하는 스트레스 테스트 결과를 각 테스트 파일별 "Flakiness score(Flaky 점수)"로 다시 피드백하여, 이 처리가 가장 필요한 테스트들이 그 주에 가장 짜증이 난 사람에 의해 선택되는 대신 상단으로 떠오르게 만들고 싶습니다. 이것이 안정적으로 작동한다면 다시 글로 정리하겠습니다.
한 가지 언급할 주의 사항이 있습니다. 이 전체 워크플로우는 의심되는 테스트를 저렴한 비용으로 루프(loop) 내에서 실행할 수 있다는 것을 전제로 합니다. 비용이 많이 드는 외부 서비스나 유료 API 호출 뒤에 가드(gated)되어 있는 테스트의 경우, 100200회의 반복은 공짜가 아닙니다. 그래서 저는 루프 횟수를 2030회로 낮추고 더 노이즈가 많은 신호(noisier signal)를 받아들여야만 했습니다. 여전히 실제 실패 데이터가 전혀 없는 것보다는 낫지만, 이 방식이 서류상으로 보이는 것보다 더 비용이 많이 드는 유일한 지점입니다.
마무리 (Wrap-up)
만약
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기