– Akismet은 유료였고 스팸은 손으로 지운 것만 292건이었다. 허니팟·서명 토큰·내용 규칙·Cloudflare로 DB에 저장되기 전에 거부하도록 만들고 16일 뒤 실제로 몇 건이 들어왔는지 다시 세어본 기록.

워드프레스로 블로그를 한지 5달 째,
블로그 관리자 화면에 들어갈 때마다 댓글 알림이 빨갛게 떠 있었다.
열어보면 전부 러시아어거나, 도박 사이트 링크였다.
처음에는 양이 많지 않아서 손으로 하나씩 눌러서 스팸 처리했다.
근데 점점 많아졌다. 스팸을 처리하느라 몇 장씩 봐야했다
그게 쌓여서 292건이 됐다.
먼저 숫자부터 봤다
감으로 “많다”고 느꼈지만 정말 많은가? 싶어서 실제로 세봤다.
| 시기 | 스팸 유입 |
|---|---|
| 4월 | 11건 |
| 5월 | 6건 |
| 6월 | 6건 |
| 7월 | 108건 |
| 8월 (19일까지) | 161건 |
5~6월엔 한 달에 6건이었다.
그런데 7월에 18배가 됐고, 8월은 19일 만에 161건이다.
하루 8~9건씩 손으로 지우고 있었다는 뜻이다.
내용도 뜯어봤다.
- 292건 중 223건(76%) 이 키릴 문자를 포함했다
- 263건(90%) 이 본문에 링크를 달고 있었다
- 절반 가까이가 작성자 웹사이트 주소를 채웠다
한국어 기술 블로그에 키릴 문자 댓글이 올 이유는 없다.
즉 기계적으로 걸러낼 수 있는 패턴이 분명히 있었다.

