# 폼 제출에 레이트 리밋을 거는 방법

> 공유 저장소로 방문자별 폼 제출에 레이트 리밋을 걸고, Retry-After와 함께 429로 응답하며, 하나의 IP를 쓰는 사무실이 막히지 않게 하세요.

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

Redis처럼 모든 서버 인스턴스가 공유하는 저장소에서 방문자 IP별 POST 수를 세고, 짧은 버스트를 허용하며, 횟수가 다 떨어지면 `Retry-After` 헤더와 함께 `429`로 응답하세요. 메모리 내 카운터는 오래 실행되는 서버 하나에서만 작동하며, 서버리스 호스트에서는 절대 작동하지 않습니다. 제한은 실제 사람들이 폼을 쓰는 방식에 맞춰 정하고, 많은 사람이 하나의 IP를 공유하는 사무실과 행사장을 위한 여유를 두세요.

## 레이트 리밋이 다른 점

레이트 리밋은 스팸과 정상 메일을 구분하지 못합니다. 막는 것은 양입니다. 반복해서 POST하는 스크립트, 폼을 계속 돌리는 봇, 버튼을 연타하는 사람입니다. 그래서 본문을 읽기 전에 실행되므로 가장 저렴한 계층이면서, 공격자와 바쁜 사무실을 구분하지 못하므로 가장 무딘 계층입니다. 도움이 될지 해가 될지는 세 가지로 결정됩니다. 횟수를 어디에 저장하는지, 무엇을 기준으로 세는지, 허용량이 얼마나 빨리 회복되는지입니다.

다이어그램: 매끄러운 방문자별 제한에 한 방문자의 POST가 몰릴 때

1. 방문자가 10초 안에 POST 3건을 보냅니다. 모두 수락됩니다.
2. 다음 POST는 얼마나 기다려야 하는지 알려 주는 Retry-After 헤더와 함께 429로 거부됩니다.
3. 그 후에는 12초마다 POST가 하나씩 더 허용됩니다.
4. 1분 동안 조용하면 허용량이 모두 돌아옵니다.

Ikiform의 Standard 수치입니다. 분당 5건, 10초에 최대 3건입니다. 매끄럽게 회복되므로 POST 하나를 더 보내려고 구간 전체를 기다릴 필요가 없습니다.

## 횟수를 저장하는 곳

| 저장소 | 작동 환경 | 주의할 점 |
| --- | --- | --- |
| 메모리 내 `Map` | 오래 실행되는 서버 하나 | 재시작하면 사라짐. 서버리스 인스턴스마다 맵이 따로 있어 실제 제한을 알 수 없음 |
| Upstash 같은 Redis | 서버리스와 엣지를 포함해 어디서든 | POST마다 네트워크 호출 한 번. 서버와 가까운 리전을 고를 것 |
| Cloudflare Workers 레이트 리밋 바인딩 | Workers | 근사치이며 Cloudflare 위치별로 집계. 기간은 10초 또는 60초만 가능 |
| nginx `limit_req` 같은 리버스 프록시 | 직접 운영하는 프록시 뒤의 서버 | 프록시별로 집계. 실제 클라이언트 IP가 필요 |

알고리즘은 저장소보다 덜 중요하지만, 방문자가 느끼는 경험을 좌우합니다. 고정 구간은 매 분마다 초기화되므로, 스크립트가 한 분의 끝에 허용량 전부를 보내고 다음 분의 시작에 또 보낼 수 있습니다. 슬라이딩 구간은 이전 구간에 가중치를 주어 그 경계를 부드럽게 만듭니다. 토큰 버킷이나 Ikiform이 쓰는 비슷한 알고리즘은 POST를 하나씩 회복하므로, 오타를 낸 사람은 몇 초 뒤에 다시 보낼 수 있고 버스트의 문은 열리지 않습니다. 문의 폼이라면 수치가 사람들의 사용 방식에 맞기만 하면 어떤 것이든 괜찮습니다.

아래 예제는 Upstash의 `@upstash/ratelimit`을 사용합니다. 모든 프레임워크와 서버리스 호스트에서 똑같이 작동하기 때문입니다. `UPSTASH_REDIS_REST_URL`과 `UPSTASH_REDIS_REST_TOKEN`을 설정하고, 헬퍼를 한 번 저장하세요.

`lib/limit.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const contact = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function allow(ip: string | null | undefined) {
  const { success, reset } = await contact.limit(ip || "no-ip");
  return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}
```

