문의 폼 스팸 막기

문의 폼 스팸 막기5분 분량

정적 사이트 문의 폼의 스팸을 막는 방법

시크릿을 보관하거나 POST 수를 셀 수 없는 정적 사이트 폼에서도 허니팟 파셜, 안전한 스크립트, 서버 측 검사로 스팸을 막으세요.

Preet SutharIkiform 창업자

정적 사이트에는 서버가 없으므로, 페이지의 어떤 것도 시크릿 키를 보관하거나, 캡차 토큰을 검증하거나, 방문자별 POST 수를 셀 수 없습니다. 이런 검사는 다른 곳에서 실행해야 합니다. 호스트의 함수나, 폼이 POST하는 폼 백엔드입니다. 어느 쪽이든 폼에 접근성을 갖춘 허니팟을 넣고, JavaScript 없이도 폼이 작동하게 유지하세요.

정적 사이트에서 다른 점

정적 사이트에서는 배포하는 모든 바이트가 공개됩니다. HTML이나 JavaScript에 있는 것은 무엇이든 봇이 읽고 건너뛸 수 있으므로, 클라이언트 측 스팸 검사는 강제가 아니라 권고일 뿐입니다. 가장 중요한 세 가지 검사는 모두 서버가 필요합니다.

  • 캡차 토큰 검증에는 시크릿 키가 필요하며, 이 키는 공개 파일에 둘 수 없습니다.
  • 방문자별 POST 수 세기에는 요청 사이에도 유지되는 저장소가 필요합니다.
  • 허니팟 읽기에는 POST가 도착한 뒤 봇의 손이 닿지 않는 곳에서 실행되는 코드가 필요합니다.

정적 호스트는 페이지로 오는 POST도 받지 않습니다. Vercel과 GitHub Pages는 정적 페이지로 온 폼 POST에 405 Method Not Allowed로 응답하므로, 스팸을 따지기 전에 폼에는 엔드포인트부터 필요합니다.

정적 사이트 폼을 연결하는 세 가지 방식과 각 방식에서 스팸 검사가 실행되는 곳페이지만 있을 때호스트 함수를 쓸 때폼 백엔드를 쓸 때정적 페이지폼이 자기 자신에게 POST호스트코드가 실행되지 않음405 또는 유실정적 페이지함수시크릿, 허니팟, 토큰 검사이메일 또는 저장소정적 페이지폼 백엔드허니팟, 점수, 제한, Turnstile받은편지함과 웹훅정적 사이트 폼을 연결하는 세 가지 방식과 각 방식에서 스팸 검사가 실행되는 곳페이지만 있을 때호스트 함수를 쓸 때폼 백엔드를 쓸 때정적 페이지폼이 자기 자신에게 POST호스트코드가 실행되지 않음405 또는 유실정적 페이지함수시크릿, 허니팟, 토큰 검사이메일 또는 저장소정적 페이지폼 백엔드허니팟, 점수, 제한, Turnstile받은편지함과 웹훅
  1. 자기 자신에게 POST하는 정적 페이지는 코드를 실행하지 않는 호스트에 닿으므로, POST는 405로 실패하거나 어디에도 가지 않습니다.
  2. 호스트 함수를 쓰면 페이지가 시크릿을 보관하는 함수로 POST하고, 함수가 허니팟을 읽고 토큰을 검증한 뒤 메시지를 전달합니다.
  3. 폼 백엔드를 쓰면 페이지가 백엔드로 바로 POST하고, 백엔드가 허니팟, 점수, 레이트 리밋, 사람 확인을 실행한 뒤 이메일을 보냅니다.
작동하는 두 방식 모두 POST가 브라우저를 떠난 뒤에 검사가 실행됩니다. 봇이 건너뛸 수 없는 곳은 거기뿐입니다.

선택지

