# 봇은 채우고 사람은 채우지 않는 허니팟 필드를 추가하는 방법

> 스크린 리더, 키보드, 자동 완성에 걸리지 않으면서 봇을 잡는 허니팟을 어떤 폼에든 추가하고, 스택별 서버 검사까지 구현하세요.

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

텍스트 입력을 하나 더 추가해 화면 밖에 숨기고, 탭 순서에서 빼고, `aria-hidden`으로 감싼 뒤 어떤 브라우저도 자동 완성하지 않는 이름을 붙이세요. 사람은 절대 채우지 않지만, 모든 입력을 채우는 봇은 채웁니다. 서버에서는 이 필드에 값이 있는 POST를 스팸으로 처리하고, 실제 메시지와 똑같이 응답하세요.

## 허니팟이 다른 점

허니팟은 두 가지 조건을 동시에 만족해야 합니다. HTML을 읽는 봇에게는 채울 만한 평범한 필드로 보여야 합니다. 모든 사람에게는 존재하지 않아야 하며, 여러분과 다른 방식으로 폼을 쓰는 사람도 포함됩니다. 스크린 리더를 쓰는 사람, 키보드로 탭 이동하는 사람, 비밀번호 관리자가 폼을 대신 채워 주는 사람입니다. 허니팟 버그는 대부분 이 중 누군가를 잊어서 생기며, 증상은 실제 메시지가 조용히 스팸으로 분류되는 것입니다.

다이어그램: 사람과 봇이 같은 허니팟 필드를 만나는 방식

1. 사람은 화면, 키보드, 스크린 리더 등 자신이 쓰는 도구로 폼을 채웁니다.
2. 함정은 화면 밖에 있고, 탭 순서에서 빠져 있으며, 보조 기술에서 숨겨져 있으므로 사람은 절대 닿지 않습니다.
3. 이름이 브라우저와 비밀번호 관리자가 자동 완성하지 않는 것이라 비어 있는 채로 남습니다.
4. 함정이 빈 채로 POST가 도착하고 받음 처리됩니다.
5. 스크립트 봇은 HTML을 파싱해 함정을 포함한 모든 입력을 찾습니다.
6. 모두 채워서 바로 POST합니다.
7. 함정에 값이 있으므로 POST는 스팸입니다. 봇은 그래도 평범한 성공 응답을 받습니다.

윗줄의 각 안전장치는 실제 사람이 필드에 닿을 수 있는 경로를 하나씩 막습니다. 하나라도 빠지면 윗줄도 스팸으로 끝날 수 있습니다.

## 마크업

함정은 폼 안에 실제 필드와 나란히 일반 텍스트 입력으로 두세요. `type="hidden"` 입력은 효과가 없습니다. 봇은 사람이 그런 입력을 편집할 수 없다는 것을 알고 건드리지 않습니다.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <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" 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>

  <button>Send</button>
