Cloudflare의 봇 챌린지(bot-challenge) 시스템은 일반적인 HTML 폼 제출을 소리 없이 차단하여, 단순한 결제 클릭을 실제 사용자의 막다른 길로 만들 수 있습니다. 요청 방식을 네이티브 내비게이션 POST에서 fetch 우선 흐름(fetch-first flow)으로 전환하면 보안을 유지하면서도 사용자 경험을 복구할 수 있습니다.
이 문제가 중요한 이유
한 개발자가 모든 테스트 스위트, curl, 그리고 로컬 서버에서 완벽하게 작동하는 결제 폼을 출시했습니다. 하지만 고객이 Chrome을 사용할 때, 동일한 폼이 첫 번째 클릭 후 보안 오류를 발생시키고 두 번째 클릭에서는 “timeout-or-duplicate” 메시지를 띄웠습니다. 이 실패로 인해 세 번의 핫픽스(hot-fix) 릴리스와 하루 전체의 디버깅 작업이 필요했습니다.
숨겨진 엣지(edge)
이 폼은 일반 HTML <form> 요소에 의존하는 오픈 소스 Astro 패키지에 포함되어 있습니다. 사용자가 Pay를 클릭하면 서버는 Stripe로의 303 리다이렉트 응답을 보내고, 브라우저는 JavaScript 없이 리다이렉트를 따릅니다. 사이트들은 스크립트가 비활성화되었을 때를 대비한 폴백(fallback)으로 이 패턴을 사용합니다.
Cloudflare는 사이트 앞단에서 봇 탐지 엔진을 실행합니다. 일반적인 GET 요청의 경우 중간 단계의 챌린지(CAPTCHA 또는 JavaScript 확인)를 보여줄 수 있습니다. 브라우저가 챌린지를 통과하면 요청이 진행됩니다.
하지만 내비게이션 POST는 챌린지를 위해 일시 중지되었다가 본문(body)을 유지한 채 재개될 수 없습니다. 엣지는 요청을 폐기하고 503 상태 코드를 반환하며, 브라우저에는 빈 페이지나 일반적인 오류가 표시됩니다. Cloudflare가 신뢰하는 것과 동일한 핑거프린트를 가진 자동화된 테스트 브라우저는 챌린지를 트리거하지 않으므로, 실제 사용자가 사이트에 접속하기 전까지 문제는 보이지 않습니다.
로그를 통해 밝혀진 사실
사용자의 Chrome 세션에서 추출한 라이브 네트워크 트레이스는 동일한 엔드포인트에 대한 두 가지 대조적인 요청을 보여주었습니다:
- Navigation POST → 503 응답, 탭 멈춤.
- fetch() POST → 요청 완료.
두 요청 모두 동일한 오리진(origin)에서 시작되었고, 동일한 자격 증명(credentials)을 포함했으며, 동일한 순간에 발생했습니다. 유일한 차이점은 전송 방식이었습니다. fetch 요청은 내비게이션 POST를 차단하는 중간 단계 흐름을 우회했습니다.
해결되지 않은 시도들
개발자는 근본 원인을 놓친 채 일련의 수정 작업을 시도했습니다:
- Turnstile 토큰이 만료되었다고 가정하고 토큰 갱신.
- 차단이 위치 기반이라고 생각하여 IP 범위 화이트리스트 등록.
- 확장 프로그램 비활성화, 서비스 워커 삭제, 쿠키 삭제.
각 변경 사항은 오류를 해결하지 못했습니다. 실패의 원인이 클라이언트나 서버 코드가 아닌, 상류(upstream)인 엣지에 있었기 때문입니다.
실용적인 해결책
Cloudflare의 보호 기능을 끄는 대신, 폼을 fetch 우선 패턴을 사용하도록 재설계했습니다:
- 폼 데이터를 수집하고 이를
fetch()를 통해 JSON 페이로드로 전송합니다. - 서버의 응답을 처리합니다. 서버가 결제 게이트웨이 URL을 반환하면,
location.assign()을 호출하여 단순 GET 요청으로 해당 위치로 이동합니다.
Fetch 요청은 중간 단계 챌린지를 트리거하지 않으므로 POST가 오리진 서버에 도달합니다. 이후의 GET 리다이렉트는 GET 본문이 비어 있고 사용자가 챌린지를 통과한 후 재전송될 수 있으므로 어떤 챌린지도 안전하게 통과할 수 있습니다.
개발자가 직면한 리스크
- 사용자 신뢰: 소리 없이 실패하는 결제 폼은 신뢰를 떨어뜨리고 매출 손실로 이어질 수 있습니다.
- 유지보수 오버헤드: 이번 사건으로 인해 세 번의 패치 릴리스와 하루 전체의 조사 작업이 필요했습니다.
- 테스트 사각지대: 내부 테스트 환경에만 의존하면 실제 환경에서만 나타나는 엣지 케이스 실패를 놓칠 수 있습니다.
커뮤니티를 위한 교훈
- 실제 브라우저를 활용하세요. 문제가 실제 사용자에게만 나타난다면, 자동화된 테스트 실행을 신뢰하는 대신 해당 세션의 네트워크 로그를 캡처하세요.
- 엣지를 스택의 일부로 취급하세요. Cloudflare는 클라이언트와 서버 사이에 위치하며, 그 동작은 요청이 어떻게 구조화되어야 하는지에 영향을 미칩니다.
- 적절한 전송 방식을 선택하세요. 내비게이션 POST와 fetch POST는 엣지에서 서로 다른 경로를 통해 이동합니다. 이러한 차이를 염두에 두고 API를 설계하세요.
- 오류 세부 정보를 노출하세요. 503 또는 “timeout-or-duplicate” 메시지를 UI에 표시하여 개발자가 로그를 뒤지지 않고도 정확한 실패 모드를 확인할 수 있도록 하세요.
향후 주의 사항
개발자는 특히 Cloudflare나 유사한 CDN 보안 서비스가 사이트 앞단에 있는 경우, 네이티브 POST 내비게이션에 의존하는 모든 폼 기반 워크플로우를 점검해야 합니다. 가벼운 fetch 래퍼(wrapper)를 추가하면 유사한 실패를 미연에 방지할 수 있습니다. 엣지에서 생성된 상태 코드를 캡처하는 모니터링 도구를 사용하면 문제가 고객에게 도달하기 전에 식별할 수 있습니다.
핵심 요약: Cloudflare의 봇 챌린지가 활성화되어 있을 때, 일반적인 HTML 폼 제출은 조용한 실패(silent failure)에 취약합니다. fetch()를 통해 POST 요청을 재라우팅하고 GET 리다이렉트로 흐름을 완료하면, 보안을 유지하면서도 에지(edge)의 한계를 우회할 수 있습니다. 에지를 단순한 네트워크 홉(network hop)이 아닌 코드로 취급하고, 그에 맞춰 전송 방식을 설계하십시오.
