폼(form, 입력 양식)을 만들어 세상에 공개하면 사람만 찾아오는 게 아니에요. 며칠 지나면 문의 게시판에 이상한 광고 글이 수백 개 쌓이고, 회원가입 창으로는 있지도 않은 계정이 저절로 만들어지죠. 저도 처음 올린 문의 폼에 하룻밤 사이 도박 사이트 링크가 300개 넘게 박힌 걸 보고 아침에 커피를 쏟을 뻔했어요. 범인은 사람이 아니라 봇(bot, 자동으로 도는 프로그램)이에요. 오늘은 이 봇을 걸러내는 실무 3종 세트, 허니팟, 캡차, 서버 제한을 하나씩 풀어볼게요.
12.1 봇은 왜 우리 폼을 노릴까요?
봇 입장에서 폼은 공짜 확성기예요. 로그인도 필요 없고, 그냥 입력칸에 광고 문구를 채워 보내기 버튼만 누르면 우리 사이트에 자기 링크가 남으니까요. 게시판이든 문의든 댓글이든 회원가입이든, 입력을 받는 곳이면 다 표적이 돼요.
사람은 폼 하나 채우는 데 몇십 초가 걸리죠. 봇은 초당 수십 번씩 제출을 때려요. 화면을 보는 게 아니라 HTML을 읽어서 input(입력 태그)을 찾아 자동으로 채우기 때문이에요. 그래서 방어의 핵심은 이 요청이 사람인지 기계인지를 티 안 나게 구분하는 거예요. 진짜 사용자에게는 불편을 주지 않으면서요.
12.2 허니팟이 대체 뭐예요?
허니팟(honeypot, 벌꿀 단지 함정)은 이름 그대로 봇을 꾀는 미끼예요. 폼에 사람 눈에는 안 보이는 가짜 입력칸을 하나 심어두는 거죠. 사람은 화면에 안 보이니 건드릴 일이 없어요. 그런데 봇은 HTML만 읽으니까 여기 입력칸이 있네 하고 친절하게 채워줘요. 그러면 우리는 서버에서 이 칸에 값이 들어왔네, 봇이구나 하고 조용히 버리면 돼요.
캡차처럼 사용자에게 뭘 시키지 않아도 되는 게 최대 장점이에요. 진짜 사용자는 방해를 전혀 못 느끼고, 단순한 봇은 대부분 여기서 걸러져요. 공짜에 가깝고 붙이기도 쉬워서 1차 방어선으로 딱이에요.
12.3 허니팟은 어떻게 만들어요?
상황을 그려볼게요. 이메일과 메시지를 받는 문의 폼이 있어요. 여기에 website라는 가짜 칸을 하나 더 넣을 거예요. 봇이 좋아할 법한 흔한 이름이면 좋아요. 먼저 HTML이에요.
<form method="post" action="/contact">
<input type="text" name="email">
<textarea name="message"></textarea>
<div class="hp">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<button>보내기</button>
</form>
이제 이 website 칸을 사람 눈에서 완전히 숨겨야 해요. 여기서 조심할 게 있어요. type="hidden"으로 하면 안 돼요. 봇도 hidden은 아 이건 원래 비는 칸이구나 하고 안 건드리거든요. 그래서 보이는 입력칸인 척 두고 CSS로만 가려요.
.hp {
position: absolute;
left: -9999px;
}
화면 밖으로 밀어내는 방식이에요. display: none보다 이렇게 왼쪽 멀리 던지는 게 안전해요. 일부 봇은 display none인 칸은 아예 무시하거든요. 마지막으로 서버에서 판정해요. 이 부분이 진짜 방어예요. 화면 쪽 CSS는 서버가 볼 수 없으니까요.
app.post("/contact", (req, res) => {
if (req.body.website) {
return res.status(200).send("ok");
}
saveContact(req.body);
res.send("ok");
});
봇이 채운 website 값이 있으면 저장하지 않고 그냥 200(정상 처리) 응답을 돌려줘요. 여기서 포인트는 에러를 안 낸다는 거예요. 너 봇이지 하고 대놓고 막으면 봇이 눈치채고 우회하거든요. 성공한 척 조용히 버리는 게 훨씬 효과적이에요.
12.4 시간 함정도 같이 놓으면 좋다던데요?
허니팟에 시간 검사를 얹으면 훨씬 촘촘해져요. 아이디어는 단순해요. 사람이 폼을 읽고 채우려면 최소 몇 초는 걸리죠. 그런데 봇은 폼을 받자마자 0.3초 만에 제출하기도 해요. 그러니 폼이 화면에 뜬 시각을 숨겨 보내고, 서버에서 제출까지 걸린 시간을 재는 거예요.
<input type="hidden" name="ts" value="1720666800">
그리고 서버에서는 이렇게 확인해요.
const gap = Date.now() / 1000 - Number(req.body.ts);
if (gap < 3) {
return res.status(200).send("ok");
}
제출까지 3초도 안 걸렸다면 사람으로 보기 어렵죠. 이때도 허니팟과 똑같이 조용히 버려요. 허니팟과 시간 함정을 함께 쓰면 비용 한 푼 안 들이고 단순 봇의 상당수가 막혀요. 여기까지가 사용자를 전혀 귀찮게 하지 않는 은근한 방어예요.
12.5 캡차는 언제 꺼내요?
허니팟으로도 막기 어려운 똑똑한 봇이 있어요. 이럴 때 캡차(CAPTCHA, 사람과 기계를 가르는 자동 검사)를 붙여요. 신호등 사진 고르기나 체크박스 하나 누르기처럼 사람은 쉽고 기계는 어려운 문제를 내는 방식이에요. 대표 도구가 구글 reCAPTCHA(리캡차)와 클라우드플레어 Turnstile(턴스타일)이에요.
요즘 방식은 옛날처럼 찌그러진 글자를 읽히지 않아요. reCAPTCHA v3나 Turnstile은 사용자의 행동 패턴(마우스 움직임, 방문 이력 등)을 보고 점수를 매겨요. 대부분의 진짜 사용자는 아무것도 안 눌러도 통과하고, 수상할 때만 추가 확인을 띄우죠. 붙이는 흐름은 두 단계예요. 화면에 위젯을 올려 토큰(임시 통과권)을 받고, 서버에서 그 토큰을 발급처에 검증 요청해요.
const r = await fetch(VERIFY_URL, {
method: "POST",
body: new URLSearchParams({
secret: SECRET_KEY,
response: req.body["cf-turnstile-response"]
})
});
const data = await r.json();
if (!data.success) {
return res.status(400).send("검증 실패");
}
핵심은 검증은 반드시 서버에서 한다는 거예요. 화면에서 통과했음 표시만 믿으면, 봇이 그 표시를 흉내 내 우회해요. 발급처에 토큰을 되물어 success가 참일 때만 진짜 처리로 넘어가야 안전해요. 캡차는 강력하지만 사용자에게 약간의 마찰을 주니, 회원가입이나 로그인처럼 중요한 폼에만 골라 쓰는 걸 권해요.
12.6 그래도 서버에서 한 번 더 막아야죠?
허니팟도 캡차도 결국 화면 쪽 장치예요. 작정한 공격자는 화면을 건너뛰고 서버로 직접 요청을 쏴요. 그래서 마지막 방어선은 서버의 속도 제한(rate limit, 요청 횟수 제한)이에요. 같은 IP(인터넷 주소)에서 1분에 5번까지만 제출을 받겠다 같은 규칙이죠.
const hits = new Map();
function limit(ip) {
const now = Date.now();
const arr = (hits.get(ip) || []).filter(t => now - t < 60000);
arr.push(now);
hits.set(ip, arr);
return arr.length <= 5;
}
IP마다 최근 1분(60000밀리초) 안의 요청 시각만 남기고, 그 개수가 5개를 넘으면 거절하는 구조예요. 이러면 봇이 초당 수십 번 때려도 대부분 튕겨 나가요. 실무에서는 서버를 껐다 켜도 유지되도록 Redis(레디스, 빠른 메모리 저장소) 같은 걸 쓰거나, 클라우드플레어 같은 앞단에서 아예 막기도 해요. 넘친 요청에는 429(Too Many Requests, 요청이 너무 많음) 코드를 돌려주면 돼요.
12.7 오늘 정리
봇은 폼을 공짜 확성기로 노리고, 화면이 아니라 HTML을 읽어 초당 수십 번 제출을 때린다고 했어요. 이걸 막는 3종 세트를 봤죠. 허니팟은 사람 눈에 안 보이는 가짜 칸으로 단순 봇을 조용히 거르고, 여기에 시간 함정을 얹으면 공짜로 더 촘촘해져요. 더 똑똑한 봇에는 캡차(reCAPTCHA, Turnstile)를 쓰되 검증은 서버에서, 중요한 폼에만 골라 붙여요. 마지막으로 화면을 건너뛰는 공격까지 잡으려면 서버의 속도 제한이 필요했고요. 한 겹으로 다 막으려 하지 말고 은근한 허니팟, 필요할 때 캡차, 바닥의 속도 제한으로 겹겹이 쌓는 게 실무의 정답이에요.