</form>
```

`스타일`:

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

- 래퍼의 `aria-hidden="true"`는 레이블과 입력을 접근성 트리에서 제거하므로 스크린 리더가 읽지 않습니다.
- `tabindex="-1"`은 Tab 키가 아무도 볼 수 없는 입력에 멈추지 않게 합니다.
- 레이블에는 **Leave this empty**라고 적습니다. 어떤 도구가 여전히 필드를 노출하더라도 읽는 사람은 어떻게 해야 하는지 알 수 있습니다.
- `required`는 추가하지 마세요. 사람이 볼 수 없는 필수 필드는 모든 정상 제출을 막습니다.

## 숨기는 방법

봇은 대놓고 숨긴 필드를 건너뛰는 법을 배웠습니다. 단순한 스크레이퍼는 여전히 모두 채우지만, 일부는 입력하기 전에 `hidden` 속성이나 인라인 `display: none`을 확인합니다. 숨기는 장치가 입력 자체에서 멀수록 더 많은 봇이 속습니다.

| 기법 | 사람에게 숨겨짐 | 여전히 잡히는 봇 |
| --- | --- | --- |
| `hidden` 속성 또는 인라인 `display: none` | 예 | 가장 단순한 봇만. 똑똑한 봇이 가장 먼저 확인하는 부분 |
| 스타일시트의 화면 밖 클래스 | 예 | 대부분. 봇이 CSS를 불러와 적용해야만 알아챔 |
| `opacity: 0` 또는 크기 0 | 대체로. 일부 돋보기 도구와 포커스 윤곽선에는 여전히 드러남 | 대부분. 하지만 사람에게는 더 위험 |
| 로드 후 JavaScript로 추가 | 예 | 스크립트를 실행하는 봇. 일반 HTML 스크레이퍼는 보지 못하므로 놓침 |

스타일시트의 화면 밖 클래스를 쓰세요. 가장 많은 봇을 잡고 사람에게도 가장 안전합니다. 허니팟 하나가 효과를 잃으면 더 교묘한 숨김 기법 대신 이름이 다른 허니팟을 하나 더 추가하세요.

## 자동 완성이 무시하는 이름 고르기

브라우저와 비밀번호 관리자는 필드의 `name`, `id`, 레이블, `autocomplete` 값을 보고 추측해서 채웁니다. `autocomplete="off"`는 힌트일 뿐이며, 일부 브라우저는 주소나 연락처처럼 보이는 필드에서 이를 무시합니다. 그래서 실제로 일을 하는 것은 이름입니다.

| 피할 것 | 이유 | 대신 쓸 것 |
| --- | --- | --- |
| `email2`, `phone`, `name`, `address` | 자동 완성이 알아보고 실제 사람의 정보로 채움 | `_gotcha`, `company_website` |
| `honeypot`, `trap`, `bot` | 일부 봇은 이름이 뻔한 필드를 건너뜀 | 평범하고 그럴듯한 이름 |
| `url`, `website` 단독 | 일부 자동 완성 도구는 저장된 프로필로 웹사이트 필드를 채움 | `company_website` 같은 복합 이름 |

## 서버에서 확인하기

허니팟은 서버가 읽어야만 작동합니다. 봇은 여러분의 스크립트를 실행하지 않으므로 브라우저 측 검사는 쓸모가 없습니다. 필드에 값이 있으면 실제 메시지와 똑같이 응답하되, POST를 저장하거나 전달하지 마세요. 응답이 다르면 어떤 필드 때문에 들켰는지 봇에게 알려 주게 됩니다. 아래 예제는 여러분의 `saveMessage`를 호출합니다. 메시지를 저장하거나 이메일로 보내는 코드로 바꾸세요.

**HTML**

일반 HTML 폼에는 자체 서버가 없습니다. 폼을 Ikiform으로 연결하고 함정 이름을 `_gotcha`로 짓거나, **Honeypot field**에서 폼마다 이름을 바꾸세요. Ikiform이 서버 검사를 하며, 함정은 응답으로 저장되지 않습니다.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <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" 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>

  <button>Send</button>
</form>
```

**Next.js**

서버 액션에서는 함정에 걸린 POST도 실제 POST와 같은 감사 페이지로 리디렉션하고 저장은 건너뛰세요.

`app/contact/actions.ts`:

```ts
"use server";

import { redirect } from "next/navigation";

export async function sendMessage(formData: FormData) {
  if (String(formData.get("company_website") ?? "").trim() !== "") {
    console.info("honeypot", { at: new Date().toISOString() });
    redirect("/contact/thanks");
  }
  await saveMessage({
    email: String(formData.get("email") ?? ""),
    message: String(formData.get("message") ?? ""),
  });
  redirect("/contact/thanks");
}
```

**Astro**

