크롤러가 죽었는데 아무도 몰랐다
법원경매 권리분석 프로젝트의 크롤러가 5일 동안 23번 실행했고, 23번 전부 차단당했다.
모니터링 대시보드에 실패가 하나도 없었다.
원인은 process.exit(0) 한 줄이었다.
차단을 감지한 크롤러가 정상 종료 코드로 프로세스를 끝냈고, Cloud Run은 그걸 성공으로 기록했다.
알림도 재시도도 무력화된 채로, 눈에 보이는 건 초록색 체크표시뿐이었다.
차단까지의 타임라인을 로그로 복기하니 214초, 74요청, 161MB를 쓰고 법원 60개 중 1개만 처리한 뒤 연결이 리셋됐다.
교훈은 명확하다.
실패는 실패 코드로 노출해야 한다.
같은 IP로 즉시 재시도하지 않는 것과 실패를 숨기는 것은 완전히 다른 정책인데, 둘을 한 줄짜리 exit(0)로 묶어버린 게 본질이었다.
봇이라는 흔적들
차단 원인을 요청 패턴에서 찾았다.
시그널이 네 가지였다.
첫째, 요청 간격이 정확히 3000ms 등간격이었다.
사람은 그렇게 요청하지 않는다.
지연을 0~2000ms 난수로 흩뿌리는 게 최소한의 예의다.
둘째, 세션을 여는 bootstrap 함수가 전역 레이트리밋 큐를 우회해 23ms 만에 GET을 4연발 쏘았다.
“시작할 때 잠깐이니까”라는 예외가 전체 스텔스 전략을 무의미하게 만든다.
셋째, 브라우저 요청과 HTTP 클라이언트 요청이 각각 다른 페이로 흘렀다.
같은 IP, 같은 세션에서 리듬이 두 개면 그 자체로 지문이다.
넷째, User-Agent는 Chrome을 사칭했는데 TLS 지문은 Bun이었다.
위장은 탐지 리스크지 보호막이 아니다.
정직한 식별 UA에 연락처를 붙이는 게 장기적으로는 더 안전하다는 결론에 도달했다.
차단도 2단계로 진행됐다.
1단계는 status 200에 한국어 차단 메시지, 응답 JSON에는 ipcheck: false가 박혀 있다.
상태 코드만 보면 정상 응답이라 이것도 모르고 넘어갈 뻔했다.
페널티 중 재요청이 쌓이면 2단계로 TCP RST가 날아온다.
차단 신호를 감지하면 즉시 멈추고 명시된 cooldown을 지키는 게 프록시 우회보다 낫다.
프록시 로테이션이나 TLS 위장 같은 우회는 원칙적으로 금지했다.
공고 API를 발견해서 브라우저를 없애기로 했다
전환점은 뜻밖의 곳에서 왔다.
남이 쓴 court-auction-notice-search 문서를 읽다가 공고 목록 API를 발견했다.
공고 목록은 6KB, 상세도 150KB다.
물건 상세 한 건이 4MB씩 나오던 기존 경로와는 자릿수가 다르다.
GCP IP에서 실제로 프로브를 돌려보니 차단 없이 200이 돌아왔다.
POST 세 번으로 인천 전체 공고를 17초 만에 긁었다.
더 좋은 건 서버단 필터다.
검색 파라미터에 대분류 코드를 건물로 지정하면 비건물 매물이 검색 결과부터 제외된다.
기존 크롤러는 비건물 매물도 상세를 4MB씩 내려받은 뒤 클라이언트에서 버렸다.
그 낭비를 원천 차단할 수 있게 됐다.
이 결정에 따라 v2는 방향을 완전히 틀었다.
Patchright와 Chromium을 코드에서만 빼는 게 아니라 Dockerfile의 chromium install까지 지운다.
발견 경로도 브라우저 검색 대신 공고 API 기반으로 바꾸고, 매일 1회 증분 수집으로 간다.
기존 데이터는 버리고 재수집하기로 했다.
마이그레이션 비용이 재수집 비용보다 크다는 판단이다.
소스를 바꿀 때 잃는 것도 목록화했다.
브라우저 검색은 매각이 완료돼도 목록에 잔류해서 크롤 중 자연스럽게 결과가 감지됐는데, 공고는 매각기일이 지나면 사라진다.
낙찰가를 영영 못 보는 구멍이 생기는 것이다.
이건 별도의 결과 폴링 잡으로 메운다.
매각기일이 지났는데 결과가 없는 물건만 골라 사건검색을 1회씩 돌리는 방식이다.
0% 컬럼은 필요 없는 게 아니라 버그일 수 있다
v2 설계 전에 프로덕션 DB의 컬럼 채움률을 실측했다.
지하층수 컬럼이 2,573건 전부 0%였다.
“수집이 안 되는 필드는 제거”하려던 참에 API 원본 태그와 대조해봤다.
원인은 오타였다.
실제 태그는 <ugrndFlrCnt>인데 정규식이 ugrdFlrCnt를 찾고 있었다.
글자 하나 고치면 살아나는 컬럼이었다.
0% 컬럼을 제거하기 전에 원본 태그와 대조하는 게 필수 절차라는 교훈을 얻었다.
fingerprint 설계의 함정도 발견했다.
지문을 구성하는 요소는 전부 법원 리스트 데이터라서, 건축물대장 보강에 실패한 물건도 지문은 동일하게 찍힌다.
1차 크롤에서 API가 429를 뱉으면 best-effort로 스킵하는데, 2차 크롤에서 지문이 같으니 그 물건은 영원히 스킵된다.
일시적 실패가 영구 결손으로 굳는 구조다.
보강 성공 여부를 지문에 포함시켜 all-or-nothing으로 저장하기로 했다.
모델 전환의 후폭풍
같은 날 진행 중이던 에이전트 프레임워크의 모델을 GLM-5.2에서 5.3으로 올렸다.
새 모델은 API의 모델 목록에 아직 등록되어 있지 않았다.
목록 조회로는 검증이 불가능해서 실제 호출로 확인했다.
일반 엔드포인트는 거부하는데 코딩 플랜 엔드포인트는 응답했다.
동일 제공자라도 상품별로 모델 노출 범위가 다르다는 것을 그때 알았다.
신모델 검증은 목록이 아니라 실제 호출로 해야 한다는 교훈이다.
전환의 후폭풍이 있었다.
전역 모델을 바꾸면 모델을 핀으로 고정하지 않은 크론잡이 이걸 “내가 승인하지 않은 지출 변화”로 해석하고 RuntimeError로 스스로를 중단시켰다.
블로그 일기 자동 발행 잡이 그 희생양이었다.
3일치 일기가 조용히 발행되지 않았고, 모니터링 리포트는 왔지만 발행 실패는 따로 보이지 않았다.
잡 3개를 모델 핀으로 고정하고 누락분을 수동으로 발행해서 복구했다.
이 사고로 얻은 운영 원칙이 하나 생겼다.
전역 모델 교체 직후에는 크론잭 last_status를 전수 점검하는 것까지가 교체 작업이다.
교체 그 자체가 끝이 아니다.
다음 생각
v2 개편 설계는 끝냈다.
브라우저 제거, 공고 기반 발견, 결과 폴링, fingerprint 보강, 서버단 필터까지 결정됐다.
이제 구현이다.
큰 리팩터라 서브에이전트로 분리해서 병렬로 돌릴 계획이다.
권리분석 쪽에서도 흥미로운 결론이 나왔다.
“선순위권리 해당사항없음” 공고에 임차인과 점유 비고까지 없으면 등기부를 열지 않고도 안전 등급을 매길 수 있다.
등기부는 필요조건이지 충분조건이 아니라는 점, 그리고 필요조건조차 없어도 결론 가능한 프로파일이 존재한다는 점이 이 프로젝트의 분석 경계를 그을 때 계속 기준이 될 것 같다.