`reset`은 밀리초 단위의 Unix 시각이므로, 헬퍼가 `Retry-After`용으로 정수 초로 바꿉니다. IP가 없는 POST는 하나의 버킷을 공유하므로, 헤더가 없다고 해서 무사통과가 되지 않습니다.

## 프레임워크에 적용하기

**HTML**

일반 HTML 폼에는 무언가를 셀 서버가 없습니다. Ikiform으로 POST하고 폼에서 프리셋을 고르세요. 제한을 넘은 방문자에게는 오류 대신 짧은 속도 줄이기 안내 페이지가 표시됩니다.

`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" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
```

**Next.js**

다른 작업을 하기 전에 서버 액션 맨 위에서 제한을 확인하세요.

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/limit";

export async function sendMessage(_previous: { message: string }, formData: FormData) {
  const forwarded = (await headers()).get("x-forwarded-for");
  const limit = await allow(forwarded?.split(",")[0]?.trim());
  if (!limit.ok) return { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
  await saveMessage(Object.fromEntries(formData));
  return { message: "Thanks. We'll reply by email." };
}
```

**Astro**

온디맨드 페이지에서는 `context.clientAddress`로 방문자의 IP를 얻습니다. `TOO_MANY_REQUESTS`는 `429`에 대응합니다.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { allow } from "../lib/limit";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({ email: z.email(), message: z.string().min(1) }),
    handler: async (input, context) => {
      const limit = await allow(context.clientAddress);
      if (!limit.ok) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
      }
      await saveMessage(input);
      return { sent: true };
    },
  }),
};
```

**TanStack Start**

`fetch` 호출자가 읽을 수 있도록 서버 함수에서 상태와 `Retry-After`를 설정하세요.

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

```ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader, setResponseHeaders, setResponseStatus } from "@tanstack/react-start/server";
import { allow } from "../lib/limit";

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") ?? "") };
  })
  .handler(async ({ data }) => {
    const limit = await allow(getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim());
    if (!limit.ok) {
      setResponseStatus(429);
      setResponseHeaders(new Headers({ "Retry-After": String(limit.retryAfter) }));
      return { ok: false, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
    }
    await saveMessage(data);
    return { ok: true, message: "Thanks. We'll reply by email." };
  });
```

**React Router**

`data()`를 쓰면 액션이 폼에 표시할 메시지와 함께 헤더가 붙은 `429`를 반환할 수 있습니다.

`app/routes/contact.tsx`:

```ts
import { data } from "react-router";
import { allow } from "../lib/limit";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const limit = await allow(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim());
  if (!limit.ok) {
    return data(
      { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` },
      { status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
    );
  }
  await saveMessage(Object.fromEntries(await request.formData()));
  return { message: "Thanks. We'll reply by email." };
}
```

**SvelteKit**

`getClientAddress()`는 어댑터에서 IP를 읽습니다. `fail(429)`는 메시지와 함께 폼을 페이지에 그대로 둡니다. 헬퍼는 `src/lib/limit.ts`로 저장하세요. SvelteKit 3은 `package.json`의 `#lib` 별칭으로 가져옵니다.

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

```ts
import { fail } from "@sveltejs/kit";
import { allow } from "#lib/limit.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress }) => {
    const limit = await allow(getClientAddress());
    if (!limit.ok) return fail(429, { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
    await saveMessage(Object.fromEntries(await request.formData()));
    return { sent: true };
  },
} satisfies Actions;
```

**Nuxt**

`xForwardedFor`를 지정한 `getRequestIP`는 프록시 뒤에서 클라이언트 IP를 읽습니다. 헬퍼를 `server/utils`에 저장하면 자동으로 가져옵니다.

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

```ts
export default defineEventHandler(async (event) => {
  const limit = await allow(getRequestIP(event, { xForwardedFor: true }));
  if (!limit.ok) {
    setResponseHeader(event, "Retry-After", limit.retryAfter);
    throw createError({ status: 429, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
  }
  await saveMessage(await readBody(event));
  return { sent: true };
});
```

**React**

싱글 페이지 앱은 제한을 강제할 수 없지만, 제한을 존중해야 합니다. 사람들이 버튼을 다시 누르게 두지 말고 `Retry-After`를 읽어 카운트다운하세요. `429 quota_exceeded`에는 `Retry-After`가 없으므로 오류 메시지로 넘어갑니다.

