내 블로그에는 하루에 평균 5개의 글이 올라온다. AI 에이전트가 쓴다. 지난 한 달 동안 30개가 넘는 글이 올라왔고, 내가 직접 타이핑한 글은 하나도 없다.

그런데 이걸 누가 지키고 있지?

글을 ordPress는 전 세계 웹사이트의 43%를 차지한다. 공격자들의 타겟 1순위다. xmlrpc.php를 통한 무차별 대입 공격, REST API를 통한 사용자 열거, wp-admin 로그인 페이지 brute force — AI가 매일 글을 쓰는 만큼, 공격 벡터도 매일 늘어난다.

Wordfence나 iThemes Security 같은 플러그인을 깔면 된다고? 맞다. 근데 그 플러그인들이 하는 일의 80%는 PHP 상수 설정 + 헤더 추가 + 액션 훅 차단이다. 5MB짜리 플러그인을 설치할 이유가 없는 작업들이다. 게다가 플러그인은 비활성화될 수 있고, 테마를 바꾸면 동작이 달라질 수 있고, 업데이트가 꼬이면 구멍이 생긴다.

그래서 MU 플러그인 하나에 모든 걸 넣었다. 794줄, 의존성 0개. 테마가 바뀌어도, 다른 플러그인이 죽어도, 이 파일 하나는 항상 살아있다.

이 글이 다루는 것

WordPress의 대표적인 공격 벡터 5가지 — 그리고 각각을 PHP 5줄로 막는 법MU 플러그인이 regular 플러그인보다 나은 이유Brute force 방어를 CAPTCHA 없이 구현하는 패턴 (transient + sleep)보안 플러그인이 SEO 메타태그, Schema.org, llms.txt까지 관리하게 된 설계 결정AI가 쓴 블로그를 AI가 방어하는 구조의 taste

왜 MU 플러그인인가

MU(Must-Use) 플러그인은 wp-content/mu-plugins/ 디렉토리에 넣으면 자동으로 활성화된다. 비활성화 버튼이 없다. 테마를 변경해도, 다른 플러그인을 지워도, 이 파일은 항상 실행된다. 보안 코드가 들어가야 할 자리다.

또 하나. MU 플러그인은 regular 플러그인보다 먼저 로드된다. plugins_loaded 훅 이전에 이미 메모리에 올라간다. 다른 플러그인이 뭘 하기 전에 먼저 차단할 수 있다는 뜻이다.

나한테 이게 중요한 이유는 하나다. AI 에이전트가 블로그를 운영하는 상황에서, “보안 플러그인이 실수로 비활성화됐다” 같은 일은 용납되지 않는다. 사람이 매일 wp-admin을 들여다보는 게 아니니까. 시스템이 스스로 지켜야 한다.

Slice 1: 응답 헤더 — 가장 간단하고, 가장 효과가 큰 것부터

첫 번째 slice는 10줄이었다. wp_headers 필터 하나에 모든 보안 헤더를 박았다.

$headers[‘Strict-Transport-Security’] = ‘max-age=31536000; includeSubDomains’; $headers[‘X-Frame-Options’] = ‘DENY’; $headers[‘X-Content-Type-Options’] = ‘nosniff’; $headers[‘Referrer-Policy’] = ‘strict-origin-when-cross-origin’; $headers[‘Permissions-Policy’] = ‘camera=(), microphone=(), geolocation=(), interest-cohort=()’; unset($headers[‘X-Powered-By’]); unset($headers[‘X-Pingback’]);

HSTS는 1년간 HTTPS를 강제한다. X-Frame-Options는 클릭재킹을 막는다. X-Content-Type-Options는 MIME 타입 스니핑을 차단한다. X-Powered-By는 서버 정보를 숨긴다. 모두 5줄이다. 이걸 위해 10MB짜리 보안 플러그인을 깔아야 할까?

이 slice의 교훈: 보안의 80%는 이미 알고 있는 설정을 적용하는 것이다. 취약점을 찾는 게 아니라, 알려진 방어선을 까먹지 않고 켜두는 것.

Slice 2: wp-admin 접근 제어 — 로그인하지 않았으면 문도 보여주지 마라

WordPress의 기본 동작: wp-admin URL로 접근하면 로그인 페이지(wp-login.php)로 리다이렉트한다. 이게 뭐가 문제냐면, 공격자에게 “여기 WordPress 사이트가 있다”고 알려주는 셈이다.

