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

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

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

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

## 정적 사이트에서 다른 점

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

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

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

다이어그램: 정적 사이트 폼을 연결하는 세 가지 방식과 각 방식에서 스팸 검사가 실행되는 곳

1. 자기 자신에게 POST하는 정적 페이지는 코드를 실행하지 않는 호스트에 닿으므로, POST는 405로 실패하거나 어디에도 가지 않습니다.
2. 호스트 함수를 쓰면 페이지가 시크릿을 보관하는 함수로 POST하고, 함수가 허니팟을 읽고 토큰을 검증한 뒤 메시지를 전달합니다.
3. 폼 백엔드를 쓰면 페이지가 백엔드로 바로 POST하고, 백엔드가 허니팟, 점수, 레이트 리밋, 사람 확인을 실행한 뒤 이메일을 보냅니다.

작동하는 두 방식 모두 POST가 브라우저를 떠난 뒤에 검사가 실행됩니다. 봇이 건너뛸 수 없는 곳은 거기뿐입니다.

## 선택지

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

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

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

**Hugo** (`layouts/partials/contact-form.html`)

```html
<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>
```

**Jekyll** (`_includes/contact-form.html`)

```html
<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/' | absolute_url }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

**Eleventy** (`_includes/contact-form.njk`)

```html
<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="{{ site.url }}/thanks/" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

`assets/contact.css`:

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

- 허니팟은 화면 밖에 있고, 탭 순서에서 빠져 있으며, 스크린 리더에서 숨겨져 있습니다. 각 속성은 [허니팟 가이드](https://ikiform.com/ko/guides/spam-protection/honeypot.md)에서 설명합니다.
- `_redirect`는 사람들을 감사 페이지로 보냅니다. Ikiform은 폼의 **Allowed domains** 중 하나에 있는 `https:` 주소일 때만 따릅니다.
- [Hugo](https://ikiform.com/ko/docs/guides/hugo.md), [Jekyll](https://ikiform.com/ko/docs/guides/jekyll.md), [Eleventy](https://ikiform.com/ko/docs/guides/eleventy.md) 가이드에서 설정, 쇼트코드, GitHub Pages를 다룹니다.

## 망가뜨리지 않고 개선하기

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

`contact-form.html`:

```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`:

```ts
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를 저장하지 않고 버리므로, 실수로 걸린 실제 메시지는 사라집니다. 최소한 로그는 남기세요.
- 함수는 호출 사이에 메모리가 없으므로 레이트 리밋이 없습니다. [레이트 리밋 가이드](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md)에서 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가 진짜라는 전제에 의존하는 내용은 절대 넣지 마세요.
- **저장소에 든 키.** 공개 정적 사이트 저장소에 커밋된 시크릿은 공개된 것입니다. 한 번이라도 그랬다면 키를 교체하세요.

## 질문

### 정적 사이트에 캡차를 추가할 수 있나요?

위젯은 표시할 수 있지만, 토큰은 서버에서 시크릿 키로 검증해야 합니다. 호스트의 함수, 여러분의 키를 검증하는 폼 백엔드, 또는 키가 필요 없는 Ikiform의 관리형 검사를 쓰세요.

### 정적 사이트에는 허니팟만으로 충분한가요?

가장 흔한 봇은 막지만, 서버에서 무언가가 허니팟을 읽을 때만 그렇습니다. 레이트 리밋과 콘텐츠 점수를 함께 쓰세요. 이것들도 서버나 폼 백엔드가 필요합니다.

### 정적 폼에 405 Method Not Allowed가 표시되는 이유는 무엇인가요?

폼이 파일만 제공하는 호스트의 페이지로 POST하고 있습니다. 폼의 action을 함수나 폼 백엔드 엔드포인트로 지정하세요.

### JavaScript가 꺼져 있어도 폼이 작동하나요?

작동해야 합니다. 먼저 일반 HTML 폼으로 POST하고, 폼을 개선하면서 문제가 생기면 일반 POST로 돌아가는 스크립트를 추가하세요.

## 가이드 더 보기

- [문의 폼 스팸 막기](https://ikiform.com/ko/guides/spam-protection.md): 허니팟, 시간 검사, 콘텐츠 점수, 레이트 리밋, 필요한 곳에만 사람 확인까지, 실제 방문자에게 부담 없는 여러 겹의 검사로 문의 폼 스팸을 막으세요.
- [Cloudflare Turnstile](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md): 일반 HTML 폼에 Cloudflare Turnstile을 추가하세요. 위젯, 서버 측 siteverify, 테스트 키, 그리고 키나 서버 없이 실행하는 방법까지 다룹니다.
- [허니팟 필드](https://ikiform.com/ko/guides/spam-protection/honeypot.md): 스크린 리더, 키보드, 자동 완성에 걸리지 않으면서 봇을 잡는 허니팟을 어떤 폼에든 추가하고, 스택별 서버 검사까지 구현하세요.
- [레이트 리밋](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md): 공유 저장소로 방문자별 폼 제출에 레이트 리밋을 걸고, Retry-After와 함께 429로 응답하며, 하나의 IP를 쓰는 사무실이 막히지 않게 하세요.
- [캡차 없이](https://ikiform.com/ko/guides/spam-protection/without-captcha.md): 허니팟, 서명된 렌더링 시각, 콘텐츠 점수, 레이트 리밋을 따로가 아니라 합쳐서 써서 캡차 없이 대부분의 폼 스팸을 막으세요.
- [reCAPTCHA 대안](https://ikiform.com/ko/guides/spam-protection/recaptcha-alternatives.md): Turnstile과 hCaptcha부터 작업 증명, 캡차 없는 방식까지 폼용 reCAPTCHA 대안을 비교하고, 작고 안전한 변경으로 전환하세요.
