# 문의 폼 스팸을 막는 방법

> 허니팟, 시간 검사, 콘텐츠 점수, 레이트 리밋, 필요한 곳에만 사람 확인까지, 실제 방문자에게 부담 없는 여러 겹의 검사로 문의 폼 스팸을 막으세요.

2026-10-25 게시 · https://ikiform.com/ko/guides/spam-protection

마찰을 더하기 전에 비용이 적고 눈에 보이지 않는 검사부터 쌓으세요. 숨겨진 허니팟 필드, 폼 작성 최소 시간, 링크와 스팸 문구에 대한 점수, 방문자별 레이트 리밋입니다. Cloudflare Turnstile 같은 사람 확인은 그래도 스팸이 통과할 때만 추가하고, 토큰은 서버에서 검증하세요. 걸러 낸 것은 버리지 말고 저장해 두면 가끔 섞이는 실제 메시지를 되살릴 수 있습니다.

공개 페이지의 문의 폼은 검색 엔진에 색인된 지 며칠 만에 쓰레기 제출을 받기 시작합니다. 대부분은 여러분을 노린 것이 아닙니다. 스크립트가 웹을 돌며 아무 `<form>`이나 찾아 모든 입력을 채우고 POST하면서 링크가 어딘가에 걸리기를 바랍니다. 그보다 적은 일부는 소프트웨어가 조종하는 실제 브라우저에서 오고, 또 일부는 같은 홍보 문구를 수천 개의 폼에 붙여 넣는 대가로 돈을 받는 사람에게서 옵니다. 종류마다 막는 검사가 다르기 때문에 한 가지 요령으로는 해결되지 않습니다.

이 가이드는 각 방어 수단이 무엇을 잡고 실제 방문자에게 어떤 부담을 주는지, 어떤 스택에서든 실행할 수 있는 코드와 함께 설명합니다. 그런 다음 폼 백엔드인 Ikiform이 같은 계층을 어떻게 대신 실행하는지 보여 줍니다.

## 비용이 낮은 순서로 쌓는 계층

검사는 비용 순으로 배치하세요. 요청 수를 세는 일은 거의 공짜이므로 본문을 읽기 전에 실행합니다. 캡차처럼 다른 서비스를 호출하는 검사는 저렴한 검사를 통과한 뒤에 실행합니다. 콘텐츠 점수는 파싱된 필드가 필요하므로 마지막입니다. Ikiform은 모든 POST에 이 순서로 검사를 실행합니다.

다이어그램: POST가 도착해서 저장되거나 스팸이 되기까지 스팸 검사가 실행되는 순서

1. POST가 폼 엔드포인트에 도착합니다.
2. 레이트 리밋이 먼저 방문자별 POST 수를 셉니다. 폭주하는 요청은 본문을 읽기 전에 429로 거부됩니다.
3. 허용 도메인은 POST가 온 페이지를 여러분이 등록한 도메인과 비교합니다.
4. 사람 확인이 켜져 있으면 POST에 유효한 Turnstile 토큰이 있어야 합니다.
5. 허니팟이 채워져 있으면 점수에 5를 더합니다.
6. 시간, 링크, 알려진 스팸 문구, 일회용 주소, 반복 제출은 더 작은 점수를 더합니다.
7. 5점 이상이면 POST는 스팸으로 저장됩니다. 그보다 낮으면 POST를 받고 이메일과 웹훅이 나갑니다.

윗줄은 POST를 거부합니다. 아랫줄은 절대 거부하지 않고 점수만 매기므로, 오탐은 잃어버린 메시지가 아니라 클릭 한 번으로 복원할 수 있는 항목이 됩니다.

## 방어 수단 비교

모든 방어 수단에는 대가가 있습니다. 이 표는 각 수단이 놓치는 것과, 여러분이 연락받고 싶은 사람들에게 요구하는 것을 솔직하게 보여 줍니다.