`contact.js`:

```js
const button = form.querySelector("button");
const status = form.querySelector("[role=status]");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  });
  const retryAfter = response.headers.get("Retry-After");
  if (response.status === 429 && retryAfter) {
    let wait = Number(retryAfter);
    button.disabled = true;
    const timer = setInterval(() => {
      status.textContent = `Too many messages. Try again in ${wait} seconds.`;
      wait -= 1;
      if (wait < 0) {
        clearInterval(timer);
        button.disabled = false;
        status.textContent = "";
      }
    }, 1000);
    return;
  }
  const result = await response.json();
  status.textContent = result.ok ? "Thanks. We'll reply by email." : result.error.message;
});
```

**Vue**

같은 처리를 Vue 컴포넌트로 구현한 것입니다. `Retry-After`를 읽고, 버튼을 비활성화하고, ref로 카운트다운하세요.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const wait = ref(0);
const notice = ref("");
let timer: ReturnType<typeof setInterval> | undefined;

function countDown(seconds: number) {
  wait.value = seconds;
  clearInterval(timer);
  timer = setInterval(() => {
    wait.value -= 1;
    if (wait.value <= 0) clearInterval(timer);
  }, 1000);
}

async function send(event: SubmitEvent) {
  notice.value = "";
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(event.target as HTMLFormElement),
  });
  const retryAfter = Number(response.headers.get("Retry-After"));
  if (response.status === 429 && retryAfter > 0) {
    countDown(retryAfter);
    return;
  }
  const result = await response.json();
  notice.value = result.ok ? "Thanks. We'll reply by email." : result.error.message;
}

onBeforeUnmount(() => clearInterval(timer));
</script>

<template>
  <form :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" required />
    <p role="status">{{ wait > 0 ? `Too many messages. Try again in ${wait} seconds.` : notice }}</p>
    <button :disabled="wait > 0">Send</button>
  </form>
</template>
```

Cloudflare Workers에서는 내장 바인딩이 가장 저렴한 선택지입니다.

`wrangler.toml`:

```toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
```

`src/index.ts`:

```ts
const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });
```

Cloudflare는 이 바인딩이 근사치이며 위치마다 따로 집계된다고 설명하므로, 분산된 공격은 이를 넘을 수 있습니다. 위치를 가리지 않는 정확한 제한이 필요하다면 Cloudflare는 Durable Object를 권장합니다. 직접 운영하는 nginx 뒤라면 앱이 POST를 보기 전에 `limit_req`가 횟수를 셉니다.

`nginx.conf`:

```text
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;