Akismet을 못 쓴 이유
워드프레스 스팸 대책의 표준 답안은 Akismet이다.
기본으로 설치까지 돼 있다.
문제는 요금이다.
Akismet의 요금제를 보면 무료 등급은 개인·비상업 블로그용인데 광고를 붙이는 순간 상업 사이트가 된다.
애드센스를 준비하는 블로그라면 유료 요금제를 써야 한다는 뜻이다.
월 몇 달러가 아까워서라기보다 이 정도 패턴이면 직접 막을 수 있겠다는 생각이 들었다.
실제로 그랬다.
스팸함에 쌓는 게 아니라, 저장을 막는다
여기서 방향을 하나 정했다.
대부분의 스팸 플러그인은 스팸을 스팸함으로 분류한다.
그러면 DB에는 그대로 쌓이고 나는 여전히 가끔 들어가 비워야 한다.
내가 원한 건 그게 아니었다.
애초에 저장되지 않게 하는 것.
워드프레스에는 preprocess_comment 필터가 있다.
댓글이 DB에 들어가기 직전에 호출된다.
여기서 거부하면 스팸함에조차 안 남는다.
4겹으로 막으면 대부분 안들어오겠지 ?
① 없는 필드에 값이 오면 봇
내 댓글 폼에는 “웹사이트” 입력란이 없다.
예전에 폼을 정리하면서 지웠다.
그런데 스팸의 절반은 url 값을 보내온다.
폼에 없는 칸을 채워서 보낸다는 건, 내 폼을 본 적도 없이 워드프레스 표준 템플릿대로 POST하는 봇이라는 뜻이다.
if (!empty($_POST['url'])) {
f0_spam_reject('url-field');
}
한 줄로 절반이 걸러진다.
② 허니팟
사람 눈에는 안 보이고, 탭 키로도 갈 수 없는 입력란을 하나 심는다.
echo '<div aria-hidden="true" style="position:absolute !important; left:-9999px !important;">'
. '<label>Website<input type="text" name="f0_website" tabindex="-1" autocomplete="off" /></label></div>';
사람은 이걸 채울 방법이 없다.
폼의 모든 입력란을 기계적으로 채우는 봇만 걸린다.
③ 서명 토큰 – 이게 제일 많이 잡는다
스팸의 대부분은 내 글 페이지를 열지도 않는다.
wp-comments-post.php로 곧장 POST를 쏜다.
그래서 폼에 서명된 타임스탬프를 심었다.
$t = time();
echo '<input type="hidden" name="f0_t" value="' . esc_attr($t) . '" />';
echo '<input type="hidden" name="f0_k" value="' . esc_attr(wp_hash('f0c' . $t)) . '" />';
검증할 때는 서명이 맞는지 확인한다.
if (!$t || !hash_equals(wp_hash('f0c' . $t), $k)) {
f0_spam_reject('token');
}
폼을 실제로 받아간 적이 없으면 유효한 서명을 만들 수 없다.
여기서 중요한 건 캐시와 공존시키는 것이었다.
내 사이트는 페이지 캐시를 쓴다.
일반적인 nonce는 시간이 지나면 만료되므로 캐시된 페이지에 박아두면 나중에 방문한 사람의 댓글이 거부된다.
wp_hash()는 같은 입력에 항상 같은 값을 내는 결정적 함수라서, 캐시된 페이지에서도 서명이 계속 유효하다.
만료는 서명이 아니라 타임스탬프로 따로 판단한다.
④ 내용 규칙
마지막은 단순하다.
키릴 문자가 있으면 거부, 링크가 2개 이상이면 거부.
if (preg_match('/[\x{0400}-\x{04FF}]/u', $blob)) {
f0_spam_reject('cyrillic');
}
한국어 블로그라서 쓸 수 있는 규칙이다.
영어권 블로그였다면 이 규칙은 못 썼을 것이다.
내 사이트의 특성이 곧 필터가 된다.
이게 범용 플러그인이 못 하는 부분이다.
검증하다 내가 만든 함정에 빠졌다
여기서 한 번 크게 헤맸다.
봇 패턴 다섯 가지를 실제로 POST해서 전부 403으로 막히는 걸 확인했다.
그다음 정상 댓글도 통과하는지 확인했는데 이것도 403이 떴다.
한참을 들여다봤다.
원인은 내가 넣은 “최소 3초” 규칙이었다.
사람은 폼을 열고 최소 몇 초는 타이핑한다는 가정으로 3초 안에 제출되면 봇으로 봤다.
그런데 내 테스트 스크립트는 폼을 받자마자 0초 만에 POST했다.
규칙은 정상이었고, 테스트가 사람이 아니었던 것이다.
6초를 기다린 뒤 다시 보내니 정상 등록됐다.
교훈이 하나 남았다.
스팸 필터를 만들 때 “막히는 것”만 확인하면 절반만 한 것이다.
통과해야 할 것이 통과하는지를 반드시 같이 봐야 한다.
그래서 검증 절차를 아예 문서에 적어뒀다.
| 테스트 | 기대 결과 |
|---|---|
| 토큰 없이 직접 POST | 403 |
| 키릴 문자 본문 | 403 |
| 링크 2개 이상 | 403 |
| 허니팟 채움 | 403 |
| url 필드 채움 | 403 |
| 정상 댓글 (6초 대기 후) | 302 – 통과 |
그런데 IP가 전부 Cloudflare였다
로그를 보다가 예상 못 한 걸 발견했다.
스팸 292건의 IP가 62개인데, 전부 104.23.x, 172.70.x 같은 대역이었다.
Cloudflare의 IP다.
즉 워드프레스는 진짜 방문자 IP를 보고 있지 않았다.
CDN을 앞에 두면 서버 입장에서 접속자는 언제나 CDN이다.
진짜 방문자 IP는 Cloudflare가 CF-Connecting-IP 헤더로 따로 넘겨준다.
이건 꽤 중요한 발견이었다.
워드프레스 단에서 IP를 차단하는 건 처음부터 무의미했다는 뜻이니까.
동시에 답도 나왔다.
진짜 IP를 아는 쪽에서 막으면 된다.
마지막 겹: Cloudflare에서 끊기
앞의 4겹은 요청이 PHP까지 도착한 뒤에 막는다.
거부하더라도 서버는 이미 일을 한 셈이다.
Cloudflare 무료 플랜에는 방화벽 규칙(WAF Custom Rules)이 5개까지 있다.
댓글 등록 주소로 오는 POST 중 한국 밖에서 온 것만 골라 검증 절차를 거치게 하면, 요청이 서버에 닿기 전에 끊긴다.
(http.request.uri.path eq "/wp-comments-post.php"
and http.request.method eq "POST"
and ip.geoip.country ne "KR")
동작은 차단(Block)이 아니라 관리형 챌린지(Managed Challenge) 로 잡았다.
해외에서 접속하는 실제 독자가 영영 막히면 안 되기 때문이다.
사람은 잠깐의 확인을 거쳐 통과하고, 봇은 통과하지 못한다.
경로를 정확히 /wp-comments-post.php로 한정한 것도 중요하다.
넓게 잡으면 REST API나 관리자 화면까지 걸려서 내가 나를 막게 된다.