| 방어 수단 | 막는 것 | 놓치는 것 | 실제 방문자의 부담 |
| --- | --- | --- | --- |
| [Honeypot](https://ikiform.com/ko/guides/spam-protection/honeypot.md) | 모든 입력을 채우는 스크립트 | CSS를 읽거나 숨겨진 필드를 건너뛰는 봇, 그리고 사람 | 스크린 리더와 자동 완성에서 숨겨져 있다면 없음 |
| 시간 함정 | 누구도 그렇게 빨리 입력할 수 없는 속도로 보낸 POST | 느린 봇과 기다리는 봇 | 최소 시간이 몇 초 정도라면 없음 |
| 링크 수, 스팸 문구, Akismet 같은 콘텐츠 점수 | SEO 홍보, 링크 뿌리기, 카지노와 약 광고 스팸 | 깔끔해 보이는 스팸. 링크가 있는 실제 메시지를 잘못 표시할 수 있음 | 걸러 낸 것을 검토한다면 없음 |
| [Rate limiting](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md) | 폭주와 반복 실행되는 스크립트 | 느리게 분산된 스팸 | 하나의 IP를 쓰는 사무실이나 행사장은 엄격한 제한에 걸릴 수 있음 |
| 허용 도메인 | 다른 곳에서 엔드포인트로 바로 보낸 POST | `Origin` 헤더를 위조하는 스크립트 | 없음 |
| [Cloudflare Turnstile](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md) | 대부분의 자동화된 브라우저 | 돈을 받는 사람 스패머 | 보통은 보이지 않음. 가끔 클릭이 필요하고 스크립트가 필요함 |
| [reCAPTCHA 또는 hCaptcha](https://ikiform.com/ko/guides/spam-protection/recaptcha-alternatives.md) | 대부분의 자동화된 브라우저 | 돈을 받는 사람 스패머와 캡차 풀이 서비스 | 일부 방문자에게 이미지 퍼즐, 그리고 서드파티 스크립트 |

처음 다섯 가지부터 시작하세요. 눈에 보이지 않으면서 작은 사이트에 오는 스팸의 대부분을 잡습니다. 사람 확인은 그 전이 아니라, 대시보드에서 스팸이 여전히 통과한다는 것이 보일 때 추가하세요. 모든 방문자가 부담을 느낄 수 있는 유일한 계층이기 때문입니다. [캡차 없이 스팸 막기](https://ikiform.com/ko/guides/spam-protection/without-captcha.md) 페이지에서 보이지 않는 계층만으로 어디까지 막을 수 있는지 볼 수 있습니다.

## 스팸과 싸우기 전에 스팸을 읽기

해결책을 고르기 전에 스팸 메시지 열 개를 살펴보세요. 공통점을 보면 누가 보내는지 알 수 있고, 그것이 막아 줄 계층을 알려 줍니다.

다이어그램: 스팸의 모습에서 그것을 막는 계층까지

1. 모든 필드에 무작위 텍스트가 있다면 모든 입력을 채우는 스크립트입니다. 허니팟이 잡습니다.
2. 한 출처에서 분당 수십 건의 POST가 온다면 반복 실행되는 스크립트입니다. 레이트 리밋이 막습니다.
3. 페이지가 로드된 지 1초 안에 보낸 깔끔한 POST는 헤드리스 브라우저에서 옵니다. 시간 함정이나 사람 확인이 막습니다.
4. 링크가 담긴 정중한 홍보 문구는 폼마다 돈을 받는 사람에게서 옵니다. 콘텐츠 점수와 여러분의 직접 검토만 이를 잡을 수 있습니다.

마지막 줄은 어떤 캡차로도 막을 수 없습니다. 사람이 풀 수 있기 때문에, 콘텐츠 점수와 매주 스팸 폴더를 확인하는 것만이 유일한 방어입니다.

## 여러 계층을 연결한 문의 폼

스택별로 만든 하나의 문의 폼입니다. 모든 버전에 화면 밖 허니팟과 사람 확인이 들어 있습니다. 브라우저 전용 버전은 키가 필요 없는 Ikiform의 관리형 Turnstile 검사를 사용합니다. 서버 버전은 직접 구현하는 방식을 보여 줍니다. 서버가 여러분의 Cloudflare 키로 Turnstile 토큰을 검증한 다음 필드를 전달합니다. 아래에서 스택을 고르세요. 선택은 이 가이드의 모든 코드 예제에 그대로 적용됩니다.

서버 탭은 작은 헬퍼 두 개를 함께 씁니다. `verifyTurnstile`은 토큰이 진짜인지 Cloudflare에 묻고, `sendToIkiform`은 시크릿 키와 방문자의 `Origin` 헤더를 붙여 필드를 전달합니다. 서버 측 fetch는 자체 `Origin`을 보내지 않으며, Ikiform은 이 헤더를 읽어 무료 `localhost` 테스트를 알아봅니다. 두 헬퍼 모두 실패 시 닫힘 방식이라, Cloudflare에 연결할 수 없으면 조용히 통과시키지 않고 방문자에게 오류를 보여 줍니다.

`lib/spam.ts`:

```ts
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export interface Contact {
  name: string;
  email: string;
  message: string;
  _gotcha: string;
}

export function readContact(data: FormData): Contact {
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    message: String(data.get("message") ?? "").trim(),
    _gotcha: String(data.get("_gotcha") ?? ""),
  };
}

export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
  if (!input.token || input.token.length > 2048) return false;
  const body = new URLSearchParams({ secret: input.secret, response: input.token });
  if (input.ip) body.set("remoteip", input.ip);
  try {
    const response = await fetch(SITEVERIFY, { method: "POST", body });
    const result = (await response.json()) as { success?: boolean };
    return result.success === true;
  } catch {
    return false;
  }
}

export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${secretKey}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(contact),
    });
    const result = await response.json().catch(() => null);
    if (result?.ok) return { ok: true as const };
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.values(fields)[0]?.[0];
    return {
      ok: false as const,
      status: response.status,
      message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
    };
  } catch {
    return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
  }
}
```

**HTML**

일반 폼에는 스크립트가 필요 없습니다. Ikiform 폼의 **Spam check**(스팸 검사)를 **Ikiform**으로 설정하면, 토큰 없이 들어온 POST에 ikiform.com의 짧은 확인 페이지가 표시되어 Turnstile을 실행하고 필드를 이어서 보냅니다. 보이지 않게 통과하는 사람은 전혀 알아차리지 못합니다.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required maxlength="120" />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10" maxlength="5000"></textarea>

  <div class="hp" aria-hidden="true">
    <label for="hp-field">Leave this empty</label>
    <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>

  <button>Send message</button>
</form>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

허니팟은 `display: none` 대신 화면 밖에 둡니다. 일부 봇은 그런 방식으로 숨겨진 필드를 건너뛰기 때문입니다. 래퍼의 `aria-hidden`과 입력의 `tabindex`가 스크린 리더와 키보드가 접근하지 못하게 합니다.

**Next.js**

서버 액션이 여러분의 키로 Turnstile 토큰을 검증한 다음 Ikiform 시크릿 키로 필드를 전달합니다. 허니팟은 그대로 함께 전달되므로, 채워진 경우 검토할 수 있는 스팸으로 저장됩니다. 위 헬퍼를 `lib/spam.ts`로 저장하세요. `TURNSTILE_SECRET_KEY`와 `IKIFORM_SECRET_KEY`를 서버 전용 환경 변수로 설정하고, Ikiform 폼의 **Spam check**는 **Off**로 두세요. 사람 확인은 이미 서버에서 실행했습니다.

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { readContact, sendToIkiform, verifyTurnstile } from "@/lib/spam";

export interface ContactState {
  ok: boolean;
  message: string;
  values?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const contact = readContact(formData);
  const values = { name: contact.name, email: contact.email, message: contact.message };
  const incoming = await headers();
  const forwarded = incoming.get("x-forwarded-for");

  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: forwarded?.split(",")[0]?.trim(),
  });
  if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again.", values };

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, contact, incoming.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return { ok: false, message: result.message, values };
}
```

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { Turnstile } from "@/components/turnstile";
import { type ContactState, sendMessage } from "./actions";

const initial: ContactState = { ok: false, message: "" };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendMessage, initial);

  if (state.ok) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.values?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.values?.message} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} reset={state} />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

`components/turnstile.tsx`:

```tsx
"use client";

import { useEffect, useRef } from "react";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action?: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    if (window.turnstile) return resolve(window.turnstile);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => window.turnstile && resolve(window.turnstile), { once: true });
  });
}