내가 한 건 간단하다. is_user_logged_in() 체크를 init 훅에서 가장 먼저 실행하는 것.

if (preg_match(‘#/wp-admin/#i’, $request) && strpos($request, ‘admin-ajax.php’) === false) { if (!is_user_logged_in()) { nocache_headers(); wp_redirect(wp_login_url($_SERVER[‘REQUEST_URI’]), 302); exit; } }

로그인한 사용자는 정상적으로 wp-admin을 쓴다. 로그인하지 않은 사용자는 로그인 페이지로 보낸다. admin-ajax.php는 예외 처리해서 프론트엔드의 AJAX 호출이 막히지 않게 한다.

별거 아닌 것 같지만, 이 6줄로 wp-admin을 향한 모든 비인가 접근 시도가 차단된다.

Slice 3: Brute force 방어 — CAPTCHA 없이, UX 희생 없이

여기가 taste가 들어간 지점이다. 보통 무차별 대입 공격을 막으려면 Google reCAPTCHA를 붙이거나, Wordfence 같은 플러그인으로 IP 차단을 한다. 둘 다 문제가 있다. CAPTCHA는 정당한 사용자의 로그인 경험을 망가뜨린다. Wordfence는 DB 테이블을 추가하고 관리 오버헤드를 만든다.

내 방식은 두 가지다.

  1. 실패 시 3초 지연. sleep(3). 단순하지만 효과적이다. 초당 300회 시도하던 공격이 초당 1회로 줄어든다. 공격자는 3초씩 기다려야 하니까 금방 포기한다. 정당한 사용자는 비밀번호를 한 번 틀려도 3초만 기다리면 된다. CAPTCHA를 풀 필요도 없다.
  1. IP당 시도 횟수 제한. WordPress transients API를 써서 IP별 실패 카운터를 1시간 동안 유지한다. 5회 실패하면 429 Too Many Requests + 잠금 메시지를 반환한다.

add_action(‘wp_login_failed’, function ($username) { sleep(3); $ip = $_SERVER[‘REMOTE_ADDR’] ?? ”; $attempts = get_transient(‘login_attempt_’ . $ip) ?: 0; set_transient(‘login_attempt_’ . $ip, $attempts + 1, HOUR_IN_SECONDS); }); add_action(‘login_init’, function () { $ip = $_SERVER[‘REMOTE_ADDR’] ?? ”; if ((get_transient(‘login_attempt_’ . $ip) ?: 0) >= 5) { header(‘HTTP/1.1 429 Too Many Requests’); wp_die(‘로그인 시도가 너무 많습니다. 1시간 후에 다시 시도해주세요.’); } });

transient는 WordPress의 내장 키-값 저장소다. 별도 DB 테이블을 만들 필요가 없다. 1시간 TTL이라 오래된 데이터는 자동으로 사라진다. 쌓이지 않는다.

이 접근의 taste: 정당한 사용자를 괴롭히지 않으면서 공격자의 속도를 죽인다. sleep(3)은 공격자에게는 3,000ms의 지연, 사용자에게는 비밀번호 한 번 더 입력하는 시간이다. 비대칭적 방어다.

Slice 4: 공격 표면 줄이기 — 없는 문은 공격할 수 없다

네 가지를 차단했다.

xmlrpc.php — pingback/trackback을 통한 DDoS와 brute force 진원지. add_filter(‘xmlrpc_enabled’, ‘__return_false’) 한 줄 + URL 접근 301 차단. REST API /wp/v2/users — 사용자 목록을 JSON으로 노출한다. 작성자명, 이메일 해시, 포스트 수까지 보여준다. 사용자 열거의 완벽한 창구. rest_endpoints 필터에서 해당 엔드포인트만 제거. readme.html, install.php — WordPress 버전 정보를 그대로 노출한다. 301로 홈으로 돌림. wp_generator 메타태그 — HTML 소스에 WordPress 버전을 박는다. remove_action(‘wp_head’, ‘wp_generator’).

핵심 원칙은 단순하다: 공격자가 정보를 얻을 수 있는 통로를 먼저 막아라. 사용자명을 모르면 brute force를 시도할 수 없다. WordPress 버전을 모르면 버전별 취약점을 노릴 수 없다.

Slice 5: 보안 플러그인이 SEO를 하는 이유

여기까지가 “보안”이었다면, 여기서부터는 판단의 문제다. 같은 MU 플러그인에 SEO, Schema.org, llms.txt, 디자인 커스터마이징까지 전부 넣었다. 794줄의 결과물이다.

왜 하나의 파일에 다 넣었을까? 이유는 셋이다.

플러그인 개수 = 공격 표면. SEO 플러그인 하나, 보안 플러그인 하나, 스키마 플러그인 하나씩 깔면 업데이트해야 할 대상이 3개로 늘어난다. 하나라도 업데이트가 밀리면 취약점이 생긴다. MU 플러그인은 하나만 관리하면 된다. 공통 데이터, 중복 제거. SEO 메타 태그를 찍을 때 사이트 이름, 설명, 로고 URL이 필요하다. Schema.org JSON-LD를 찍을 때도 똑같은 정보가 필요하다. 같은 파일에 있으면 get_bloginfo() 한 번으로 모두 해결된다. AI 접근성까지. /llms.txt 엔드포인트를 MU 플러그인으로 구현했다. AI 크롤러가 내 콘텐츠를 인덱싱할 수 있게 하는 파일이다. 이건 “보안”이 아니라 “개방”에 가깝지만, 같은 원리다 — 믿을 수 있는 대상에게는 문을 열고, 아닌 대상에게는 닫는 것.

이 결정의 taste: 보안과 SEO는 분리된 관심사가 아니다. 둘 다 “누가 이 사이트에 접근할 수 있고, 무엇을 볼 수 있는가”를 제어한다. 검색 엔진에게는 메타데이터를 노출하고, 공격자에게는 사용자 목록을 숨긴다. 같은 파일에서 관리하는 게 자연스럽다.

설계 결정과 실패 모드

결정대안선택 이유실패 모드

MU 플러그인Regular 플러그인비활성화 불가, 가장 먼저 로드FTP로 직접 파일을 삭제해야 비활성화 가능 (의도된 동작) transient 기반 brute forceDB 테이블 / CAPTCHA별도 스키마 불필요, UX 희생 없음transient가 Redis에 저장된 경우 TTL 정확도 문제 가능성 (현재는 MySQL, 문제 없음) sleep(3) 지연CAPTCHA / rate limit 플러그인의존성 0, 정당한 사용자 영향 최소PHP-FPM worker를 3초간 점유. 동시 공격이 많으면 worker 고갈 가능. (단일 사용자 블로그라 영향 제한적) REST API 엔드포인트 선택적 제거REST API 전체 비활성화Gutenberg 에디터 유지, AI 글쓰기 파이프라인 유지새로운 REST API 엔드포인트가 추가되면 수동 업데이트 필요 하나의 파일에 보안+SEO 통합각각 별도 플러그인관리 포인트 1개, 공통 데이터 재사용파일이 커질수록 가독성 저하. Slice 단위 주석으로 분리해서 대응

AI가 블로그를 쓸 때 보안은 누가 하는가

돌아보면 이 MU 플러그인을 만든 것도 AI 에이전트였다. content-manager가 “WordPress 보안 정리”라는 태스크를 받고, explorer가 취약점을 분석하고, implementer가 794줄을 작성했다. reviewer가 검증하고, sre가 배포했다.

그러니까 AI가 쓴 블로그를 AI가 방어한 셈이다.

하지만 중요한 건 여기서 끝이 아니라는 거다. 이 구조의 진짜 가치는 내가 신경 쓰지 않아도 계속 지켜진다는 것이다. MU 플러그인은 비활성화되지 않는다. transients는 자동으로 만료된다. 보안 헤더는 모든 응답에 자동으로 붙는다. 유지보수 비용이 0에 수렴한다.

Taste의 관점에서 다시 보자면, 이건 “방어의 자동화”다. AI 에이전트에게 콘텐츠 생성을 Tier 1(완전 자율)로 맡길 수 있는 이유는, 같은 AI가 방어막도 Tier 1으로 유지하고 있기 때문이다. 무언가를 자동화했다면, 그걸 지키는 것도 자동화해야 한다.

읽어줘서 고맙다. 이 MU 플러그인의 전체 코드는 GitHub에 올라가 있다.