문의 폼 스팸 막기4분 분량
폼 제출에 레이트 리밋을 거는 방법
공유 저장소로 방문자별 폼 제출에 레이트 리밋을 걸고, Retry-After와 함께 429로 응답하며, 하나의 IP를 쓰는 사무실이 막히지 않게 하세요.
Redis처럼 모든 서버 인스턴스가 공유하는 저장소에서 방문자 IP별 POST 수를 세고, 짧은 버스트를 허용하며, 횟수가 다 떨어지면 Retry-After 헤더와 함께 429로 응답하세요. 메모리 내 카운터는 오래 실행되는 서버 하나에서만 작동하며, 서버리스 호스트에서는 절대 작동하지 않습니다. 제한은 실제 사람들이 폼을 쓰는 방식에 맞춰 정하고, 많은 사람이 하나의 IP를 공유하는 사무실과 행사장을 위한 여유를 두세요.
레이트 리밋이 다른 점
레이트 리밋은 스팸과 정상 메일을 구분하지 못합니다. 막는 것은 양입니다. 반복해서 POST하는 스크립트, 폼을 계속 돌리는 봇, 버튼을 연타하는 사람입니다. 그래서 본문을 읽기 전에 실행되므로 가장 저렴한 계층이면서, 공격자와 바쁜 사무실을 구분하지 못하므로 가장 무딘 계층입니다. 도움이 될지 해가 될지는 세 가지로 결정됩니다. 횟수를 어디에 저장하는지, 무엇을 기준으로 세는지, 허용량이 얼마나 빨리 회복되는지입니다.
- 방문자가 10초 안에 POST 3건을 보냅니다. 모두 수락됩니다.
- 다음 POST는 얼마나 기다려야 하는지 알려 주는 Retry-After 헤더와 함께 429로 거부됩니다.
- 그 후에는 12초마다 POST가 하나씩 더 허용됩니다.
- 1분 동안 조용하면 허용량이 모두 돌아옵니다.
횟수를 저장하는 곳
| 저장소 | 작동 환경 | 주의할 점 |
|---|---|---|
메모리 내 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을 설정하고, 헬퍼를 한 번 저장하세요.
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 폼에는 무언가를 셀 서버가 없습니다. Ikiform으로 POST하고 폼에서 프리셋을 고르세요. 제한을 넘은 방문자에게는 오류 대신 짧은 속도 줄이기 안내 페이지가 표시됩니다.
<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>다른 작업을 하기 전에 서버 액션 맨 위에서 제한을 확인하세요.
"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." };
}온디맨드 페이지에서는 context.clientAddress로 방문자의 IP를 얻습니다. TOO_MANY_REQUESTS는 429에 대응합니다.
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 };
},
}),
};fetch 호출자가 읽을 수 있도록 서버 함수에서 상태와 Retry-After를 설정하세요.
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." };
});data()를 쓰면 액션이 폼에 표시할 메시지와 함께 헤더가 붙은 429를 반환할 수 있습니다.
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." };
}getClientAddress()는 어댑터에서 IP를 읽습니다. fail(429)는 메시지와 함께 폼을 페이지에 그대로 둡니다. 헬퍼는 src/lib/limit.ts로 저장하세요. SvelteKit 3은 package.json의 #lib 별칭으로 가져옵니다.
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;xForwardedFor를 지정한 getRequestIP는 프록시 뒤에서 클라이언트 IP를 읽습니다. 헬퍼를 server/utils에 저장하면 자동으로 가져옵니다.
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 };
});싱글 페이지 앱은 제한을 강제할 수 없지만, 제한을 존중해야 합니다. 사람들이 버튼을 다시 누르게 두지 말고 Retry-After를 읽어 카운트다운하세요. 429 quota_exceeded에는 Retry-After가 없으므로 오류 메시지로 넘어갑니다.
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 컴포넌트로 구현한 것입니다. Retry-After를 읽고, 버튼을 비활성화하고, ref로 카운트다운하세요.
<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에서는 내장 바인딩이 가장 저렴한 선택지입니다.
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"
[ratelimits.simple]
limit = 5
period = 60const 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가 횟수를 셉니다.
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로 노출하므로 위 코드가 읽을 수 있습니다. 회복 방식은 레이트 리밋 문서에서 설명합니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.작동 확인하기
- 폼을 연속으로 여러 번 제출하세요. 제한을 넘으면
Retry-After헤더와 함께429를 받거나 Ikiform의 속도 줄이기 안내 페이지가 표시됩니다. Retry-After에 적힌 초만큼 기다린 뒤 다시 제출하세요. 수락됩니다.- Wi-Fi를 끈 휴대폰처럼 다른 네트워크에서 제출하세요. 별도의 허용량이 있습니다.
- 서버가 POST를 전달한다면 두 기기에서 빠르게 제출하고, 서로의 횟수를 공유하지 않는지 확인하세요.
주의할 점
- 거부된 POST가 허용량을 쓰면 안 됩니다. 작업 전에 세고, 거부된 POST가 구간을 더 뒤로 밀지 않게 하세요. 그렇지 않으면 조급한 방문자가 스스로를 잠가 버립니다.
- 페이지 로드는 제한하지 마세요. 폼 조회가 아니라 제출을 세세요. 그렇지 않으면 페이지를 새로 고치기만 해도 보낼 수 있는 횟수가 줄어듭니다.
429 rate_limited한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5건을 허용합니다.Retry-After만큼 기다리거나, 하나의 공유 IP에서 POST하는 폼에는 Relaxed 또는 Custom을 고르세요.- IPv6. 한 사람이 IPv6 주소를 많이 가질 수 있습니다. 엄격하게 제한하려면 전체 주소가 아니라
/64접두사 기준으로 세세요.
질문
문의 폼이라면 방문자당 몇 건이면 충분합니다. Ikiform의 Strict 프리셋은 10분에 2건을 허용합니다. 공유 네트워크에서 쓰는 가입 폼과 행사 폼은 분당 20건 정도로 더 필요합니다.
안정적으로는 불가능합니다. 서버리스 인스턴스마다 메모리가 따로 있고 인스턴스가 생겼다 사라지므로, 횟수가 무작위로 초기화됩니다. Redis 같은 공유 저장소를 쓰세요.
Retry-After 헤더에서 가져온 대기 시간을 알려 주는 평범한 메시지와, 그때까지 비활성화된 제출 버튼입니다. 사람이 입력한 내용은 지우지 마세요.
네. 모든 폼은 방문자당 분당 5건인 Standard 프리셋으로 시작하며, 폼마다 Relaxed, Strict, Custom으로 바꿀 수 있습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.