
Cloudflare Turnstile CAPTCHA - 14편. 스팸 방지 구현
Cloudflare Turnstile로 스팸 봇을 차단합니다.
이 글에서는 Turnstile 설정, 프론트엔드 연동, 서버 검증, UX 개선을 다룹니다.
🤖 스팸이 들어왔다
문의 폼을 만들고 며칠 지나니 이상한 문의가 들어왔습니다.
이름: asdfasdf
이메일: spam@bot.com
내용: Buy cheap viagra!!!스팸 봇이었습니다.
하루에 수십 개씩 쌓이기 시작했습니다.
Claude에게 물었습니다.
스팸 문의를 막으려면 어떻게 해야 해?CAPTCHA를 넣으면 된다고 했습니다. 그리고 여러 옵션을 알려줬습니다.
🔰 CAPTCHA 종류
Google reCAPTCHA v2
"로봇이 아닙니다" 체크박스를 클릭하고, 가끔 신호등이나 자동차 이미지를 선택해야 합니다.
사용자가 짜증날 수 있습니다.
Google reCAPTCHA v3
백그라운드에서 점수를 계산하고, 의심되면 차단합니다.
개인정보 수집 우려가 있습니다.
Cloudflare Turnstile
거의 항상 자동으로 통과하고, 의심될 때만 간단한 체크를 합니다.
Claude가 이걸 추천했습니다. 무료이고, 사용자 경험이 좋고, 설정도 쉽다고요.
🚀 Turnstile 설정
1. Cloudflare 계정 생성
dash.cloudflare.com에서 무료로 가입합니다.
2. Turnstile 사이트 추가
- 왼쪽 메뉴에서 Turnstile 클릭
- Add site 클릭
- 정보 입력:
Site name: kmetaro Domain: your-domain.com (또는 localhost) Widget Mode: Managed (권장)
3. 키 복사
두 개의 키가 생성됩니다.
Site Key: 0x4AAAAAAA... (클라이언트용)
Secret Key: 0x4AAAAAAB... (서버용)4. 환경 변수 설정
# .env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAB...
📦 프론트엔드 구현
라이브러리 설치
npm install @marsidev/react-turnstile
Turnstile 컴포넌트
// components/forms/TurnstileWidget.tsx
'use client'
import { Turnstile } from '@marsidev/react-turnstile'
interface Props {
onVerify: (token: string) => void
}
export function TurnstileWidget({ onVerify }: Props) {
return (
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={onVerify}
options={{
theme: 'light',
}}
/>
)
}
문의 폼에 적용
// components/forms/InquiryForm.tsx
'use client'
import { useState } from 'react'
import { TurnstileWidget } from './TurnstileWidget'
export function InquiryForm() {
const [turnstileToken, setTurnstileToken] = useState('')
const onSubmit = async (data: InquiryFormData) => {
if (!turnstileToken) {
alert('보안 검증을 완료해주세요')
return
}
const formData = new FormData()
// ... 기존 데이터
formData.append('turnstileToken', turnstileToken)
const result = await submitInquiry(formData)
// ...
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 기존 필드들 */}
<div className="mt-4">
<TurnstileWidget onVerify={setTurnstileToken} />
</div>
<button
type="submit"
disabled={!turnstileToken || isSubmitting}
>
문의하기
</button>
</form>
)
}
토큰이 없으면 버튼이 비활성화됩니다.
🔒 서버 사이드 검증
클라이언트만 체크하면 우회할 수 있습니다.
서버에서 반드시 검증해야 합니다. Claude가 강조했습니다.
검증 함수
// lib/turnstile.ts
export async function verifyTurnstile(token: string): Promise<boolean> {
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
}),
}
)
const data = await response.json()
return data.success === true
}
Server Action에서 사용
// app/actions/inquiry.ts
'use server'
import { verifyTurnstile } from '@/lib/turnstile'
export async function submitInquiry(formData: FormData) {
// 1. Turnstile 검증
const token = formData.get('turnstileToken') as string
if (!token) {
return { error: '보안 검증이 필요합니다' }
}
const isValid = await verifyTurnstile(token)
if (!isValid) {
return { error: '보안 검증에 실패했습니다' }
}
// 2. 나머지 로직 (DB 저장 등)
const supabase = await createClient()
const { error } = await supabase.from('inquiries').insert({
// ...
})
if (error) return { error: error.message }
return { success: true }
}
🎯 적용할 곳들
스팸 공격이 가능한 모든 폼에 적용했습니다.
- 문의 폼 - 제일 중요
- 회원가입 - 가짜 계정 방지
- 로그인 - 무차별 대입 공격 방지
- 비밀번호 찾기 - 이메일 폭탄 방지
🔄 토큰 재사용 방지
Turnstile 토큰은 일회용입니다.
폼 제출이 실패하면 토큰도 다시 발급받아야 합니다.
// 에러 발생 시 Turnstile 리셋
const [turnstileKey, setTurnstileKey] = useState(0)
const onSubmit = async (data) => {
const result = await submitInquiry(formData)
if (result.error) {
// Turnstile 리셋 (key 변경으로 컴포넌트 재생성)
setTurnstileKey(prev => prev + 1)
setTurnstileToken('')
}
}
return (
<TurnstileWidget
key={turnstileKey}
onVerify={setTurnstileToken}
/>
)
key를 바꾸면 컴포넌트가 다시 마운트됩니다.
⚠️ 삽질했던 것들
위젯이 안 나옴
Domain 설정이 잘못됐습니다.
Cloudflare 대시보드에서 localhost도 추가해야 개발 환경에서 테스트할 수 있습니다.
"Invalid sitekey" 에러
Site Key가 틀렸습니다. .env.local을 확인했습니다.
NEXT_PUBLIC_ 접두사도 확인해야 합니다. 클라이언트에서 쓰려면 필수입니다.
서버 검증 실패
Secret Key가 틀렸거나, 토큰이 만료됐습니다.
토큰은 5분 안에 검증해야 합니다.
무한 로딩
회사 방화벽 같은 네트워크 문제였습니다.
<Turnstile
onError={() => {
console.log('Turnstile error')
}}
onExpire={() => {
setTurnstileToken('')
}}
/>
에러 핸들링을 추가했습니다.
💡 UX 개선
Claude가 제안한 것들입니다.
로딩 상태 표시
const [isWidgetLoading, setIsWidgetLoading] = useState(true)
<div className="relative">
{isWidgetLoading && (
<div className="absolute inset-0 flex items-center">
<Loader2 className="animate-spin" />
<span>보안 검증 로딩 중...</span>
</div>
)}
<Turnstile
onLoad={() => setIsWidgetLoading(false)}
// ...
/>
</div>
검증 완료 표시
{turnstileToken && (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="w-4 h-4" />
<span className="text-sm">보안 검증 완료</span>
</div>
)}
사용자에게 진행 상황을 알려주는 게 좋습니다.
📊 스팸 차단 효과
적용 전후 비교입니다.
| 적용 전 | 적용 후 | |
|---|---|---|
| 일일 스팸 | 50~100건 | 0~2건 |
| 사용자 불편 | 없음 | 거의 없음 |
| 서버 부하 | 높음 | 낮음 |
거의 99% 차단됐습니다. Turnstile은 정말 효과가 좋았습니다.
📋 이번 편 요약
| 항목 | 내용 |
|---|---|
| 다룬 기능 | 스팸 방지 CAPTCHA 구현 |
| 사용 기술 | Cloudflare Turnstile |
| 적용 대상 | 문의 폼, 회원가입, 로그인 |
| 차단 효과 | 스팸 99% 감소 |
다음 편에서는 제품 목록과 상세 페이지를 구현합니다.
💬 CAPTCHA 관련 질문 있으면 댓글로 남겨주세요!
'바이브코딩' 카테고리의 다른 글
| 16. 제품 선택부터 이메일 발송까지 - React Hook Form 문의 폼 구현 (0) | 2026.01.23 |
|---|---|
| 15. 목록과 상세 구현 - Next.js 제품 카탈로그 페이지 (0) | 2026.01.22 |
| 13. 고객과 관리자 권한 분리 - Supabase RBAC 역할 관리 (0) | 2026.01.20 |
| 12. 인증과 권한 체크 구현 - Next.js 미들웨어 페이지 보호 (0) | 2026.01.19 |
| 11. 회원가입과 비밀번호 찾기 - Supabase 이메일 인증 구현 (0) | 2026.01.18 |