1. 개요

문의 폼과 관리자 로그인에 Cloudflare Turnstile 을 붙였다. 처음 도입할 때 자체 로더를 만들었고, 나중에 @marsidev/react-turnstile 로 바꾸려다 회귀 범위가 커서 보류했다. 그래서 위젯 로딩의 모든 실패 경로를 직접 겪었다. 이 글은 “가끔 안 뜬다” 는 보고에서 시작해 로더를 다시 짠 이야기와, 백엔드 쪽에서 fail-open 의 성립 조건을 코드로 못박은 이야기다.


2. 핵심 내용

2-1. 증상: 가끔 로딩 문구만 남는다

로그인 폼이 “Loading verification…” 에서 멈추고 버튼이 죽어 있다. 새로고침하면 된다. 콘솔에는 에러 세 개가 있는데 전부 무관했다(CSP Report-Only 노이즈). 재현이 안 되니 한참 방치됐다.

2-2. 원인 1: 상태 전환이 없는 return

로더 코드가 이랬다.

script.onload = () => {
  if (!window.turnstile) return;   // ← 여기
  window.turnstile.render(...)
  setState("ready")
}

script.onload 가 발화한 시점에 window.turnstile 이 아직 없는 구간이 실재한다. 스크립트가 로드됐다고 그 안의 초기화 코드가 끝난 건 아니다. 그 경우 return 만 하고 상태를 바꾸지 않으니 loading 에 영구 고정된다. 재시도 버튼도 없다. 사용자는 죽은 화면을 본다.

2-3. 원인 2: 직렬 체인

관리자 레이아웃에 isMounted 게이트가 있었다. 하이드레이션이 끝난 뒤에야 자식을 그린다. 로그인 폼은 그 자식이었다. 그러니 “레이아웃 하이드레이션 → 폼 마운트 → 스크립트 삽입 → 스크립트 로드 → 위젯 렌더 → 토큰” 이 전부 직렬이었다. 느린 네트워크에서 체인이 길어지면 사용자가 포기한다.

2-4. 다시 짠 로더

  • 스크립트 로드는 모듈 수준 Promise 하나 로 공유한다. 위젯이 둘 있어도 <script> 는 한 번.
  • 실패한 <script> 는 반드시 제거하고 재삽입 한다. 이미 error 가 발화한 엘리먼트에 리스너를 다시 붙이면 다시 발화하지 않는다. Promise 가 영원히 settle 되지 않는 교착이다.
  • onload 대신 Cloudflare 가 권장하는 ?onload= 전역 콜백 을 쓴다. 이건 window.turnstile 이 준비된 뒤 불린다. 전역 콜백은 나중에 삭제하지 말고 no-op 으로 바꾼다. 삭제하면 콘솔 경고.
  • 12초 타임아웃. 그 안에 콜백이 안 오면 error.
  • render() 가 undefined 를 돌려주는 경우도 error.
  • 모든 실패가 error 상태에 착지 하고, error 는 “Try again” 버튼을 보인다.

핵심은 마지막 줄이다. 비동기 로더는 실패를 명시적 상태로 착지 시켜야 재시도 UI 가 의미를 가진다. return 으로 빠지는 경로가 하나라도 있으면 그 경로의 사용자는 영원히 기다린다.

로그인 페이지는 isMounted 게이트 앞으로 빼고, challenges.cloudflare.com 에 preconnect/dns-prefetch 를 넣었다. 위젯 자리는 실측 69px 로 예약해 레이아웃 시프트를 없앴다.

2-5. 사파리는 체크박스를 내민다

신호가 적은 환경(사파리, 프라이빗 모드)에서 Turnstile 은 수동 챌린지를 띄운다. 위젯은 떴는데 토큰이 안 온다. 사용자가 체크해야 한다. 안내가 없으면 “인증이 고장났다”.

위젯이 렌더된 뒤 2.5초 안에 토큰이 안 오면 “Check the box above to continue.” 를 띄운다. 그리고 위젯이 onStatusChange 로 상태를 밖에 알려서, 호출부가 버튼 문구를 바꾼다. “Waiting for verification…” / “Verification expired” / “Verification unavailable”. 버튼이 왜 비활성인지 버튼이 말한다.

2-6. 백엔드: fail-open 의 성립 조건

백엔드는 토큰을 siteverify 에 보내 검증한다. 정책은 이렇다.

  • 시크릿이 비어 있으면 검증을 건너뛴다(배포 순서 독립, 같은 패턴).
  • siteverify 가 success: false 를 돌려주면 거부(fail-closed).
  • siteverify 호출 자체가 실패 하면(타임아웃, 5xx) 통과(fail-open). Cloudflare 가 잠깐 죽었다고 문의가 전부 막히는 것보다 낫다는 판단.

여기서 한 가지를 더 넣었다. 토큰 길이 2048 상한을 siteverify 호출 전에 검사 한다. 왜냐하면 fail-open 은 “호출 실패” 를 통과로 취급하는데, 공격자가 수 MB 짜리 토큰을 보내면 siteverify 호출이 실패한다. 그러면 fail-open 이 공격자가 켤 수 있는 스위치 가 된다. 길이 상한은 그 스위치를 없앤다. “실패를 누가 유발할 수 있는가” 를 물어야 fail-open 을 안전하게 쓸 수 있다.

검증 실패는 400 으로 돌려준다. 401 로 하면 프론트의 토큰 재발급 경로가 잘못 작동한다. 상태 코드에도 소비자가 있다.

2-7. 남용 방어는 세 층

Turnstile 혼자 다 막지 않는다.

  1. Turnstile: 자동화된 봇
  2. IP 레이트리밋: 로그인 분 10회·시간 50회, 문의 분 5회·시간 15회·일 40회
  3. 이메일 쿼터: 같은 주소로 시간 3회·일 8회

클라이언트 IP 는 CF-Connecting-IP → XFF 에서 신뢰 홉 수만큼 오른쪽에서 건너뛴 값 → remoteAddr 순으로 잡고, IP 리터럴 형식을 검사한 뒤 채택한다. 관리자 로그인의 Server Action 은 원 요청의 IP 헤더를 백엔드에 전달한다.


3. 마무리

요약

  • script.onload 시점에 window.turnstile 이 없는 구간이 있다. ?onload= 콜백을 쓴다.
  • 실패한 <script> 는 제거 후 재삽입. 리스너 재부착은 영원히 settle 안 되는 Promise 를 만든다.
  • 모든 실패 경로가 error 상태에 착지해야 재시도 UI 가 의미를 가진다.
  • fail-open 을 쓰려면 “실패를 누가 유발할 수 있는가” 를 먼저 막는다. 토큰 길이 상한.
  • 버튼이 왜 비활성인지 버튼이 말하게 하라.

다음은 날짜는 문자열로다.