export function Turnstile({ siteKey, reset }: { siteKey: string; reset?: unknown }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    let active = true;
    loadTurnstile().then((turnstile) => {
      if (active && box.current) widgetId = turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    });
    return () => {
      active = false;
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey, reset]);

  return <div ref={box} />;
}
```

액션이 반환될 때마다 위젯을 명시적으로 렌더링하고 다시 렌더링합니다. Turnstile 토큰은 한 번만 쓸 수 있기 때문입니다. React는 액션이 실행된 뒤 폼을 초기화하므로, `defaultValue`로 오류 후에 방문자가 입력한 텍스트를 되돌려 놓습니다.

Next.js에서 더 자세히 알아보려면 [Turnstile을 이용한 Next.js 폼 스팸 방지](https://ikiform.com/ko/guides/spam-protection/nextjs.md)을(를) 읽어 보세요.

**Astro**

Astro Action이 Zod로 필드를 검증하고, `context.clientAddress`로 토큰을 확인한 뒤 POST를 전달합니다. 위 헬퍼를 `src/lib/spam.ts`로 저장하고, 두 키는 `astro:env/server`의 `getSecret`으로 읽으세요. 폼은 클라이언트 JavaScript 없이 POST하므로 시도할 때마다 페이지가 새로 로드되고 위젯도 새로 렌더링됩니다. 어댑터를 추가하고 `prerender = false`를 유지하세요. `TURNSTILE_SECRET_KEY`와 `IKIFORM_SECRET_KEY`를 서버 전용 환경 변수로 설정하고, Ikiform 폼의 **Spam check**는 **Off**로 두세요. 사람 확인은 이미 서버에서 실행했습니다.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
import { sendToIkiform, verifyTurnstile } from "../lib/spam";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1).max(120),
      email: z.email(),
      message: z.string().trim().min(10).max(5000),
      _gotcha: z.string().nullish(),
      "cf-turnstile-response": z.string().nullish(),
    }),
    handler: async (input, context) => {
      const human = await verifyTurnstile({
        secret: getSecret("TURNSTILE_SECRET_KEY") ?? "",
        token: input["cf-turnstile-response"] ?? "",
        ip: context.clientAddress,
      });
      if (!human) throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try the check again." });

      const result = await sendToIkiform(getSecret("IKIFORM_SECRET_KEY") ?? "", {
        name: input.name,
        email: input.email,
        message: input.message,
        _gotcha: input._gotcha ?? "",
      }, context.request.headers.get("origin"));
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.message });
      return { sent: true };
    },
  }),
};
```

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll reply by email.</p>
) : (
  <form method="POST" action={actions.sendMessage}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div class="cf-turnstile" data-sitekey={siteKey} data-action="contact"></div>
    {result?.error && (
      <p role="alert">{isInputError(result.error) ? "Check your name, email and message." : result.error.message}</p>
    )}
    <button>Send message</button>
  </form>
)}

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

Astro에서 더 자세히 알아보려면 [Turnstile을 이용한 Astro 폼 스팸 방지](https://ikiform.com/ko/guides/spam-protection/astro.md)을(를) 읽어 보세요.

**TanStack Start**

`createServerFn` POST 함수가 `getRequestHeader`로 방문자의 IP를 읽고, 토큰을 검증한 뒤 필드를 전달합니다. 위젯 컴포넌트는 Next.js 탭과 같은 `src/components/turnstile.tsx`입니다. `TURNSTILE_SECRET_KEY`와 `IKIFORM_SECRET_KEY`를 서버 전용 환경 변수로 설정하고, Ikiform 폼의 **Spam check**는 **Off**로 두세요. 사람 확인은 이미 서버에서 실행했습니다.

`src/routes/contact.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";

const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { contact: readContact(data), token: String(data.get("cf-turnstile-response") ?? "") };
  })
  .handler(async ({ data }) => {
    const human = await verifyTurnstile({
      secret: process.env.TURNSTILE_SECRET_KEY!,
      token: data.token,
      ip: getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim(),
    });
    if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again." };
    const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, data.contact, getRequestHeader("origin"));
    return result.ok ? { ok: true, message: "Thanks. We'll reply by email." } : { ok: false, message: result.message };
  });

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const send = useServerFn(sendMessage);
  const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setState(await send({ data }));
    } catch {
      setState({ ok: false, message: "Couldn't send your message. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (state?.ok) return <p role="status">{state.message}</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={state} />
      {state?.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

TanStack Start에서 더 자세히 알아보려면 [Turnstile을 이용한 TanStack Start 폼 스팸 방지](https://ikiform.com/ko/guides/spam-protection/tanstack-start.md)을(를) 읽어 보세요.

**React Router**

라우트의 `action`이 토큰을 검증하고 POST를 전달합니다. `actionData`를 `reset`으로 넘기면 응답마다 새 위젯이 렌더링됩니다. 위젯 컴포넌트는 Next.js 탭의 것과 같습니다. `TURNSTILE_SECRET_KEY`와 `IKIFORM_SECRET_KEY`를 서버 전용 환경 변수로 설정하고, Ikiform 폼의 **Spam check**는 **Off**로 두세요. 사람 확인은 이미 서버에서 실행했습니다.

`app/routes/contact.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
  });
  if (!human) return data({ ok: false, message: "We couldn't confirm you're human. Try the check again." }, { status: 403 });

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, readContact(formData), request.headers.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return data({ ok: false, message: result.message }, { status: result.status });
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const navigation = useNavigation();
  const pending = navigation.state === "submitting";

  if (actionData?.ok) return <p role="status">{actionData.message}</p>;

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={actionData} />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

