크롤러 구조를 아예 갈아엎고 나니 보이던 것들

26년 08월 14일

크롤링 속도는 워커 수가 아니라 IP 수였다

법원 경매 정보를 긁어오는 크롤러를 아예 갈아엎었다.

기존 구조는 워커 3개를 띄워서 60개 법원을 셋으로 나눠 동시에 돌리는 방식이었다.

그런데 이게 계속 차단당했다.

알고 보니 3개 워커가 전부 같은 IP로 법원 사이트를 때리고 있었다.

Cloud Run에서 컨테이너 몇 개를 띄우든 송신 IP는 하나로 묶이기 때문에 상대 입장에서는 한 놈이 세 배로 시도하는 것처럼 보였던 것이다.

그래서 단일 워커로 줄여봤다.

그랬더니 이번엔 31.4시간이 걸렸다.

Cloud Run Job 타임아웃인 6시간을 가뿐하게 넘겼다.

병목은 워커 수가 아니라 법원 사이트 응답 속도였다.

요청 하나에 1초씩 걸리니 초당 처리량이 0.24건으로 박혀 있는데 워커를 아무리 늘려봤자 IP가 하나면 의미가 없다.

여기서 결론이 나왔다.

크롤링 속도는 (IP당 한계) × (IP 수)다.

그래서 새 구조는 브라우저 하나만 띄우고 페이지 안에서 상세 조회만 3-way 병렬로 돌리는 방식으로 정리했다.

리스트를 넘기는 이동은 직렬로 하고 각 페이지에서 물건 3건을 동시에 적재한다.

IP는 하나니까 차단 걱정이 없고 병렬은 IP 한도 안에서만 회수한다.

재시도 로직도 전부 들어냈다.

예전에는 rate-limit을 만나면 30분 대기 후 스스로 재실행하는 코드가 있었는데 이 동안 비용만 계속 나갔다.

이제는 rate-limit을 만나는 즉시 종료하고 하루 2회 20시와 05시 고정 스케줄로 돌린다.

135개 단위 테스트가 전부 통과했다.

”12시간 안에 물건이 있으면 스킵”의 문제

크롤러를 돌릴 때 중복 실행 방지용 스킵 판단이 하나 있었다.

12시간 안에 해당 법원 물건이 수집돼 있으면 이번 실행은 넘어간다는 규칙이었다.

그런데 이 기준이 자꾸 오판을 했다.

이전 실행이 실패로 끝났는데도 물건만 몇 개 남아 있으면 “성공했다”고 보고 스킵해버린 것이다.

실패한 실행을 다시 안 돌리니 결국 데이터에 구멍이 생긴다.

그래서 기준을 바꿨다.

물건 존재 여부가 아니라 batch_runs라는 완료 추적 테이블에 완전 성공 기록이 있느냐로 판단한다.

성공이면 스킵, 아니면 다시 돈다.

court-crawler뿐 아니라 시세 수집과 고아 데이터 정리 잡에도 같은 유틸을 묶어서 적용했다.

지표가 전부 정상이었던 OOM

이 날 가장 오래 붙잡고 있던 문제는 OOM이었다.

크롤러가 메모리 부족으로 SIGKILL을 당하는데 Cloud Monitoring 지표상으로는 메모리가 멀쩡했다.

원인은 detailRes.json이었다.

법원 상세 페이지에 사진이 base64로 실려 오는데 그게 물건 하나에 120에서 150MB쯤 된다.

이걸 루프가 끝날 때까지 들고 있으니 메모리가 터질 수밖에 없었다.

첫 시도는 mapped 객체 쪽을 null 처리하는 거였는데 소용이 없었다.

JavaScript 엔진 입장에서 mapped된 참조를 끊어도 원본 detailRes.json이 살아 있으면 가비지 컬렉터가 회수하지 않는다.

결국 원본 변수에 직접 null을 넣어서 해결했다.

그러고 나니 1.5Gi에서 OOM이 0건이 됐다.

그런데 여기서 의문이 하나 남았다.

지표가 왜 정상이었냐는 거다.

둘째 문제의 답은 관측 쪽에 있었다.

Cloud Monitoring은 샘플링이 60초 단위다.

1.5Gi짜리 사진 payload가 들어왔다 나가는 피크가 60초 사이에 완료되면 지표에 흔적이 남지 않는다.

거기에 더해 cgroup은 컨테이너 전체 프로세스 트리의 메모리를 합산해서 보고한다.

크롤러 본체와 브라우저가 각자 쓰는 메모리가 섞여서 나오니 “평소와 같은 수준”처럼 보였던 것이다.

지표가 정상이라고 믿으면 안 되는 경우가 있다는 걸 몸으로 배웠다.

catch문에서 exit하면 finally가 안 돈다

마지막 문제는 잠금 누수였다.

크롤러가 크롤링 전에 crawl_lock이라는 잠금을 잡고 finally에서 해제하는 구조였다.

