클릭만으로는 재현할 수 없는 프로덕션 버그
요약
재현하기 어려운 프로덕션 타이밍 버그를 AI 에이전트와 모킹 도구를 활용해 해결하는 과정을 다룹니다. 특정 API 호출의 실패나 지연을 제어하여 레이스 컨디션(Race Condition)을 테스트하는 방법을 제시합니다.
핵심 포인트
- 클릭만으로는 재현 불가능한 타이밍 버그의 특성 설명
- AI 에이전트를 활용한 코드 기반의 버그 원인 분석
- TWD 모크를 이용한 특정 API 요청의 실패 및 지연 제어
- UI 상태가 아닌 실제 네트워크 페이로드를 검증하는 테스트 방식
프로덕션 로그에 결제 요청이 나타났는데, 최상위 레벨에는 country: "ROW"가 있고 고객 객체 내부의 billing_address.country에는 "FRA"가 찍혀 있었습니다. 동일한 바디(body) 안에 두 개의 서로 다른 국가가 존재한 것입니다. 프론트엔드(frontend)에서 분명히 그렇게 보냈지만, 아무도 이를 다시 재현할 수 없었습니다.
모든 팀에는 이런 버그가 하나씩 있습니다. 페이로드(payload)는 모순되고, 로그는 그것이 실제로 발생했음을 증명하지만, 앱을 아무리 클릭해 봐도 재현되지 않습니다. 이는 보통 타이밍 버그(timing bug)의 전형적인 특징입니다. 무언가가 정확히 잘못된 순간에 실패했거나 늦게 도착한 것입니다.
수동 테스트로 해결할 수 없는 이유
우리는 먼저 수동으로 재현을 시도했습니다. 하지만 실패했습니다.
이유는 노력의 부족이 아니라 구조적인 문제입니다. 이 버그를 유발하려면 사용자가 국가를 변경할 때 실행되는 가격 재계산(price recalculation)이라는 특정 API 호출이, 사용자가 결제를 제출하는 바로 그 순간에 실패하거나 느리게 응답해야 합니다. 수동으로는 이를 제어할 방법이 없습니다:
- 단 한 번의 요청에 대해 실제 백엔드(backend)를 원하는 시점에 실패하게 만들 수 없습니다.
- 브라우저 개발자 도구(devtools)의 스로틀링(throttling)은 전역적이고 투박합니다. 우리가 원하는 특정 호출이 아니라 모든 것을 느리게 만듭니다.
- 운 좋게 한 번 성공하더라도, 두 번은 할 수 없습니다. 반복할 수 없는 재현(repro)은 재현이 아닙니다.
결국 버그는 "미확인" 상태로 남고, 티켓은 방치되며, 페이로드는 몇 주마다 로그에 계속 나타납니다.
에이전트는 화면이 아니라 코드를 읽습니다
여기서 AI 에이전트(AI agent)가 접근 방식을 바꾸었습니다. 외부에서 추측하는 대신, 에이전트는 코드를 읽었고 앱이 국가 정보를 두 곳에 보관하고 있다는 사실을 찾아냈습니다. 하나는 사용자가 작성하는 결제 양식(billing form)에 있고, 다른 하나는 페이지가 처음 로드될 때 설정되는 앱 상태(app state)에 있습니다. 사용자가 다른 국가를 선택하면 앱은 가격을 업데이트하기 위해 백그라운드 요청(background request)을 보내며, 해당 요청이 성공했을 때만 앱 상태가 양식의 상태를 따라잡게 됩니다.
"양식이 변경된 시점"과 "앱 상태가 따라잡은 시점" 사이의 이 간극이 바로 버그의 핵심입니다. 단 몇 분 만에 두 가지 발생 경로를 찾아냈습니다:
- 백그라운드 요청이 실패합니다. 앱 상태는 이전 국가를 유지하고, 폼(form)은 새로운 국가를 보여주며, 결제 버튼은 여전히 작동합니다.
- 사용자가 너무 빠르게 결제합니다. 백그라운드 요청이 돌아오기 전에 결제가 생성되어, 여전히 이전 국가를 읽어옵니다.
두 가지 모두 타이밍 조건(timing conditions)입니다. 두 가지 모두 클릭만으로는 재현할 수 없는 바로 그런 유형의 문제입니다. 그리고 두 가지 모두 TWD 모크(mock)로 표현하기 매우 간단합니다. TWD에서는 테스트가 요청별로 서버의 동작을 제어하기 때문입니다.
실패 케이스는 하나의 모크로 구현됩니다:
await twd.mockRequest("recalcFail", {
url: "/api/pricing/quote",
method: "POST",
...
단언(assertion)에 주목하세요: twd.waitForRequest는 가로챈 바디(body)를 반환하므로, 테스트는 UI가 무엇을 표시했는지가 아니라 앱이 실제로 네트워크(wire)를 통해 무엇을 보냈는지를 확인합니다.
지연된 모크(Delayed Mock)로 레이스 컨디션(Race) 재현하기
두 번째 경로는 타이밍 제어가 필요합니다. 재계산(recalculation)은 성공해야 하지만, 느리게 진행되어야 합니다. TWD 모크는 delay를 허용하므로, 테스트가 계속 진행되는 동안 서비스 워커(service worker)가 응답을 붙잡고 있게 됩니다:
await twd.mockRequest("recalcSlow", {
url: "/api/pricing/quote",
method: "POST",
...
테스트에 setTimeout을 쓰지도 않고, 불안정한 sleep을 넣지도 않으며, 신호에 맞춰 나쁘게 동작하는 실제 백엔드를 쓰지도 않습니다. 지연(delay)이 모크 정의의 일부이기 때문에 레이스 컨디션(race)은 결정론적(deterministic)입니다. 수동으로는 절대 맞출 수 없었던 조건이 이제 매 실행마다 재현됩니다.
두 테스트 모두 첫 번째 시도에서 통과했습니다. 몇 주 동안 지속된 "재현 불가" 이슈가 단 한 번의 세션 만에 해결되었습니다.
무엇이 차이를 만들었나
두 가지가 있었으며, 그 중 어느 것도 마법은 아닙니다:
- 에이전트가 코드를 읽습니다. 버그를 재현하는 인간은 UI에서 내부로 들어가며 타이밍 윈도우 (timing window)가 어디인지 추측해야 합니다. 반면 에이전트는 코드에서 외부로 나갑니다. 에이전트는 스토어 (store), 동기화 메커니즘 (sync mechanism), 그리고 그 사이의 간극을 찾아낸 다음, 이를 직접 겨냥하는 테스트를 작성합니다.
- 모크 레이어 (mock layer)가 타이밍을 테스트 입력값으로 만듭니다. 실패와 지연이 테스트 파일 내의 요청별로 선언되며, 브라우저에서 실행 중인 실제 앱을 대상으로 동작합니다. 이를 통해 "경쟁 상태 (race condition)"는 단순히 잡기를 바라는 대상에서, 사용자가 직접 명시할 수 있는 대상으로 변합니다.
전체 루프는 이미 열려 있는 브라우저 탭을 제어하는 twd-relay를 통해 실행되었으므로, 모든 재현 시도는 실행되는 동안 눈으로 확인할 수 있었습니다. 수정 후에는 동일한 두 테스트가 회귀 테스트 커버리지 (regression coverage)를 위해 테스트 스위트 (suite)에 그대로 남았습니다.
만약 "재현 불가"라고 적힌 티켓이 있는데 로그 라인은 그 반대를 말하고 있다면, 이 조합을 시도해 볼 가치가 있습니다. 에이전트가 코드를 읽게 하고, 타이밍을 모크 (mock)에 넣으십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기