선택지스팸 검사장단점
Netlify Forms모든 POST에 Akismet. 추가하면 netlify-honeypot 필드와 reCAPTCHA v2도Netlify에서만 작동. 호스트를 옮기면 폼을 다시 만들어야 함. Netlify Forms 대안 참고
호스트의 함수직접 작성하는 모든 것: 허니팟, 토큰 검사, 저장소를 쓰는 레이트 리밋직접 작성하고 유지 보수해야 하며, 뒤에 이메일이나 저장 서비스가 필요
Ikiform 같은 폼 백엔드코드 없이 허니팟, 점수, 레이트 리밋, 허용 도메인, 사람 확인별도 서비스, 제출 건수 기준 요금

재사용 가능한 파셜로 만든 폼

어떤 엔드포인트를 고르든 폼은 파셜이나 include로 한 번만 작성해, 모든 페이지가 같은 허니팟을 쓰게 하세요. 아래 버전은 Ikiform으로 POST합니다. 대신 함수를 쓰려면 action을 함수 경로로 바꾸고 _redirect를 지우세요.

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
assets/contact.css
<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
  • 허니팟은 화면 밖에 있고, 탭 순서에서 빠져 있으며, 스크린 리더에서 숨겨져 있습니다. 각 속성은 허니팟 가이드에서 설명합니다.
  • _redirect는 사람들을 감사 페이지로 보냅니다. Ikiform은 폼의 Allowed domains 중 하나에 있는 https: 주소일 때만 따릅니다.
  • Hugo, Jekyll, Eleventy 가이드에서 설정, 쇼트코드, GitHub Pages를 다룹니다.

망가뜨리지 않고 개선하기

스크립트로 방문자를 페이지에 머물게 할 수 있지만, 스크립트가 폼이 작동하는 유일한 방법이 되어서는 안 됩니다. 이 스크립트는 fetch로 POST하고, 두 경우에 일반 브라우저 POST로 돌아갑니다. 네트워크 호출이 실패할 때, 그리고 Ikiform의 사람 확인이 challenge_required로 토큰을 요구할 때입니다. 일반 POST는 대신 Ikiform의 확인 페이지를 받으므로, 스크립트를 바꾸지 않고도 검사를 켤 수 있습니다.

contact-form.html
<script>
  document.querySelectorAll("form.contact").forEach((form) => {
    const status = form.querySelector("[role=status]");
    const button = form.querySelector("button");

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      try {
        const response = await fetch(form.action, {
          method: "POST",
          headers: { Accept: "application/json" },
          body: new FormData(form),
        });
        const result = await response.json();
        if (result.ok) {
          form.replaceChildren("Thanks. We'll reply by email.");
          return;
        }
        if (result.error.code === "challenge_required") {
          form.submit();
          return;
        }
        const wait = response.headers.get("Retry-After");
        if (response.status === 429 && wait) {
          status.textContent = `Too many messages. Try again in ${wait} seconds.`;
        } else {
          status.textContent = result.error.message;
        }
      } catch {
        form.submit();
        return;
      }
      button.disabled = false;
    });
  });
</script>
  • form.submit()은 submit 이벤트를 다시 발생시키지 않고 폼을 보내므로 폴백이 무한 반복되지 않습니다.
  • 429는 Retry-After에서 대기 시간을 읽어 보여 줍니다. Ikiform은 이 헤더를 브라우저 코드에 노출합니다.
  • 성공 문구가 폼을 대체하므로 실수로 두 번 보내는 사람이 없습니다.

호스트 함수를 직접 작성한다면

Netlify, Cloudflare Pages, Vercel은 모두 정적 파일 옆에서 작은 함수를 실행합니다. 함수는 여러분의 백엔드이므로 스팸 검사도 함수가 맡습니다. 이 Netlify Function은 허니팟을 읽고, 링크로 가득 찬 메시지를 버리고, Turnstile 토큰을 검증한 뒤 Resend로 이메일을 보냅니다. 걸러 낸 POST는 모두 실제 POST와 같은 감사 페이지 리디렉션을 받습니다.

netlify/functions/contact.mts
import type { Config, Context } from "@netlify/functions";

const LINK = /https?:\/\/|www\./gi;