React Router에서 더 자세히 알아보려면 [Turnstile을 이용한 React Router 폼 스팸 방지](https://ikiform.com/ko/guides/spam-protection/react-router.md)을(를) 읽어 보세요.

**SvelteKit**

기본 폼 액션이 `getClientAddress()`로 토큰을 검증하고 POST를 전달합니다. 이 버전은 일부러 `use:enhance`를 빼서, 시도할 때마다 페이지가 새로 로드되고 Turnstile이 새 토큰과 함께 새 위젯을 렌더링하게 합니다. 위 헬퍼를 `src/lib/spam.ts`로 저장하세요. 키는 `src/env.ts`에서 `defineEnvVars`로 선언하고 `PUBLIC_TURNSTILE_SITE_KEY`에 `public: true`를 지정해, 페이지에서 `$app/env/public`으로 가져올 수 있게 하세요. `TURNSTILE_SECRET_KEY`와 `IKIFORM_SECRET_KEY`를 서버 전용 환경 변수로 설정하고, Ikiform 폼의 **Spam check**는 **Off**로 두세요. 사람 확인은 이미 서버에서 실행했습니다.

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, TURNSTILE_SECRET_KEY } from "$app/env/private";
import { readContact, sendToIkiform, verifyTurnstile } from "#lib/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const data = await request.formData();
    const contact = readContact(data);
    const values = { name: contact.name, email: contact.email, message: contact.message };

    const human = await verifyTurnstile({
      secret: TURNSTILE_SECRET_KEY,
      token: String(data.get("cf-turnstile-response") ?? ""),
      ip: getClientAddress(),
    });
    if (!human) return fail(403, { values, error: "We couldn't confirm you're human. Try the check again." });

    const result = await sendToIkiform(IKIFORM_SECRET_KEY, contact, request.headers.get("origin") ?? url.origin);
    if (!result.ok) return fail(result.status, { values, error: result.message });
    return { sent: true };
  },
} satisfies Actions;
```

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

<svelte:head>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form method="POST">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" value={form?.values?.message ?? ""}></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button>Send message</button>
  </form>
{/if}
```

SvelteKit에서 더 자세히 알아보려면 [Turnstile을 이용한 SvelteKit 폼 스팸 방지](https://ikiform.com/ko/guides/spam-protection/sveltekit.md)을(를) 읽어 보세요.

**Nuxt**

서버 라우트가 토큰을 검증하고 POST를 전달합니다. 위 헬퍼를 `server/utils/spam.ts`로 저장하면 Nuxt가 자동으로 가져옵니다. 컴포넌트는 Turnstile을 명시적으로 렌더링하고 시도가 실패하면 초기화합니다. `TURNSTILE_SECRET_KEY`와 `IKIFORM_SECRET_KEY`를 서버 전용 환경 변수로 설정하고, Ikiform 폼의 **Spam check**는 **Off**로 두세요. 사람 확인은 이미 서버에서 실행했습니다.

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string | undefined>>(event);
  const config = useRuntimeConfig(event);

  const human = await verifyTurnstile({
    secret: config.turnstileSecretKey,
    token: body["cf-turnstile-response"] ?? "",
    ip: getRequestIP(event, { xForwardedFor: true }),
  });
  if (!human) throw createError({ status: 403, message: "We couldn't confirm you're human. Try the check again." });

  const result = await sendToIkiform(config.ikiformSecretKey, {
    name: body.name?.trim() ?? "",
    email: body.email?.trim() ?? "",
    message: body.message?.trim() ?? "",
    _gotcha: body._gotcha ?? "",
  }, getRequestHeader(event, "origin"));
  if (!result.ok) throw createError({ status: result.status, message: result.message });
  return { sent: true };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
    turnstileSecretKey: "",
    public: {
      turnstileSiteKey: "",
    },
  },
});
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const config = useRuntimeConfig();
const fields = reactive({ name: "", email: "", message: "", _gotcha: "" });
const box = ref<HTMLDivElement>();
const token = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
let widgetId: string | undefined;

function api() {
  return (window as Window & { turnstile?: TurnstileApi }).turnstile;
}

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    const ready = api();
    if (ready) return resolve(ready);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => api() && resolve(api()!), { once: true });
  });
}

onMounted(async () => {
  const turnstile = await loadTurnstile();
  if (!box.value) return;
  widgetId = turnstile.render(box.value, {
    sitekey: config.public.turnstileSiteKey,
    action: "contact",
    callback: (value) => (token.value = value),
    "expired-callback": () => (token.value = ""),
  });
});

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    token.value = "";
    if (widgetId) api()?.reset(widgetId);
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