16일 뒤에 다시 세봤다
방어를 배포한 게 8월 19일이다.
배포하고 나서는 아무것도 안 했다.
그냥 두고 16일이 지난 뒤에 다시 세봤다.
이 방어는 스팸을 스팸함으로 분류하는 게 아니라 저장 자체를 거부하기 때문에 확인 방법이 단순하다.
배포 시각 이후 스팸함에 새로 쌓인 건수 = 뚫린 건수다.
| 구간 | 기간 | 스팸 유입 |
|---|---|---|
| 배포 전 | 8/05 ~ 8/18 (14일) | 126건 (하루 평균 9.0건) |
| 배포 후 | 8/20 ~ 9/04 (16일) | 0건 |
스팸함 누계는 292건에서 그대로 292건이다.
배포 전 속도가 유지됐다면 약 140건이 더 쌓였을 텐데 한 건도 들어오지 않았다.
다만 이 숫자를 과장하지는 않으려고 한다.
“0건”은 봇을 다 막았다는 증거가 아니라, 막힌 요청이 DB에 아무 흔적도 남기지 않았다는 뜻이다.
거부된 요청은 서버 로그와 Cloudflare 이벤트에만 남는다.
그러니 “몇 마리를 잡았는지”는 이 표로 알 수 없고 “내가 손으로 지울 일이 없어졌는지”만 알 수 있다.
내가 원했던 건 후자였다.
“실제 독자의 댓글이 잘 통과한다”는 건 아직 실전으로 확인되지 않았고
배포 때 손으로 돌린 검증(6초 기다린 뒤 POST → 302 통과)이 유일한 근거다.
트래픽이 늘어서 진짜 댓글이 달리기 시작하면 그때 한 번 더 확인할 일이 남아 있다.
정리
돈을 안 쓰고 막을 수 있었던 이유는, 문제를 먼저 세어봤기 때문이다.
292건을 들여다보니 키릴 76%, 링크 90%라는 패턴이 보였고, 그 패턴이 그대로 규칙이 됐다.
범용 플러그인은 내 블로그가 한국어라는 걸 모른다.
내 사이트에 대해 내가 아는 것이 가장 강한 필터였다.
워드프레스 스팸으로 골치 아픈 분들과
혹시나 따라 하실 분을 위해 다섯 줄로 줄이면 이렇다.
- 먼저 세어라 – 스팸의 언어·링크 수·IP 분포를 보면 규칙이 저절로 나온다.
- 분류하지 말고 거부하라 –
preprocess_comment에서 막으면 DB에 아무것도 안 남는다. - 폼을 본 적 있는지 물어라 – 서명 토큰 하나가 직접 POST하는 봇 대부분을 걸러낸다.
- 캐시를 고려하라 – 만료되는 nonce는 캐시된 페이지에서 정상 댓글을 죽인다. 결정적 해시 + 별도 시간 판정.
- 통과해야 할 것이 통과하는지 확인하라 – 막히는 것만 보면 절반만 검증한 것이다.
스팸 292건을 내려받아 통계를 내고, 스니펫을 올리고, 실제로 POST를 쏴서 검증하는 과정이 대부분 자동화됐다.
함께 읽으면 좋은 글
글쓴이 · Plutojoshua
핀테크에서 LLM과 AI 에이전트를 실제 서비스에 적용하는 개발자다. 카카오 AI 앰배서더로 Kanana 모델을 앱에 직접 연동해 검증하고, 비전공자로 시작해 실제 코드와 측정값을 근거로 “왜 그렇게 설계했는지”를 1인칭으로 기록한다. 운영자 소개는 About, 코드는 GitHub에서 볼 수 있다.