server {
  location = /api/contact {
    limit_req zone=contact burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:3000;
  }
}
```

## 무엇을 기준으로 셀까

- **IP와 폼을 함께.** 일반적인 선택입니다. 한 방문자가 이 폼을 폭주시킬 수 없고, 사이트의 다른 폼을 써도 같은 허용량이 줄지 않습니다.
- **많은 사람, 하나의 IP.** 사무실, 학교, 이동통신사, 컨퍼런스 Wi-Fi는 많은 사람을 하나의 주소 뒤에 둡니다. IP별 제한을 그들이 쓸 수 있을 만큼 느슨하게 두거나, 행사 당일에는 올리세요.
- **실제 클라이언트 IP.** 프록시나 CDN 뒤에서는 호스트가 설정하는 헤더를 읽고 그것만 신뢰하세요. 원시 `X-Forwarded-For` 값은 클라이언트가 설정할 수 있습니다.
- **여러분 서버의 IP.** 서버가 POST를 다른 서비스로 전달하면, 그 서비스에서는 모든 방문자가 서버의 IP를 공유합니다. 전달하기 전에 여러분 쪽에서 방문자의 IP 기준으로 제한하세요.

## Ikiform의 레이트 리밋

Ikiform은 본문을 읽기 전에 방문자 IP와 폼별로 모든 POST를 정확히 세며, 고정 구간이 아니라 매끄러운 회복 방식을 씁니다. 폼마다 **Rate limit**에서 프리셋을 고릅니다. 모든 폼에 걸쳐 IP당 분당 600건의 폭주 방어가 그 무엇보다 먼저 실행됩니다.

| 프리셋 | 방문자당 | 어울리는 곳 |
| --- | --- | --- |
| Standard | 분당 5건, 10초에 최대 3건 | 대부분의 폼 |
| Relaxed | 분당 20건 | 공유 Wi-Fi를 쓰는 행사, 로그인 뒤의 폼 |
| Strict | 10분에 2건, 이후 5분마다 1건 추가 | 문의 폼과 영업 폼 |
| Custom | 10초, 1분, 10분, 1시간, 1일당 1~1,000건 | 1인 1회 응모, 키오스크 |

Custom 상한을 정하지 않으면 폼 전체로는 모든 방문자를 합쳐 분당 300건까지 받습니다. 제한을 넘으면 브라우저 POST에는 속도 줄이기 안내 페이지가 표시되고, `fetch`는 `Retry-After`와 함께 `429 rate_limited`를 받습니다. Ikiform은 이 헤더를 CORS로 노출하므로 위 코드가 읽을 수 있습니다. 회복 방식은 [레이트 리밋 문서](https://ikiform.com/ko/docs/rate-limits.md#refill)에서 설명합니다.

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

## 작동 확인하기

1. 폼을 연속으로 여러 번 제출하세요. 제한을 넘으면 `Retry-After` 헤더와 함께 `429`를 받거나 Ikiform의 속도 줄이기 안내 페이지가 표시됩니다.
2. `Retry-After`에 적힌 초만큼 기다린 뒤 다시 제출하세요. 수락됩니다.
3. Wi-Fi를 끈 휴대폰처럼 다른 네트워크에서 제출하세요. 별도의 허용량이 있습니다.
4. 서버가 POST를 전달한다면 두 기기에서 빠르게 제출하고, 서로의 횟수를 공유하지 않는지 확인하세요.

## 주의할 점

- **거부된 POST가 허용량을 쓰면 안 됩니다.** 작업 전에 세고, 거부된 POST가 구간을 더 뒤로 밀지 않게 하세요. 그렇지 않으면 조급한 방문자가 스스로를 잠가 버립니다.
- **페이지 로드는 제한하지 마세요.** 폼 조회가 아니라 제출을 세세요. 그렇지 않으면 페이지를 새로 고치기만 해도 보낼 수 있는 횟수가 줄어듭니다.
- **`429 rate_limited`** 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5건을 허용합니다. `Retry-After`만큼 기다리거나, 하나의 공유 IP에서 POST하는 폼에는 Relaxed 또는 Custom을 고르세요.
- **IPv6.** 한 사람이 IPv6 주소를 많이 가질 수 있습니다. 엄격하게 제한하려면 전체 주소가 아니라 `/64` 접두사 기준으로 세세요.

## 질문

### 분당 폼 제출을 몇 건까지 허용해야 하나요?

문의 폼이라면 방문자당 몇 건이면 충분합니다. Ikiform의 Strict 프리셋은 10분에 2건을 허용합니다. 공유 네트워크에서 쓰는 가입 폼과 행사 폼은 분당 20건 정도로 더 필요합니다.

### Vercel이나 Netlify에서 메모리로 레이트 리밋을 걸 수 있나요?

안정적으로는 불가능합니다. 서버리스 인스턴스마다 메모리가 따로 있고 인스턴스가 생겼다 사라지므로, 횟수가 무작위로 초기화됩니다. Redis 같은 공유 저장소를 쓰세요.

### 레이트 리밋에 걸린 폼은 무엇을 보여 줘야 하나요?

Retry-After 헤더에서 가져온 대기 시간을 알려 주는 평범한 메시지와, 그때까지 비활성화된 제출 버튼입니다. 사람이 입력한 내용은 지우지 마세요.

### Ikiform은 기본적으로 폼에 레이트 리밋을 거나요?

네. 모든 폼은 방문자당 분당 5건인 Standard 프리셋으로 시작하며, 폼마다 Relaxed, Strict, Custom으로 바꿀 수 있습니다.

## 가이드 더 보기

- [문의 폼 스팸 막기](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/honeypot.md): 스크린 리더, 키보드, 자동 완성에 걸리지 않으면서 봇을 잡는 허니팟을 어떤 폼에든 추가하고, 스택별 서버 검사까지 구현하세요.
- [정적 사이트](https://ikiform.com/ko/guides/spam-protection/static-site.md): 시크릿을 보관하거나 POST 수를 셀 수 없는 정적 사이트 폼에서도 허니팟 파셜, 안전한 스크립트, 서버 측 검사로 스팸을 막으세요.
- [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 대안을 비교하고, 작고 안전한 변경으로 전환하세요.
