워드프레스 크론을 누가 띄우는지 33일 로그로 세어 보니, 방문자보다 공격 스캐너가 많았다
워드프레스는 예약 작업을 도는 크론(cron)을 서버의 시계가 아니라 방문자의 요청에 얹어서 돌립니다. 누군가 페이지를 열면 그 요청 안에서 “지금 돌릴 일이 있는가”를 확인하고, 있으면 자기 자신에게 wp-cron.php를 한 번 더 호출합니다. 그래서 “방문자가 없으면 크론이 늦는다”는 말을 자주 듣는데, 정작 누가 얼마나 늦게 띄우는지를 재본 적은 없었습니다. 이번에 이 서버의 접속 로그 33일치, 2,819,012줄을 전부 읽어 크론 호출만 골라냈습니다. 세 개의 워드프레스 사이트에서 33일 동안 2,647번 호출됐고, 그중 누가 띄웠는지 하나로 좁혀지는 735건을 분류하니 절반에 가까운 45.9%가 방문자가 아니라 로그인 페이지와 xmlrpc.php를 두드리는 공격 스캐너였습니다. 그리고 그중 일부는 크론을 띄우기만 하고 아무 일도 하지 않는 “빈 호출”이었다는 것을 실험으로 확인했습니다.
어떻게 셌는가
이 서버의 접속 로그는 여러 사이트가 한 파일에 섞여 쌓입니다. 다행히 워드프레스가 자기 자신에게 보내는 크론 요청은 User-Agent에 사이트 주소가 들어 있어서(WordPress/7.0.4; https://kilho.org) 사이트별로 나눌 수 있었습니다. 대상은 로테이션된 로그 5개, 2026년 8월 9일 0시부터 9월 11일 0시까지 33.0일입니다.
| 사이트 | 크론 호출 | 하루 평균 | 하루 최소~최대 |
|---|---|---|---|
| kilho.org | 934 | 28.3 | 24~49 |
| damyo.net | 883 | 26.8 | 24~31 |
| 네트워크 루트(비공개) | 830 | 25.1 | 12~43 |
하루 24번 안팎이 나오는 이유는 세 사이트 모두 한 시간마다 도는 이벤트(wp_privacy_delete_old_export_files)가 하나씩 있기 때문입니다. 나머지 이벤트는 12시간·1일·1주 주기라 하루에 몇 번 보태는 정도입니다. 플러그인은 0개라 코어 이벤트 9~12개가 전부입니다.
호출 자체는 가볍습니다. 돌릴 일이 없을 때 wp-cron.php를 직접 POST해 보니 응답까지 30~135ms였고, 홈 화면 한 번(200~230ms)보다 짧았습니다. 문제는 호출 횟수가 아니라 제때 호출되는가입니다.
한 시간짜리 이벤트가 실제로 몇 분 늦게 돌았나
워드프레스는 반복 이벤트를 다시 예약할 때 원래 예정 시각에 주기를 더합니다. 늦게 돌았어도 다음 예정 시각은 밀리지 않습니다. 그래서 매시간 이벤트의 예정 시각은 “매시 몇 분 몇 초”로 고정되고, 로그에 찍힌 실제 호출 시각과의 차이가 곧 지연입니다. 로그 3일 단위로 호출이 가장 몰리는 2분 창을 찾아 그 시작점을 예정 시각으로 잡았습니다(10초 단위 추정이라 오차는 그 안쪽입니다).
| 사이트 | 시간 슬롯 | 지연 중앙값 | 상위 10% | 최대 | 한 시간 넘게 늦은 슬롯 |
|---|---|---|---|---|---|
| damyo.net | 790 | 73초 | 512초 | 3,547초 | 0 |
| kilho.org | 781 | 220초 | 886초 | 3,556초 | 9 (1.2%) |
| 네트워크 루트 | 775 | 322초 | 2,201초 | 3,578초 | 125 (16.1%) |
지연을 구간별로 보면 사이트 차이가 더 분명합니다.
| 지연 | damyo.net | kilho.org | 네트워크 루트 |
|---|---|---|---|
| 10초 미만 | 7.5% | 7.9% | 2.6% |
| 10초~1분 | 39.1% | 15.5% | 10.6% |
| 1~5분 | 32.4% | 36.8% | 34.2% |
| 5~15분 | 16.8% | 30.2% | 25.8% |
| 15~30분 | 2.4% | 7.9% | 12.2% |
| 30~60분 | 1.8% | 1.7% | 14.6% |
이 서버에는 33일 동안 요청이 끊긴 가장 긴 구간이 127초였습니다. 5분 이상 조용했던 적은 한 번도 없습니다. 그런데도 kilho.org의 시간 이벤트는 절반이 3분 40초 이상 늦었고, 열 번에 한 번은 15분 가까이 늦었습니다. 요청은 초당 1건꼴로 들어오는데 왜 늦을까요.
요청이 많다고 크론이 도는 게 아니다
이유는 로그에 섞인 요청의 대부분이 이 워드프레스를 거치지 않기 때문입니다. 같은 서버에 다른 사이트가 열 개 넘게 있고, 크론을 띄우는 것은 그중 kilho.org의 워드프레스를 실제로 로드한 요청뿐입니다. 이미지·CSS 같은 정적 파일은 PHP를 거치지 않아 아무것도 띄우지 않습니다. 결국 kilho.org라는 사이트에 PHP까지 도달하는 요청이 몇 분에 한 번이라는 뜻이고, 그 간격이 그대로 크론 지연이 됩니다. damyo.net이 더 빠른 것도 콘텐츠가 좋아서가 아니라 그 사이트로 오는 요청이 조금 더 잦기 때문입니다.
네트워크 루트 사이트는 검색엔진 색인을 막아 둔 빈 사이트라 정상 방문자가 거의 없습니다. 그 결과 시간 이벤트가 한 시간을 넘겨 도는 일이 16%였습니다. 방문자가 없는 워드프레스에서 예약 글이 밀리는 현상은 이 숫자 그대로입니다. 예약 글이 ‘예약을 놓침’으로 표시되는 원인은 예약 글이 ‘예약을 놓침’으로 표시될 때 확인할 WP-Cron 원인에서 다뤘는데, 그때는 원리만 적었고 이번에 실제 지연 분포가 나왔습니다.
누가 띄우는가 — 크론 직전 1초의 요청
크론 호출은 그것을 띄운 요청과 같은 초에 로그에 찍힙니다. 그래서 크론 호출 직전 1초 안에 들어온 요청(정적 파일과 서버 자신의 요청 제외)이 하나뿐인 경우만 골라 “이 요청이 띄웠다”고 봤습니다. 두 사이트 1,825건 중 후보가 하나로 좁혀진 것은 735건(40.3%)입니다. 나머지는 같은 초에 요청이 둘 이상이거나 하나도 없어(로그 시각이 요청 시작 기준이라 긴 요청은 뒤에 찍힙니다) 제외했습니다.
| 띄운 요청의 종류 | kilho.org | damyo.net | 합계 |
|---|---|---|---|
| 공격·탐색 경로 (xmlrpc.php, wp-login.php, install.php, .env 등) | 143 (37.0%) | 194 (55.7%) | 337 (45.9%) |
| 봇·도구 UA (검색·AI 크롤러, curl, python 등) | 109 (28.2%) | 80 (23.0%) | 189 (25.7%) |
| 브라우저 UA로 정상 페이지 | 122 (31.5%) | 63 (18.1%) | 185 (25.2%) |
| 기타 | 13 | 11 | 24 (3.3%) |

경로별로 보면 xmlrpc.php 167건, 홈(/) 142건, wp-login.php 115건, 그다음이 wp-admin/install.php 16건입니다. “브라우저 UA로 정상 페이지”도 전부 사람은 아닙니다. 스캐너 상당수가 크롬 UA를 달고 옵니다. 반대로 봇 쪽에는 Googlebot, AhrefsBot, SemrushBot, WordPress.com의 핑 요청이 들어 있습니다. 사람이 글을 읽는 요청이 크론을 띄운 비율은 넉넉히 잡아도 넷 중 하나입니다.
이 서버는 xmlrpc.php 접근을 차단하는 이유를 글로 쓴 적이 있는데, 정작 실측해 보니 xmlrpc.php를 두드리는 요청이 크론을 가장 많이 띄우고 있었습니다. 차단하면 크론을 띄워 주던 요청이 사라지니 지연은 오히려 늘어납니다. 보안 조치와 크론 지연은 같은 손잡이에 달려 있습니다.
install.php 스캐너가 띄운 크론은 아무 일도 하지 않았다
9월 10일 새벽 4시 22분부터 2분 사이에 kilho.org의 크론이 21번 호출된 구간이 있었습니다. 시간당 한 번이 정상인 자리입니다. 같은 시각 로그에는 IP 하나가 /wp/wp-admin/install.php, /old/wp-admin/install.php, /test/wp-admin/install.php처럼 설치 파일 위치를 바꿔 가며 44번 두드린 기록이 있었고, 그 요청이 200을 받을 때마다 크론이 한 번씩 따라 찍혔습니다. 33일 전체로는 install.php 요청 709건(IP 133개, 30일에 걸침), 그 직후 1초 안에 찍힌 크론 호출이 kilho.org 54건, 네트워크 루트 12건입니다.
왜 21번이나 띄웠나
처음엔 잠금이 안 걸리나 했습니다. 워드프레스는 크론을 띄울 때 doing_cron이라는 임시값(transient)에 시각을 적어 60초 동안 다시 띄우지 않게 막습니다. 그런데 install.php는 파일 첫 줄에서 WP_INSTALLING을 켜고 워드프레스를 로드합니다. 설치 중에는 임시값을 DB에 쓰지 않고 그 요청 안에서만 사는 메모리 캐시에 씁니다(set_transient가 wp_installing()이면 wp_cache_set만 호출합니다). 설치가 끝난 사이트에서도 이 파일을 열면 “이미 설치되어 있습니다” 화면을 보여 주기 위해 같은 경로를 탑니다.
그 결과 두 가지가 동시에 일어납니다.
- 잠금이 DB에 없으니 install.php 요청마다 크론이 새로 뜹니다.
- 띄워진
wp-cron.php는 DB에서 잠금을 읽어 자기 주소에 붙은 값과 비교하는데, DB에는 아무것도 없으니 이벤트를 하나도 돌리지 않고 종료합니다.
로그만으로는 두 번째를 증명할 수 없어서 실험했습니다. 1초 전에 예정된 1회성 이벤트를 만들어 두고, PHP 안에서 wp_installing(true)를 켠 뒤 spawn_cron()을 호출했습니다.
MODE=install
probe due: yes | wp_installing: true
spawn 0.041s | lock in cache: 1789053973.13… | lock in DB: (none)
1.5s later | lock in DB: (none) | probe event still scheduled: YES (did not run)
MODE=normal
probe due: yes | wp_installing: false
spawn 0.035s | lock in cache: (none) | lock in DB: 1789053978.40…
1.5s later | lock in DB: (none) | probe event still scheduled: no (ran)
설치 모드에서는 잠금이 캐시에만 남고 DB는 비어 있으며, 크론 호출은 로그에 찍혔지만 이벤트는 그대로 남았습니다. 일반 모드에서는 DB에 잠금이 걸리고 이벤트가 사라졌습니다. 4시 22분의 21번 호출은 스캐너가 install.php를 두드릴 때마다 워드프레스를 통째로 한 번 더 로드하고 아무것도 안 한 채 끝난 호출이었습니다. 시간 이벤트가 실제로 돈 것은 그 뒤 일반 요청이 들어왔을 때입니다.
한 번의 빈 호출은 30~130ms짜리 PHP 실행 하나라 비용은 작습니다. 다만 kilho.org 크론 호출 934건 중 install.php 직후 1초 안에 찍힌 54건(5.8%)은 거의 전부 이런 빈 호출로 봐야 하고, 스캐너가 몰리는 2분 동안은 크론이 도는 것처럼 보이지만 실제로는 예정된 일이 계속 미뤄지는 상태가 됩니다.
예정 시각이 이유 없이 옮겨진 흔적
kilho.org의 시간 이벤트 예정 시각은 8월 9일부터 24일까지 매시 18분 근처였다가, 8월 24일에서 27일 사이에 매시 21~23분으로 옮겨졌습니다. 워드프레스는 늦게 돌아도 예정 시각을 밀지 않으므로 정상 동작에서는 이 값이 바뀌지 않습니다. 바뀌는 경우는 이벤트가 사라졌다가 init 훅에서 time() 기준으로 다시 만들어질 때뿐입니다. 네트워크 루트 사이트는 호출이 예정 시각 근처에 모이지 않아 이 방법으로는 예정 시각 자체를 안정적으로 잡을 수 없었습니다.
이벤트가 왜 사라졌는지는 확인하지 못했습니다. 크론 목록 전체가 cron 옵션 하나에 직렬화돼 있어, 두 프로세스가 동시에 읽고 쓰면 한쪽의 변경이 지워질 수 있습니다. 위의 빈 호출과 정상 호출이 같은 초에 겹치는 일이 실제로 있었으니 후보로는 맞지만, 로그로 잡히는 종류의 흔적이 아니라서 추정으로만 남깁니다.
고치지 않은 것과 그 이유
보통 이 주제의 결론은 “DISABLE_WP_CRON을 켜고 시스템 크론으로 wp-cron.php를 호출하라”입니다. 이번에는 하지 않았습니다.
| 선택지 | 얻는 것 | 이 서버에서 안 한 이유 |
|---|---|---|
| 시스템 크론으로 전환 | 지연이 요청과 무관해짐, 빈 호출 소멸 | 지금 예약 글·자동 갱신 어느 것도 지연으로 실패한 적이 없음. 15분 지연이 문제되는 작업이 없음 |
| install.php 접근 차단 | 빈 호출 54건 소멸 | 재설치 시 필요. 스캐너가 다른 경로로 옮겨 갈 뿐 크론 지연은 그대로 |
| xmlrpc.php 차단 | 보안 | 크론을 가장 많이 띄우는 요청이 사라져 지연이 늘어남. 필요해지면 시스템 크론과 같이 적용 |
대신 기준 하나를 남깁니다. 예약 글이나 시간에 민감한 작업을 이 서버에 올리게 되면, 그때는 “방문자가 적어서”가 아니라 “워드프레스까지 도달하는 요청이 몇 분에 한 번이라서” 늦는다는 것을 전제로 시스템 크론을 먼저 켭니다. 이 서버는 지금 그 기준에 걸리는 작업이 없어서 그대로 둡니다.
측정 방법 요약
- 로그:
/var/log/httpd/access_log와 로테이션 4개, 2026-08-09 00:00 ~ 2026-09-11 00:03, 2,819,012줄. 파이썬으로 전수 파싱. - 크론 호출 식별: 서버 자신의 IP에서 온
POST /wp-cron.php?doing_wp_cron=…중 UA가WordPress/로 시작하는 것. 외부 IP가 wp-cron.php를 두드린 463건은 제외. - 지연: 3일 창마다 호출이 가장 몰리는 2분 구간의 시작을 예정 시각으로 추정(10초 단위). 예정 시각 뒤 첫 호출까지의 초.
- 띄운 요청: 크론 호출 직전 1초 안의 비정적 요청이 하나뿐일 때만 집계.
- 빈 호출 실험:
wp eval-file로 1회성 이벤트를 예정 시각 −1초에 만들고wp_installing(true)여부만 바꿔spawn_cron()실행. 잠금은wp_options를 직접 조회. 실험 이벤트는 삭제.