그런데 어느 순간부터 잠금이 영구히 남아서 다음 실행이 시작을 못 하는 일이 반복됐다.

코드를 추적하니 범인은 catch 블록 안의 process.exit()였다.

에러를 만나면 catch에서 곧바로 프로세스를 끊어버리는데 process.exit()는 finally 블록을 실행하지 않는다.

당연한 얘기 같은데 막상 운영 코드에서는 조용히 스며들어 있었다.

exit 직전에 잠금 해제를 직접 호출하도록 수정해서 마무리했다.

HTTP 클라이언트도 정리

재시도 규칙도 정리했다.

공통 HTTP 모듈에 limit 3회, backoff 25초짜리 기본값을 두고 상속해서 쓰되 비공식 법원 API만 예외로 뒀다.

비공식 API는 재시도 자체가 위험해서 limit 0에 동시 요청은 p-queue로 묶었다.

가장 짜증 났던 건 지오코딩이었다.

VWorld라는 좌표 변환 서비스가 한도를 초과하면 429 에러를 주지 않는다.

200 OK에 NOT_FOUND를 실어 보낸다.

그러니 재시도 로직이 아예 발동을 안 한다.

게다가 실패한 지오코딩에 37.0/127.0 식의 placeholder 좌표가 박혀서 NOT NULL 검사를 무력화하고 있었다.

데이터베이스를 열어보니 NULL이어야 할 좌표 270건에 전부 대한민국 중심 좌표가 들어가 있었다.

placeholder 대신 NULL을 넣도록 고치고 270건을 전부 치환했다.

rate 게이트를 손으로 짜던 코드는 TOCTOU 경합이 있어서 p-queue로 갈아탔다.

그 외의 정리

Drizzle로 스키마를 관리하면서 migrations 폴더에 쌓인 37개 파일과 db

전부 제거했다.

이제 schema가 유일한 진실 공급원이고 CI의 codegen과 type-check가 드리프트를 잡는다.

동적 컬럼을 다루는 sql.raw 호출만 역매핑 레이어를 하나 얹어서 타입 안전을 확보했다.

GitHub Actions도 Node 20 deprecation 대응을 했다.

docker 액션 3종을 v4/v7로 올리고 setup-node도 v7로 올렸다.

여기서 배운 건, 액션 런타임은 메이저 버전 번호만 보면 안 된다는 거다.

action.yml의 using 필드를 열어봐야 실제 노드 버전이 나온다.

메이저 버전이 같아도 내부 런타임이 바뀌어 있는 경우가 있다.

Husky 9에는 commit-msg 훅을 하나 달았다.

중국어가 섞이면 차단하고 한국어가 없으면 차단한다.

macOS의 BSD grep은 -P 플래그를 못 쓰기 때문에 Perl 원라이너로 짰다.

그리고 에이전트 오케스트레이터에서 asyncSubagents가 고립되는 교착 상태를 하나 잡았다.

고립 항목을 정리하는 강제 청소가 idle/pending 게이트 뒤에 있어서 도달 자체를 못 하던 문제였다.

청소를 게이트 앞으로 옮기니 smoke-test 16개, persistence 테스트 20개가 전부 통과했다.

macOS 디스크 1.8GB에서 48GB로

개인 맥북도 정리했다.

uv 캐시가 40GB, Docker 빌드 캐시가 19.4GB를 차지하고 있었다.

둘을 정리하니 가용 공간이 1.8GB에서 48GB가 됐다.

46GB를 회수한 셈이다.

캐시가 얼마나 조용하게 디스크를 먹는지 실감한 하루였다.

관측 불가능한 자산에 대한 권고 차단

투자 도우미 쪽에서도 하나 고쳤다.

내가 관측할 수 없는 별도 계좌의 현금에 대해 “현금 비중을 늘리라”는 권고가 반복해서 나오던 문제였다.

입력에 안 들어오는 자산을 가지고 비율을 계산하니 계속 비슷한 소리를 하는 것이었다.

프롬프트 네 곳을 고쳐서 권고 자체를 차단하고 금액 출력도 숨겼다.

데이터 격리 테스트 15개가 전부 통과했다.

관측 불가능한 자산에 대한 권고는 프롬프트 수준에서 차단해야 하고 출력 스키마와 입력 다이제스트를 함께 손봐야 완전히 차단된다는 걸 확인했다.

마무리

하루 종일 크롤러랑 싸운 날이었다.

구조를 바꾸니 이전에는 안 보이던 것들이 보였다.

지표의 사각지대, finally를 우회하는 exit, 오판하는 스킵 판단.

전부 기존 구조가 가진 가정 위에 얹혀 있던 문제들이었다.

다음에는 batch_runs 기반 스킵이 실제 운영에서 얼마나 정확하게 동작하는지 며칠 지켜볼 생각이다.