Nuxt에서 더 자세히 알아보려면 [Turnstile을 이용한 Nuxt 폼 스팸 방지](https://ikiform.com/ko/guides/spam-protection/nuxt.md)을(를) 읽어 보세요.

**React**

싱글 페이지 앱에는 Turnstile 시크릿을 보관할 서버가 없으므로 대신 Ikiform의 관리형 검사를 사용합니다. `@ikiform/sdk`의 `challenge()`는 Turnstile에 클릭이 필요할 때만 보이는 작은 ikiform.com 프레임을 마운트하고, `submit()`에 쓸 토큰을 반환합니다. **Spam check**를 **Ikiform**으로 설정하고 **Allowed domains**(허용 도메인)에 도메인을 추가하세요.

`src/ContactForm.tsx`:

```tsx
import { challenge, submit } from "@ikiform/sdk";
import { type FormEvent, useRef, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const check = useRef<HTMLDivElement>(null);
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setStatus("sending");
    setError("");
    try {
      const turnstileToken = await challenge(FORM_ID, { container: check.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      if (result.ok) {
        setStatus("sent");
        return;
      }
      setError(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
    } catch {
      setError("The human check didn't finish. Try again.");
    }
    setStatus("idle");
  }

  if (status === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <div ref={check} />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

같은 관리형 검사를 Vue 싱글 파일 컴포넌트로 구현한 것입니다. 방문자가 클릭해야 할 때만 빈 `div` 안에 프레임이 나타납니다.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { challenge, submit } from "@ikiform/sdk";
import { ref } from "vue";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const check = ref<HTMLDivElement>();
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send(event: SubmitEvent) {
  const data = new FormData(event.target as HTMLFormElement);
  status.value = "sending";
  error.value = "";
  try {
    const turnstileToken = await challenge(FORM_ID, { container: check.value });
    const result = await submit(FORM_ID, data, { turnstileToken });
    if (result.ok) {
      status.value = "sent";
      return;
    }
    error.value = Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message;
  } catch {
    error.value = "The human check didn't finish. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="check" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- 모든 버전은 HTML 탭의 style 블록에 있는 `.hp` 클래스를 사용합니다. 전역 CSS에 넣어 두세요.
- 허니팟 이름은 Ikiform 기본값인 `_gotcha`입니다. 서버 버전은 이 필드를 그대로 전달하므로, 채워진 경우에도 `honeypot` 사유와 함께 스팸 폴더에 들어갑니다.
- Turnstile은 폼에 숨겨진 `cf-turnstile-response` 입력을 추가합니다. 토큰은 300초 동안 유효하고 한 번만 쓸 수 있으므로 시도마다 새 토큰이 필요합니다.
- Cloudflare의 테스트 키를 쓰면 이 모든 것을 로컬에서 실행할 수 있습니다. [Turnstile 가이드](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)에 키 목록이 있습니다.

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform은 **Allowed domains**와 자체 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5건인 Standard 제한이 모든 방문자를 합쳐서 셉니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

아래 필드 정의를 직접 시험해 보세요. 브라우저에서 Ikiform의 검증기를 실행하며, 서버가 스팸 점수를 계산하기 전에 적용하는 것과 같은 규칙입니다.

폼: Contact

- **이름** (`name`, text, 필수)
- **이메일** (`email`, email, 필수)
- **주제** (`topic`, select)
- **메시지** (`message`, textarea, 필수)

[문의 폼 템플릿](https://ikiform.com/forms/contact-form)과 같은 필드입니다. 이 정의를 폼의 **Fields** 탭에 붙여 넣으면 Ikiform은 정의와 맞지 않는 POST를 `422 validation_failed`로 거부합니다.

## 허니팟

허니팟은 사람에게는 보이지 않고 봇은 채우는 입력입니다. 값이 들어 있다면 소프트웨어가 보낸 POST입니다. 방문자에게는 아무 부담이 없고, HTML을 파싱해 찾은 입력을 모두 채우는 가장 오래되고 흔한 봇을 잡습니다.

요령 자체보다 잘 숨기는 것이 더 중요합니다. 허니팟을 대충 만들면 세 가지 문제가 생깁니다.

- **스크린 리더가 읽어 줍니다.** 시각 장애가 있는 방문자는 필드가 하나 더 있다고 듣고 채운 뒤 스팸으로 표시됩니다. `aria-hidden="true"`로 감싸고, 비워 두라는 레이블을 붙이세요.
- **키보드 포커스가 갑니다.** Tab 키로 폼을 이동하다가 보이지 않는 입력에서 멈춥니다. `tabindex="-1"`로 탭 순서에서 빼세요.
- **자동 완성이 채웁니다.** 브라우저와 비밀번호 관리자는 이름을 보고 필드를 채웁니다. 필드 이름을 `email2`나 `phone`으로 지으면 일부는 실제 사람의 정보로 채워 버립니다. `_gotcha`처럼 어떤 자동 완성도 알아보지 못하는 이름을 쓰고 `autocomplete="off"`를 추가하세요.

[허니팟 가이드](https://ikiform.com/ko/guides/spam-protection/honeypot.md)에서 숨기는 기법, 봇이 이를 알아채는 방법, 스택별 서버 측 검사를 다룹니다. Ikiform에서는 허니팟이 채워지면 점수에 5가 더해지며, 이것만으로 기준값 5에 도달합니다. 필드 이름은 **Honeypot field**에서 폼마다 바꿀 수 있습니다.

## 시간 검사

사람은 문의 폼을 채우는 데 몇 초가 걸립니다. 많은 봇은 몇 밀리초면 끝납니다. 시간 함정은 폼이 렌더링된 시각을 기록해 두고 POST될 때 그 간격을 확인합니다. 단순한 버전은 숨겨진 필드에 타임스탬프를 넣습니다.

`시간 함정, 단순한 버전`:

```ts
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;
```

봇은 원하는 숫자를 아무거나 보낼 수 있으므로, 평범한 타임스탬프는 신경 쓰지 않는 봇만 막습니다. 서버에서 시크릿으로 서명하면 봇은 더 늦은 렌더링 시각을 위조할 수 없습니다. [캡차 없이 스팸 막기](https://ikiform.com/ko/guides/spam-protection/without-captcha.md#signed-timestamp) 페이지에서 서명된 버전을 만듭니다.

폼이 SDK나 [shadcn/ui 컴포넌트](https://ikiform.com/ko/docs/shadcn.md)에서 온 경우 Ikiform이 렌더링 시각에 대신 서명합니다. 이들은 `_ikiform_ts`를 보내며, 렌더링 후 2초 안에 보낸 POST는 점수에 3이 더해집니다. 위조되었거나 하루 지난 타임스탬프도 3이 더해집니다. 일반 HTML 폼은 타임스탬프를 보내지 않으며 불이익을 받지 않습니다.

## 콘텐츠 점수 매기기

어떤 스팸은 사람이 직접 입력하거나 붙여 넣기 때문에 모든 봇 검사를 통과합니다. 정체를 드러내는 것은 내용입니다. 링크, 판매 문구, 일회용 주소, 두 번 보낸 같은 텍스트입니다. 신호 하나만으로 차단하지 말고, 신호마다 점수를 매겨 합계가 높으면 스팸으로 처리하세요. 메시지에 링크 하나는 평범하지만, 이름 필드의 링크는 절대 평범하지 않습니다.

| 신호 | Ikiform이 더하는 점수 |
| --- | --- |
| 허니팟이 채워짐 | +5 |
| 이름 또는 제목 필드의 링크 | +3 |
| 최근 24시간 안에 이 폼으로 같은 필드를 보냄 | +3 |
| 서명된 렌더링 시각 후 2초 안에 보냄 | +3 |
| 전체 필드에 걸쳐 링크 3개 이상 | +2 |
| "seo services" 같은 알려진 스팸 문구 22개 중 하나 | +2 |
| 일회용 이메일 도메인의 주소 | +2 |

5점 이상이면 스팸입니다. 약한 신호 하나만으로는 도달하지 않습니다. 링크 3개가 든 실제 메시지는 2점입니다. 두 개가 겹치면 자주 도달합니다. 예를 들어 이름의 링크와 스팸 문구가 함께 있을 때입니다. 콘텐츠 점수를 직접 매긴다면 오래된 호스팅 서비스로 Akismet이 있고, 많은 팀은 위와 같은 정규 표현식 몇 개로 시작합니다.

## 사람 확인

사람 확인은 브라우저가 사람에 의해 조작되고 있음을 증명하게 합니다. Cloudflare Turnstile 같은 최신 방식은 대부분 보이지 않게 실행되고, 확신이 없을 때만 체크박스를 보여 줍니다. 위젯은 브라우저에 토큰을 주고, 서버는 그 토큰을 제공자에게 보내 검증해야 합니다. 서버에서 검증하지 않는 토큰은 아무것도 막지 못합니다. 봇이 위젯을 건너뛰고 토큰 없이 POST할 수 있기 때문입니다.

Ikiform은 각 폼의 **Spam check** 아래에 세 가지 설정이 있습니다.

| 설정 | 동작 | 코드 변경 |
| --- | --- | --- |
| **Off** (the default) | 챌린지 없음. 허니팟, 시간 검사, 점수 매기기는 계속 실행됩니다. | 없음 |
| **Ikiform** | Ikiform이 ikiform.com에서 실행하는 Turnstile. Cloudflare 계정이나 키가 필요 없음 | HTML POST는 없음. `fetch` POST는 먼저 `@ikiform/sdk`의 `challenge()`를 호출 |
| **Your own Turnstile keys**(자체 Turnstile 키) | 여러분의 위젯과 시크릿 키. Ikiform이 토큰을 검증하고 호스트 이름을 허용 도메인과 비교 | 폼에 위젯을 렌더링해 `cf-turnstile-response`를 보내게 함 |

토큰 없는 `fetch` POST는 `403 challenge_required`를 받습니다. Cloudflare에 연결할 수 없으면 Ikiform은 POST를 버리지 않고 `captcha_unavailable` 사유와 함께 스팸으로 저장하므로 복원할 수 있습니다. [스팸 문서](https://ikiform.com/ko/docs/spam.md#human-check)에서 두 흐름을 모두 볼 수 있습니다. Turnstile과 Google의 reCAPTCHA, hCaptcha를 비교하고 있다면 [reCAPTCHA 대안](https://ikiform.com/ko/guides/spam-protection/recaptcha-alternatives.md)을 참고하세요.

## 레이트 리밋과 허용 도메인

레이트 리밋은 한 방문자가 POST할 수 있는 빈도에 상한을 둡니다. 스팸과 정상 메일을 구분하지는 않고 양을 막습니다. IP와 폼별로 세고, 짧은 버스트는 허용하며, `Retry-After` 헤더와 함께 `429`로 응답하세요. [레이트 리밋 가이드](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md)에서 스택별로 구현하고, 메모리 내 카운터가 서버리스 호스트에서 실패하는 이유를 설명합니다.

Ikiform은 본문을 읽기 전에 모든 POST를 정확히 셉니다. 폼마다 프리셋을 고릅니다. Standard는 방문자당 분당 5건, Relaxed는 방문자당 분당 20건, Strict는 방문자당 10분에 2건입니다. Standard는 추가로 10초에 최대 3건까지 허용하며, 폼 전체로는 분당 300건을 받습니다. 정직한 사람은 10분 안에 두 번 쓰지 않으므로 문의 폼에는 Strict가 어울립니다. 수치는 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)에 있습니다.

**Allowed domains**(허용 도메인)에는 폼이 있는 사이트를 등록합니다. 다른 페이지에서 온 POST는 스팸으로 표시되고, **Reject**를 고르면 `403 origin_not_allowed`로 거부됩니다. 다른 사람이 자기 폼을 여러분의 엔드포인트로 연결하는 것을 막습니다. 작정한 스크립트는 검사하는 헤더를 위조할 수 있으므로 자물쇠가 아니라 하나의 계층으로 여기세요.

## 스팸은 어떻게 처리되나

봇에게 걸렸다고 알려 주지 마세요. 오류는 무엇을 바꿔야 하는지 가르쳐 주지만, 평범한 성공 페이지는 아무것도 가르쳐 주지 않습니다. 그리고 스팸을 도착하자마자 삭제하지 마세요. 어떤 필터든 가끔 실제 메시지를 잡으며, 삭제한 메시지는 되돌릴 수 없습니다.

스팸으로 판정된 POST는 실제 제출과 같은 성공 응답을 받으므로 봇은 아무것도 알 수 없습니다. `spam` 상태로 저장되고, 알림 이메일과 웹훅을 보내지 않으며, 사용량에 포함되지 않고, 30일 뒤 삭제됩니다. 스팸을 차단한 워크스페이스에는 매주 월요일 합계, 가장 많이 받은 폼, 주요 사유를 담은 요약 이메일이 한 통 갑니다.

대시보드의 상태 필터에서 **Spam**을 고르면 각 항목의 점수와 사유를 볼 수 있습니다. **Not spam**을 누르면 되돌아가고, 그때 이메일과 웹훅이 나갑니다. 월요일 이메일은 워크스페이스 설정의 **Weekly spam summary**에서 끌 수 있습니다. 스팸은 30일 동안 보관되므로 적어도 한 달에 한 번은 확인하세요.

> 같은 실제 메시지가 계속 걸린다면 사유를 읽어 보세요. `business_name`이라는 필드의 링크는 이름 필드의 링크로 점수가 매겨집니다. 필드 이름을 `business`로 바꾸면 더 이상 이름 필드로 세지 않습니다.

## 상황별 가이드

아래 페이지들은 방어 수단 하나나 사이트 종류 하나를 더 깊이 다루며, 그 상황에 맞는 코드를 제공합니다.

- [허니팟 필드](https://ikiform.com/ko/guides/spam-protection/honeypot.md): 스크린 리더, 키보드, 자동 완성에 걸리지 않으면서 봇을 잡는 허니팟을 어떤 폼에든 추가하고, 스택별 서버 검사까지 구현하세요.
- [Cloudflare Turnstile](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md): 일반 HTML 폼에 Cloudflare Turnstile을 추가하세요. 위젯, 서버 측 siteverify, 테스트 키, 그리고 키나 서버 없이 실행하는 방법까지 다룹니다.
- [캡차 없이](https://ikiform.com/ko/guides/spam-protection/without-captcha.md): 허니팟, 서명된 렌더링 시각, 콘텐츠 점수, 레이트 리밋을 따로가 아니라 합쳐서 써서 캡차 없이 대부분의 폼 스팸을 막으세요.
- [reCAPTCHA 대안](https://ikiform.com/ko/guides/spam-protection/recaptcha-alternatives.md): Turnstile과 hCaptcha부터 작업 증명, 캡차 없는 방식까지 폼용 reCAPTCHA 대안을 비교하고, 작고 안전한 변경으로 전환하세요.
- [레이트 리밋](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md): 공유 저장소로 방문자별 폼 제출에 레이트 리밋을 걸고, Retry-After와 함께 429로 응답하며, 하나의 IP를 쓰는 사무실이 막히지 않게 하세요.
- [정적 사이트](https://ikiform.com/ko/guides/spam-protection/static-site.md): 시크릿을 보관하거나 POST 수를 셀 수 없는 정적 사이트 폼에서도 허니팟 파셜, 안전한 스크립트, 서버 측 검사로 스팸을 막으세요.
- [Webflow 폼 스팸](https://ikiform.com/ko/guides/spam-protection/webflow.md): Webflow 자체 스팸 설정이 실행되지 않는, 폼 백엔드로 POST하는 Webflow 폼을 화면 밖 허니팟과 사람 확인으로 보호하세요.
- [Next.js](https://ikiform.com/ko/guides/spam-protection/nextjs.md): 허니팟, 서버 액션에서 확인하는 Turnstile, Vercel·Netlify·Cloudflare에서의 IP별 제한, 그리고 이를 위한 CSP로 Next.js 폼을 스팸으로부터 보호하세요.
- [Astro](https://ikiform.com/ko/guides/spam-protection/astro.md): 허니팟, astro:env 시크릿으로 확인하는 Turnstile, IP별 제한, Astro 내장 CSP를 갖춘 Action으로 Astro 폼을 스팸으로부터 보호하세요.
- [TanStack Start](https://ikiform.com/ko/guides/spam-protection/tanstack-start.md): 클라이언트 IP를 위한 함수 미들웨어, 토큰 버킷, 서버 함수에서 검증하는 Turnstile, CSP로 TanStack Start 폼을 스팸으로부터 보호하세요.
- [React Router](https://ikiform.com/ko/guides/spam-protection/react-router.md): Turnstile을 쓰는 라우트 액션, cf-connecting-ip를 키로 하는 Workers 레이트 리밋, .server 모듈, CSP 헤더로 React Router 폼을 스팸으로부터 보호하세요.
- [SvelteKit](https://ikiform.com/ko/guides/spam-protection/sveltekit.md): getClientAddress를 쓰는 폼 액션, IP별 제한, use:enhance 뒤의 Turnstile, 이를 허용하는 CSP로 SvelteKit 폼을 스팸으로부터 보호하세요.
- [Nuxt](https://ikiform.com/ko/guides/spam-protection/nuxt.md): Turnstile을 쓰는 Nitro 서버 라우트, Nitro 스토리지의 IP별 제한, runtimeConfig 시크릿, routeRules로 설정한 CSP로 Nuxt 폼을 스팸으로부터 보호하세요.

## 작동 확인하기

실제로 사용하기 전에 테스트 폼으로 다음을 실행해 보세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 `localhost`에서 보낸 테스트 제출을 매월 50건까지 받습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번만 결제하세요. 제출 10,000건에 $49, 50,000건에 $149이며 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서 오는 POST든 받습니다.
2. 페이지에서 폼을 채워 제출하세요. 여러분의 감사 페이지나 Ikiform의 호스팅 감사 페이지로 이동합니다.
3. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
4. 허니팟을 채워 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
5. 이름 필드에 링크를 넣고 메시지에 `seo services` 문구를 넣어 보내세요. `url_in_name`과 `keyword` 사유와 함께 스팸으로 저장됩니다.
6. **Spam check**를 **Ikiform**으로 바꾸고 일반 HTML 폼을 제출하세요. 잠깐 확인 페이지가 보인 뒤 감사 페이지로 이동합니다.
7. 폼을 연속으로 몇 번 제출하세요. 제한을 넘으면 폼은 `429`로 응답하고 방문자에게는 Ikiform의 속도 줄이기 안내 페이지가 표시됩니다.
8. 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 **Spam**으로 바꿔 보세요. 스팸은 실제 제출과 같은 성공 응답을 받습니다.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않음 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 사유를 확인하고, 스팸 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
| `403 challenge_required` | 폼의 스팸 검사가 **Ikiform**으로 설정되어 있는데 `fetch` POST에 토큰이 없었습니다. 제출할 때마다 먼저 `@ikiform/sdk`의 `challenge(formId)`로 토큰을 받거나, 검사를 끄세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 들어감 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
| `429 rate_limited` | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5건을 허용합니다. `Retry-After`만큼 기다리거나, 하나의 공유 IP에서 POST하는 폼에는 Relaxed 또는 Custom을 고르세요. |
| `403 captcha_failed` | Turnstile 토큰이 없거나, 만료되었거나, 두 번 사용되었습니다. 토큰은 300초 동안 유효하고 한 번만 쓸 수 있으므로, 시도할 때마다 새 위젯을 렌더링하거나 `challenge()`를 다시 호출하세요. |
| 스팸이 여전히 받음 상태로 들어옴 | 하나를 열어 나머지와 공통점이 무엇인지 읽어 보세요. 스크립트 스팸이라면 **Spam check**를 **Ikiform**으로 설정하고, 사람이 입력한 홍보라면 더 엄격한 레이트 리밋과 주간 검토가 필요합니다. |
| 실제 방문자가 보낼 수 없다고 알려 옴 | 허니팟이 스크린 리더에서 숨겨져 있고 자동 완성이 건너뛰는 이름인지, 엄격한 레이트 리밋이 한 사무실 IP를 쓰는 사람들을 잡고 있지 않은지 확인하세요. |

## 질문

### 문의 폼 스팸을 막는 가장 좋은 방법은 무엇인가요?

한 가지 요령이 아니라 여러 계층입니다. 숨겨진 허니팟, 폼 작성 최소 시간, 콘텐츠 점수, 레이트 리밋이 실제 방문자에게 부담 없이 대부분의 스팸을 막습니다. 그래도 스팸이 통과하면 Cloudflare Turnstile 같은 사람 확인을 추가하고, 토큰은 서버에서 검증하세요.

### 문의 폼에 캡차가 필요한가요?

처음에는 필요 없습니다. 문의 폼 스팸의 대부분은 허니팟과 레이트 리밋으로 막을 수 있는 단순한 봇에서 옵니다. 스팸 폴더에 봇이 여전히 통과하는 것이 보일 때 캡차를 추가하고, 이미지 퍼즐보다는 Turnstile 같은 보이지 않는 방식을 고르세요.

### 왜 실제 사람에게서 스팸이 오나요?

일부 스팸은 폼마다 돈을 받는 사람이 붙여 넣은 것으로, 보통 SEO나 개발 외주 홍보입니다. 봇 검사로는 사람을 막을 수 없습니다. 콘텐츠 점수가 링크와 문구로 많은 수를 잡고, 나머지는 스팸 폴더에서 삭제하면 됩니다.

### Ikiform은 보낸 사람에게 메시지가 스팸으로 표시되었다고 알려 주나요?

아니요. 스팸은 실제 메시지와 같은 성공 응답을 받으므로 봇은 아무것도 알 수 없습니다. 30일 동안 보관되고, 이메일이나 웹훅을 보내지 않으며, 대시보드에서 스팸 아님으로 표시할 수 있습니다.

### Ikiform에서 Turnstile을 쓰려면 Cloudflare 계정이 필요한가요?

아니요. 폼의 Spam check를 Ikiform으로 설정하면 관리할 키 없이 ikiform.com에서 Turnstile이 실행됩니다. 페이지에 여러분만의 위젯을 두고 싶을 때만 자체 Turnstile 키를 사용하세요.

### 허니팟이 접근성을 해칠 수 있나요?

대충 숨기면 그렇습니다. 래퍼에 aria-hidden을 붙여 화면 밖으로 숨기고, tabindex -1로 탭 순서에서 빼고, 브라우저가 자동 완성하지 않는 이름을 붙이세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료이며, 결제하기 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 제출 10,000건에 $49 또는 50,000건에 $149로 한 번만 결제할 수 있으며 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [허니팟 필드](https://ikiform.com/ko/guides/spam-protection/honeypot.md): 스크린 리더, 키보드, 자동 완성에 걸리지 않으면서 봇을 잡는 허니팟을 어떤 폼에든 추가하고, 스택별 서버 검사까지 구현하세요.
- [Cloudflare Turnstile](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md): 일반 HTML 폼에 Cloudflare Turnstile을 추가하세요. 위젯, 서버 측 siteverify, 테스트 키, 그리고 키나 서버 없이 실행하는 방법까지 다룹니다.
- [캡차 없이](https://ikiform.com/ko/guides/spam-protection/without-captcha.md): 허니팟, 서명된 렌더링 시각, 콘텐츠 점수, 레이트 리밋을 따로가 아니라 합쳐서 써서 캡차 없이 대부분의 폼 스팸을 막으세요.
- [reCAPTCHA 대안](https://ikiform.com/ko/guides/spam-protection/recaptcha-alternatives.md): Turnstile과 hCaptcha부터 작업 증명, 캡차 없는 방식까지 폼용 reCAPTCHA 대안을 비교하고, 작고 안전한 변경으로 전환하세요.
- [레이트 리밋](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md): 공유 저장소로 방문자별 폼 제출에 레이트 리밋을 걸고, Retry-After와 함께 429로 응답하며, 하나의 IP를 쓰는 사무실이 막히지 않게 하세요.
- [정적 사이트](https://ikiform.com/ko/guides/spam-protection/static-site.md): 시크릿을 보관하거나 POST 수를 셀 수 없는 정적 사이트 폼에서도 허니팟 파셜, 안전한 스크립트, 서버 측 검사로 스팸을 막으세요.
- [Webflow 폼 스팸](https://ikiform.com/ko/guides/spam-protection/webflow.md): Webflow 자체 스팸 설정이 실행되지 않는, 폼 백엔드로 POST하는 Webflow 폼을 화면 밖 허니팟과 사람 확인으로 보호하세요.
- [Next.js](https://ikiform.com/ko/guides/spam-protection/nextjs.md): 허니팟, 서버 액션에서 확인하는 Turnstile, Vercel·Netlify·Cloudflare에서의 IP별 제한, 그리고 이를 위한 CSP로 Next.js 폼을 스팸으로부터 보호하세요.
- [Astro](https://ikiform.com/ko/guides/spam-protection/astro.md): 허니팟, astro:env 시크릿으로 확인하는 Turnstile, IP별 제한, Astro 내장 CSP를 갖춘 Action으로 Astro 폼을 스팸으로부터 보호하세요.
- [TanStack Start](https://ikiform.com/ko/guides/spam-protection/tanstack-start.md): 클라이언트 IP를 위한 함수 미들웨어, 토큰 버킷, 서버 함수에서 검증하는 Turnstile, CSP로 TanStack Start 폼을 스팸으로부터 보호하세요.
- [React Router](https://ikiform.com/ko/guides/spam-protection/react-router.md): Turnstile을 쓰는 라우트 액션, cf-connecting-ip를 키로 하는 Workers 레이트 리밋, .server 모듈, CSP 헤더로 React Router 폼을 스팸으로부터 보호하세요.
- [SvelteKit](https://ikiform.com/ko/guides/spam-protection/sveltekit.md): getClientAddress를 쓰는 폼 액션, IP별 제한, use:enhance 뒤의 Turnstile, 이를 허용하는 CSP로 SvelteKit 폼을 스팸으로부터 보호하세요.
- [Nuxt](https://ikiform.com/ko/guides/spam-protection/nuxt.md): Turnstile을 쓰는 Nitro 서버 라우트, Nitro 스토리지의 IP별 제한, runtimeConfig 시크릿, routeRules로 설정한 CSP로 Nuxt 폼을 스팸으로부터 보호하세요.
- [빌더 폼 한계 넘어서기](https://ikiform.com/ko/guides/website-builder-forms.md): Webflow, Netlify, Carrd, Framer 폼이 한도나 빠진 기능에 부딪히면, 디자인한 폼은 그대로 두고 대신 폼 백엔드를 가리키게 하세요.
- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
