크롤러가 하루 네 번 차단됐다, WAF는 속도가 아니라 누적량을 보고 있었다

26년 09월 08일

법원 사이트 크롤러가 계속 차단됐다. 스로틀을 1.5초에서 2초로 늘려도, 세션을 돌려도 소용없었다. 결론부터 말하면 WAF는 속도가 아니라 누적 요청량(1,1001,700건) 기준으로 차단하고 있었고, 스로틀 조정은 처음부터 답이 아니었다. 진짜 해법은 폴링할 대상 자체를 94.5% 줄이는 거였다.

같은 증상이 네 번 반복됐다

법원경매 정보를 모으는 배치 크롤러가 하루에 최대 네 번 차단됐다. 한 달 전엔 차단이 0회였는데 말이다.

첫 반응은 뻔했다. “요청이 빠르구나, 간격을 늘리자.” 그래서 1.5초 간격을 2초로 올렸다. 결과는 무효였다. 첫 차단까지 걸린 시간은 두 설정이 사실상 같았다. 1시간 40분 vs 1시간 50분. 오히려 2초 설정은 더 적은 요청 수에서 차단됐고, 유효 처리량은 8,840회에서 5,400회로 33% 줄었다.

시간은 같은데 요청 수가 다르다는 건, WAF가 재는 기준이 따로 있다는 뜻이었다. 초당 요청이 아니라 접속 시간 창 안의 누적량이나 IP 패턴 쪽이었다.

시계열 로그를 나란히 놓으니 규칙이 보였다

차단 시각, 요청 수, 간격을 로그에서 나란히 놓고 봤다.

1.5초 간격이면 누적 1,700건은 대략 42분 걸린다. 관측된 차단 간격(첫 차단 1h40m, 이후 2374분)과 계산이 맞았다. 그리고 재개 직후 차단이 점점 빨라지는 것도 확인했다. 1시간 40분이 19분, 10분으로 짧아졌다. IP 평판이 학습되고 있다는 신호였다. 코드 레벨 대응으로는 못 이긴다.

그리고 아무도 안 잡았을 것 같은 문제가 로그에서 튀어나왔다. 크롤 루프에서 재폴링 작업이 맨 앞에 있었다. 차단, 냉각, 재개를 반복할 때마다 진행 상태가 메모리에만 있어서 큐 전체(10,755건)를 처음부터 다시 폴링했다. 차단 4회는 초기 버스트 1회에 재시작 재폴링이 3번 꼬리를 문 것이었다.

이게 진짜 범인이었다. 수집 파이프라인이 2일간 정지했다. 신규 공고를 발견하는 요청이 특정 시점 이후 전멸했다. 크롤러가 살아있는 것처럼 보였는데 실제로는 재폴링 무한 반복에 갇혀서 정작 새 데이터를 못 가져오고 있었다.

큐의 93%는 애초에 폴링할 필요가 없었다

재폴링 큐를 열어보니 14,233건이 쌓여 있었다. 정상일 때는 대기 80건 수준이었다. 크롤이 계속 실패하면서 하루 1,200건씩 11일 동안 쌓인 결과였다.

그런데 이 큐를 조건별로 뜯어보니 이상했다. 이미 결과가 확정된 물건이 잔뜩 있었던 것이다.

  • 이미 매각완료(낙찰가 기록됨): 1,056건
  • 이번 기일 유찰 기록이 이미 있음: 8,550건
  • 결과 게시 지연창(기일+1~3일) 안의 물건: 727건

진짜로 폴링이 필요한 잔여는 968건이었다. 전체의 6%다. 94.5%가 면제 대상이었다. 유찰(002)과 낙찰(001)은 같은 기일의 상호 배타적 결과라, 이미 유찰 기록이 있으면 재폴링해도 같은 이벤트만 돌아온다. 멱등 no-op다. 다시 묻는 것 자체가 낭비였다.

면제 조건을 적용하자 폴링 예상 시간이 계산상 16분이 됐다. 4.5시간짜리 작업이 16분으로 줄어든 것이다.

여기서 한 가지 선택을 기각했다. 상태 전이 규칙을 새로 만들자는 제안이었는데, 웹 화면을 들여다보니 이미 공고대기, 결과대기 같은 단계를 저장 없이 매번 파생 계산하고 있었다. 새 상태 머신을 얹는 대신 기존 파생 시스템에 맞췄다. 유찰, 취하 같은 status 값은 enum에만 존재하고 한 번도 쓰인 적 없었다는 사실도 그 판단을 지지했다.

등간격 1.5초는 정밀한 신호다

간격 분포를 실측했더니 또 하나 나왔다. 요청 n=1,999개의 90.7%가 정확히 1.5초 격자 위에 있었다. p50 1501ms, p95 1502ms. 병렬 레인이 4MB짜리 응답을 파싱하는 동안에도 게이트가 다음 요청을 채워서 밀리지 않는 것이다.

사람이라면 불가능한 분포다. 이건 방어가 아니라 공격 서명이었다. WAF 입장에서 보면 시작부터 끝까지 메트로놈을 보는 셈이다.

그래서 게이트에 지터를 넣었다. 평균 간격은 유지하되 0~750ms 무작위 지연을 얹었다. 1.5초 ±100ms 밴드에 몰려 있던 비중이 90.7%에서 6.1%로 떨어졌다. 처리량은 약 20% 희생했다. 처음엔 아까웠는데, 누적량 기반 차단이라는 결론이 나온 이상 순간 처리량은 볼 게 아니었다.

6시간 39분 무차단

최종 수정은 사건 단위 백오프다. 폴링 이력을 디비에 영속화해서 재시작하면 이미 확인한 사건은 다시 부르지 않게 했다.

배치를 돌렸다. 6시간 39분, 60개 법원 1,802건을 무차단으로 완주했다. 기존엔 10분에서 110분 사이에 반드시 차단됐다. 2차 실행에선 재조회 0사건, 백오프 스킵 916건을 직접 확인했다. 재시작 재호출이 0건이라는 검증이었다.

남은 것

IP 평판 학습은 여전히 열려 있는 변수다. 이번엔 누적량을 줄여서 회피했지만, 평판이 나빠진 상태에서의 첫 차단 임계가 어디까지 내려가는지는 아직 안 재봤다. 지터 분포도 사람 것과 비교하면 여전히 기계적이다. 밴드 비중 6.1%는 낮아졌지만 분포 모양까지 자연스러운 건 아니다.

확인한 것과 가정을 나눠보면, 누적량 기반 차단과 메트로놈 시그니처는 로그 실측으로 확정한 사실이다. IP 평판 학습은 관측된 차단 간격 단축(1h40m → 19분 → 10분)으로 추정한 가정이다. WAF 내부 로직을 직접 볼 수는 없으니, 경계값 1,1001,700건도 관측 범위에서 얻은 근사치다.

돌아보면 “간격을 늘렸는데도 막힌다”는 보고가 들어왔을 때 요청 주기부터 의심했는데, 확인해야 할 건 차단 임계 자체가 내려가고 있는지였다. 그리고 미확인 백로그가 쌓여 있으면 재시도가 임계 도달을 앞당겨서 악순환이 커진다. 백로그부터 정리하는 순서가 맞았다.