Astro Action에서는 입력에서 함정을 꺼내고, 값이 있으면 평소와 같은 결과를 반환하세요.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { z } from "astro/zod";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      email: z.email(),
      message: z.string().min(1),
      company_website: z.string().nullish(),
    }),
    handler: async ({ company_website, ...fields }) => {
      if (company_website?.trim()) return { sent: true };
      await saveMessage(fields);
      return { sent: true };
    },
  }),
};
```

**TanStack Start**

`.validator()`에서 함정을 읽고 핸들러에서 저장을 건너뛰세요. 두 경로 모두 같은 응답을 반환합니다.

`src/server/send-message.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";

export const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return {
      email: String(data.get("email") ?? ""),
      message: String(data.get("message") ?? ""),
      trapped: String(data.get("company_website") ?? "").trim() !== "",
    };
  })
  .handler(async ({ data: { trapped, ...fields } }) => {
    if (!trapped) await saveMessage(fields);
    return { ok: true };
  });
```

**React Router**

라우트 액션은 어느 경우든 감사 페이지로 리디렉션하고, 함정에 걸리지 않은 POST만 저장합니다.

`app/routes/contact.tsx`:

```ts
import { redirect } from "react-router";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  if (String(form.get("company_website") ?? "").trim()) return redirect("/contact/thanks");
  await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
  return redirect("/contact/thanks");
}
```

**SvelteKit**

폼 액션은 함정이 비어 있을 때만 저장한 뒤, 두 경우 모두 `303`으로 리디렉션합니다.

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

```ts
import { redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request }) => {
    const form = await request.formData();
    if (!String(form.get("company_website") ?? "").trim()) {
      await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
    }
    redirect(303, "/contact/thanks");
  },
} satisfies Actions;
```

**Nuxt**

서버 라우트는 함정에 걸린 POST와 실제 POST 모두에 같은 본문과 함께 `201`로 응답합니다.

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

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<{ email?: string; message?: string; company_website?: string }>(event);
  if (body.company_website?.trim()) {
    setResponseStatus(event, 201);
    return { sent: true };
  }
  await saveMessage({ email: body.email ?? "", message: body.message ?? "" });
  setResponseStatus(event, 201);
  return { sent: true };
});
```

**React**

싱글 페이지 앱은 함정을 직접 확인할 수 없습니다. 다른 필드와 함께 보내고, POST를 받는 서버가 판단하게 하세요. `FormData`는 추가 코드 없이 함정을 포함합니다.

`submit.ts`:

```ts
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
  method: "POST",
  headers: { Accept: "application/json" },
  body: new FormData(form),
});
```

**Vue**

Vue에서도 마찬가지입니다. `new FormData(form)`이 함정을 포함하도록 `<form>` 안에 두고, 서버가 확인하게 하세요.

`submit.ts`:

```ts
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
  method: "POST",
  headers: { Accept: "application/json" },
  body: new FormData(form),
});
```

> 함정에 걸린 POST는 저장하지 않더라도 로그를 남기세요. 하루 단위 횟수를 보면 허니팟이 여전히 제 몫을 하는지 알 수 있고, 갑자기 0으로 떨어지면 봇이 건너뛰는 법을 배웠다는 뜻입니다.

## Ikiform이 허니팟을 처리하는 방식

모든 Ikiform 폼에는 기본적으로 `_gotcha`라는 허니팟이 있습니다. 폼 설정의 **Honeypot field**에서 폼마다 바꿀 수 있으며, 어떤 이름을 고르든 예약된 이름이 되어 응답으로 저장되지 않습니다. 허니팟이 채워지면 스팸 점수에 5가 더해지며, 이것만으로 기준값 5에 도달합니다.

