1. 개요
B2B 사이트의 문의 폼은 스팸의 표적이다. 기획서에는 “문의 등록 시 이메일 OTP 인증” 이 있었다. 운영을 시작하고 보니 그 OTP 는 실제로 검증되지 않는 필드 였다. 코드가 값을 받기만 하고 대조하지 않았다. 봇은 못 막고 사람만 이탈시키는 단계였던 것이다. 이 글은 그걸 걷어내고 무엇으로 바꿨는지, 바꾸면서 무엇에 걸렸는지 적는다.
2. 핵심 내용
2-1. 등록은 Turnstile, 조회는 OTP
봇 방어와 본인 확인은 다른 문제다. 둘을 나눴다.
- 등록: Cloudflare Turnstile. 사용자는 대부분 아무것도 안 해도 통과한다. 백엔드가 토큰을 siteverify 로 검증한다.
- 조회: 남긴 문의를 나중에 확인하는 페이지. 남의 RFQ 상세가 열리면 안 되니 여기는 이메일 인증코드를 남겼다. 이번엔 실제로 대조한다.
2-2. 양쪽이 함께 켜지고 함께 꺼진다
Turnstile 은 프론트(사이트 키)와 백엔드(시크릿) 둘 다 설정이 있어야 동작한다. 세 저장소(프론트·백엔드·인프라)가 따로 배포되므로 어느 순서로 나가도 깨지지 않아야 했다.
- 백엔드: 시크릿이 없으면 검증을 건너뛴다.
- 프론트: 사이트 키가 없으면 위젯을 안 띄운다.
- 매니페스트: 시크릿 참조는
optional: true.
코드를 먼저 머지해도 동작이 안 바뀌고, 시크릿을 넣는 순간 양쪽이 함께 켜진다. 기본은 fail-closed(검증 실패 시 거부), 시크릿이 있는데 siteverify 자체가 죽으면 설정으로 fail-open 을 택할 수 있게 뒀다.
2-3. 레이트리밋 두 축
Redis INCR + EXPIRE 고정 윈도우다. bucket4j 같은 라이브러리 없이 갔다. 정책은 enum 한 곳에 수치와 근거 주석 이 같이 산다. “왜 10분에 3회인가” 를 6개월 뒤의 내가 물을 것을 알기 때문이다.
축이 둘이다.
- IP 축: 문의 등록 10분 3회·시간 10회, 인증코드 발송 분 3회, 확인 분 5회, 관리자 로그인 분 10회.
- 주소 축: 이메일 주소당 시간 5회, 5회 실패 시 코드 폐기.
IP 축만 있으면 분산 IP 공격에 뚫린다. 주소 축만 있으면 한 IP 가 주소를 바꿔 가며 때린다. 둘이 있어야 한다. 하루 단위 윈도우는 사용자 요구(“조회를 하루 몇 번으로 막지 말 것”)로 만들지 않았고, 그 결정을 테스트로 고정했다. 누가 나중에 일 단위를 넣으면 테스트가 이유를 말해 준다.
Redis 가 죽으면 fail-open 이다. 문의가 안 들어오는 것보다 스팸이 잠시 들어오는 게 낫다는 판단인데, 이건 사이트 성격에 따라 반대일 수 있다.
2-4. “죽은 버튼”
배포하고 얼마 뒤 “관리자 로그인이 안 되는데 챌린지가 안 보인다” 는 보고가 왔다. 백엔드 로그를 보니 로그인 실패가 0건. 서버가 막은 게 아니라 요청이 나가지 않은 것이다.
위젯이 토큰을 낼 때까지 버튼이 disabled 인데, 왜 비활성인지 아무 안내가 없었다. 위젯 로딩이 느리거나 실패하면 사용자는 죽은 버튼을 본다. disabled 는 이유를 말하지 않는다.
찾아낸 결함이 셋이었다.
- 로더 교착: 스크립트 로드에 실패한
<script>엘리먼트에 리스너를 다시 붙이고 있었다. 이미 error 가 발화한 엘리먼트는 다시 발화하지 않으므로 Promise 가 영원히 settle 되지 않는다. 실패한 엘리먼트는 제거하고 새로 삽입해야 한다. - 정렬 불일치: 호출부마다 className 이 달라 문의 폼의 위젯이 화면 밖으로 밀렸다.
- 자리 예약 없음: 위젯이 뜨면서 버튼이 72px 아래로 밀렸다.
고친 뒤 위젯 컴포넌트는 상태 5종(disabled | loading | ready | expired | error)을 밖으로 내고, 호출부가 그걸로 버튼 문구를 바꾼다. 자리를 미리 예약하고, 정렬 책임을 컴포넌트가 갖는다. 문구는 세 언어.
2-5. 사파리는 체크박스를 내민다
Turnstile 은 대부분 사용자가 아무것도 안 해도 통과시키지만, 사파리처럼 신호가 적은 환경에서는 수동 챌린지(체크박스) 를 내민다. 사용자가 체크할 때까지 토큰이 없다. 안내가 없으면 “인증이 고장났다” 로 읽힌다.
이 경로는 항상 통과하는 테스트 사이트 키로는 재현되지 않는다. Cloudflare 가 제공하는 “항상 인터랙티브 챌린지” 테스트 키를 써야 로컬에서 사파리 상황을 볼 수 있다. 자동 통과 키만으로 검증하면 인터랙티브 경로는 영원히 안 보인다.
2-6. 토큰은 1회용이다
Turnstile 토큰은 한 번 검증하면 끝이고 약 5분 뒤 만료된다. 로그인에 실패하고 다시 시도하면 같은 토큰이 timeout-or-duplicate 로 거부된다. 호출의 finally 에서 위젯을 리셋해야 하는데, 그러면 리셋 직후 새 토큰이 올 때까지 짧게 토큰이 비는 구간이 생긴다. 그 구간을 위젯 상태로 드러내면(다시 loading), 사용자는 기다릴 이유를 안다.
3. 마무리
요약
- 봇 방어(Turnstile)와 본인 확인(OTP)은 다른 문제다. 나눠라.
- 시크릿 없으면 건너뛰기 + 키 없으면 위젯 미표시 +
optional: true. 배포 순서가 서로를 막지 않는다.- 레이트리밋은 IP 축과 주소 축 둘 다. 수치 옆에 근거를 적는다.
disabled는 이유를 말하지 않는다. 위젯 상태를 밖으로 내고 버튼 문구로 말하라.- 사파리 수동 챌린지는 인터랙티브 테스트 키로만 재현된다.
다음은 이미지 최적화를 일부러 끈 이야기다.