export default async (req: Request, context: Context) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const form = await req.formData();
  const email = String(form.get("email") ?? "");
  const message = String(form.get("message") ?? "");
  const thanks = Response.redirect(new URL("/thanks/", req.url), 303);

  if (String(form.get("_gotcha") ?? "").trim()) return thanks;
  if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;

  const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: new URLSearchParams({
      secret: process.env.TURNSTILE_SECRET_KEY ?? "",
      response: String(form.get("cf-turnstile-response") ?? ""),
      remoteip: context.ip,
    }),
  });
  const verdict = (await check.json()) as { success: boolean };
  if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);

  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: "Contact form <form@example.com>",
      to: "you@example.com",
      reply_to: email,
      subject: "New message from the website",
      text: message,
    }),
  });
  return thanks;
};

export const config: Config = { path: "/api/contact" };
  • context.ip는 방문자의 IP이며, siteverify에서 remoteip로 쓸 수 있습니다.
  • TURNSTILE_SECRET_KEY와 RESEND_API_KEY는 저장소가 아니라 사이트의 환경 변수에 설정하세요.
  • 걸러 낸 POST를 저장하지 않고 버리므로, 실수로 걸린 실제 메시지는 사라집니다. 최소한 로그는 남기세요.
  • 함수는 호출 사이에 메모리가 없으므로 레이트 리밋이 없습니다. 레이트 리밋 가이드에서 Redis로 추가합니다.

Ikiform이 정적 폼에 해 주는 것

폼이 Ikiform으로 바로 POST하면 서버 측 작업은 Ikiform에서 이루어집니다. 허니팟은 _gotcha이고, 콘텐츠 점수와 방문자별 레이트 리밋이 모든 POST에서 실행되며, Allowed domains는 다른 사이트에서 온 POST를 스팸으로 표시합니다. 키 없이 Cloudflare Turnstile을 쓰려면 Spam check를 Ikiform으로 바꾸세요. 일반 HTML POST는 확인 페이지에서 이를 실행하고, 위 스크립트는 그 페이지로 돌아갑니다. 스팸으로 판정된 POST는 실제 제출과 같은 성공 응답을 받으므로 봇은 아무것도 알 수 없습니다. spam 상태로 저장되고, 알림 이메일과 웹훅을 보내지 않으며, 사용량에 포함되지 않고, 30일 뒤 삭제됩니다. 스팸을 차단한 워크스페이스에는 매주 월요일 합계, 가장 많이 받은 폼, 주요 사유를 담은 요약 이메일이 한 통 갑니다.

폼이 게시되는 모든 호스트를 Allowed domains에 등록하세요. 여러분의 도메인과, 테스트하는 동안에는 username.github.io 같은 호스트 자체 주소도 등록합니다. Netlify 배포 미리보기와 Vercel 미리보기 URL은 메인 사이트 주소의 하위 도메인이 아니므로, 미리보기를 테스트할 때는 해당 미리보기 호스트를 정확히 추가하세요.

작동 확인하기

  1. JavaScript를 끄고 제출하세요. 폼은 여전히 POST되고 감사 페이지로 이동합니다.
  2. JavaScript를 켜고 제출하세요. 폼이 감사 문구로 바뀝니다.
  3. 허니팟을 채워 _gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
  4. Spam check를 Ikiform으로 설정하고 스크립트를 켠 채로 제출하세요. 페이지가 일반 POST로 돌아가 확인 페이지를 보여 줍니다.
  5. 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 Spam으로 바꿔 보세요. 스팸은 실제 제출과 같은 성공 응답을 받습니다.

주의할 점

  • 403 origin_not_allowed, 또는 모든 POST가 스팸으로 들어감. 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다.
  • 축소된 HTML. 일부 빌드 단계는 비어 있거나 쓰이지 않는다고 판단한 속성을 지웁니다. 빌드된 페이지의 허니팟에 aria-hidden과 tabindex가 남아 있는지 확인하세요.
  • 캐시된 감사 페이지. 스팸과 실제 POST의 감사 페이지 URL이 같다면(그래야 합니다), POST가 진짜라는 전제에 의존하는 내용은 절대 넣지 마세요.
  • 저장소에 든 키. 공개 정적 사이트 저장소에 커밋된 시크릿은 공개된 것입니다. 한 번이라도 그랬다면 키를 교체하세요.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.