Ikiform은 POST를 버리지 않습니다. 스팸으로 보관하므로, 실제 사람이 함정에 걸리더라도 메시지를 찾아 복원할 수 있습니다. 스팸으로 판정된 POST는 실제 제출과 같은 성공 응답을 받으므로 봇은 아무것도 알 수 없습니다. `spam` 상태로 저장되고, 알림 이메일과 웹훅을 보내지 않으며, 사용량에 포함되지 않고, 30일 뒤 삭제됩니다. 스팸을 차단한 워크스페이스에는 매주 월요일 합계, 가장 많이 받은 폼, 주요 사유를 담은 요약 이메일이 한 통 갑니다. [스팸 문서](https://ikiform.com/ko/docs/spam.md#honeypot)와 [HTML 폼](https://ikiform.com/ko/docs/html.md#honeypot)에 레퍼런스가 있습니다.

## 작동 확인하기

1. 스크린 리더를 켜고 폼을 불러와 이동해 보세요. 함정은 한 번도 읽히지 않습니다.
2. Tab 키로 폼을 이동해 보세요. 포커스가 마지막 실제 필드에서 바로 버튼으로 갑니다.
3. 브라우저와 비밀번호 관리자가 폼을 자동 완성하게 해 보세요. 함정은 비어 있습니다.
4. 허니팟을 채워 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
5. 평범한 메시지를 보내세요. 함정 필드의 흔적 없이 받음 상태로 도착합니다.

## 주의할 점

- **JSON POST.** `FormData` 대신 본문을 직접 만든다면 함정을 포함하세요. 그렇지 않으면 서버는 봇의 POST에서도 함정을 빈 값으로 읽습니다.
- **필드 이름을 바꾸는 폼 빌더.** 일부 빌더는 입력 이름에 접두사를 붙이거나 이름을 바꿉니다. 함정의 실제 이름은 편집기가 아니라 POST된 본문에서 확인하세요.
- **캐싱.** 이름을 바꾼 뒤에도 캐시된 페이지에 옛 함정 이름이 남아 있으면, POST가 옛 이름으로 도착해 통과합니다. 양쪽 이름을 동시에 바꾸세요.
- **체크박스 허니팟.** 숨겨진 체크박스도 작동하지만, 스크린 리더는 엉뚱한 체크박스를 텍스트 입력보다 더 서툴게 처리합니다. 텍스트 입력을 쓰세요.
- **하나의 계층일 뿐.** 실제 브라우저를 실행하고 보이지 않는 필드를 건너뛰는 봇은 어떤 허니팟이든 그냥 지나칩니다. [레이트 리밋](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md)과 함께 쓰고, 필요하면 [사람 확인](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)도 더하세요.

## 질문

### 허니팟 필드가 아직도 스팸 봇에 효과가 있나요?

네, 가장 흔한 봇, 즉 찾은 입력을 모두 채우는 스크립트에는 효과가 있습니다. 완전한 브라우저를 실행하고 보이지 않는 필드를 건너뛰는 봇은 통과하므로, 허니팟은 유일한 계층이 아니라 첫 번째 계층으로 쓰세요.

### 허니팟 필드가 접근성에 나쁜가요?

대충 숨긴 경우에만 그렇습니다. aria-hidden이 있는 요소로 감싸고, 입력에 tabindex -1을 주고, Leave this empty라는 레이블을 붙이고, 절대 필수로 지정하지 마세요. 그러면 스크린 리더와 키보드가 건너뜁니다.

### 실제 메시지가 왜 허니팟에 걸렸나요?

보통 자동 완성 때문입니다. 함정의 이름이 email2나 phone처럼 실제 필드처럼 보여서 브라우저나 비밀번호 관리자가 채운 것입니다. 어떤 자동 완성도 알아보지 못하는 이름으로 바꾸세요.

### 보낸 사람에게 메시지가 거부되었다고 알려야 하나요?

아니요. 함정에 걸린 POST에도 실제 POST와 똑같이 응답하세요. 오류를 보내면 어떤 필드에 걸렸는지 봇에게 알려 주게 되고, 다음 버전의 봇은 그 필드를 건너뜁니다.

## 가이드 더 보기

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