지난 글에서 날짜 버킷으로 중복 제거를 고치고 나서, 실제로 얼마나 나아졌는지 전체 숫자로 확인해봤다.
실제 숫자로 보니
검색 5분 19초, 크롤링 4분 7초, 그다음 중복 제거가 60분 31초 걸렸다. 크롤링에 성공한 51,812건 중 살아남은 고유 기사는 18,843건, 나머지 32,969건은 중복 — 크롤링된 기사의 63.6%가 어딘가에서 이미 본 내용의 재게재였다. 그 뒤 LLM 요약이 29분 51초 걸려서, 전체는 1시간 39분에 끝났다. 몇 시간을 넘겨도 안 끝나던 것에서 1시간대로 줄어든 건 맞는데, 숫자를 보고 나니 다른 게 눈에 들어왔다 — 크롤링된 기사의 절반 이상이 애초에 처음 보는 내용이 아니었다는 것, 그리고 이 리포트를 다음 달에 또 돌리면 오늘 “고유하다”고 판정한 기사 상당수를 또 처음부터 비교하게 된다는 것.
예상은 틀렸다
LLM 요약 단계가 끝나기 전에, 호출당 23초로 잡고 대략 걸릴 시간을
미리 어림잡아봤다. 실제로 끝나고 보니 18,843건에 29분 51초 —
호출당 평균 0.95초로, 예상보다 23배 빨랐다. 근거 없이 어림잡은
숫자를 그대로 믿고 넘어가지 않기를 잘했다는 걸, 실측값이 나오고서야
확인한 셈이다. 이 숫자는 동시 처리 개수를 얼마나 올려도 괜찮을지
가늠하는 데도 그대로 썼다.
진짜 한도부터 확인했다
동시 처리 개수를 올리기 전에, 실제 API 한도부터 확인했다. AI Studio의 사용량 화면을 직접 열어서, 지금 쓰는 모델의 분당 요청 4,000건, 분당 토큰 400만, 일일 요청 15만 건이라는 실제 수치를 확인했다 — 지금 동시 처리 10개로는 이 한도에 한참 못 미치고 있었다. 가격 문서도 따로 찾아봐서, 지금 쓰는 가벼운 모델보다 6배 비싼 모델로 올릴 이유가 있는지도 따져봤다. 지금 하는 작업은 주어진 텍스트 안에서 사실을 뽑아내는 일이지 열린 추론이 아니라서, 품질 문제가 실제로 나타나지도 않은 상태에서 몇 배 비싼 모델로 미리 올려둘 근거는 부족하다고 판단했다. 동시 처리 개수만 30개로 올렸다.
매번 처음부터 다시 비교하는 게 아까웠다
정작 손대고 싶었던 건 따로 있었다. 중복 여부는 계정이나 키워드가 아니라 순수하게 기사 내용에 달린 판단이라, 이 리포트를 반복해서 돌릴 걸 감안하면 매번 처음부터 버킷 비교를 다시 하는 건 낭비였다. 캐시 테이블에 이 기사가 “대표(고유)“인지 “어떤 URL의 중복”인지를 저장하는 칼럼을 추가하고, 다음 실행부터는 리포트 기간 앞뒤로 이미 판정 끝난 대표 기사들을 미리 불러와서, 새로 들어오는 기사가 이미 판정된 것이면 비교 자체를 건너뛰게 했다.
여기에 판단 하나를 더 얹었다. 지금까지는 같은 기사가 여러 매체에 겹칠 때 먼저 본 쪽을 대표로 남기고 있었는데, 어느 쪽이 내용을 더 자세히 담고 있는지를 기준으로 바꾸기로 했다. 원래는 작게 나눠서 따로 반영하는 걸 선호하는 편인데, 이번엔 굳이 묶었다 — 대표 선정 규칙이 나중에 바뀌면, 이미 캐시에 옛날 규칙으로 박제된 대표 기사들이 그대로 남아서 결국 수동으로 다시 리셋해야 하는 상황이 오기 때문에, 나눠서 하면 오히려 총 작업량이 늘어나는 경우였다.
이 기능을 만들다가 기존 코드의 진짜 버그 하나를 잡았다. 기사를
캐시에 저장하는 함수가 INSERT OR REPLACE를 쓰고 있었는데, SQLite에서
이건 내부적으로 삭제 후 재삽입이다. 크롤링 직후 한 번 저장하고
요약이 끝난 뒤 같은 URL로 다시 저장하는 시점에, 방금 써놓은 중복
판정 칼럼까지 통째로 날아갈 뻔했다. 실제로 어떤 칼럼을 갱신할지
명시하는 방식으로 바꿔서, 새 기능이 건드리지 않는 칼럼은 그대로
남게 고쳤다.
크롤러도 손볼 게 있었다
중복 제거를 손보는 동안 크롤링 실패율도 신경 쓰였다. 이미 비슷한 문제를 겪어본 다른 저장소(뉴스 자동화 파이프라인)에 그때 만들어둔 대응 로직이 있다는 게 기억나서, 새로 짜기 전에 거기부터 확인했다. 403 응답이 오면 다른 User-Agent로 재시도, SSL 핸드셰이크가 실패하면 검증을 완화해서 재시도, 본문 추출이 안 되면 AMP 페이지로, 그것도 안 되면 메타 설명 태그로 — 도메인에 상관없이 쓸 수 있는 대응만 골라서 가져왔다. 특정 언론사 전용 API나 외부 유료 스크래핑 서비스처럼 이 프로젝트에 딱히 필요하다는 근거가 없는 부분은 가져오지 않았다. 그리고 원래 크롤링이 실패하면 그냥 조용히 넘어가고 있었던 걸, 실패한 URL과 이유를 로그에 남기게 바꿨다 — 다음부터는 뭐가 실패했는지 세는 대신 직접 확인할 수 있게.
다시 돌려서 실패 목록을 봤다
이 상태로 1년치 리포트를 한 번 더 돌렸다. 이번엔 결과물을 뽑는 게 목적이 아니라, 오늘 고친 것들이 실제로 시간과 실패율에 어떻게 반영되는지 보려는 의도적인 재실행이었다. 방금 추가한 로그 덕분에 실패 내역이 처음으로 숫자로 잡혔다 — HTTP 403이 896건으로 가장 많았고, 본문 추출 실패가 382건(특정 매체 하나에 몰려 있었는데, 그 사이트가 JS 렌더링을 해야 본문이 나오는 구조로 보였다), 타임아웃 108건, SSL 핸드셰이크 실패 37건, 키 길이 문제로 인한 실패 34건, 그리고 처음 보는 유형인 인코딩 오류 2건이었다.
진짜 새로 생긴 버그를 하나 잡았다
인코딩 오류 2건을 열어보니 URL 경로에 한글이 인코딩 안 된 채로 그대로 들어 있었다. 이런 URL은 요청 자체를 만들 수가 없어서 바로 예외가 났다. 이미 퍼센트 인코딩된 URL을 다시 이중으로 인코딩하지 않도록 조심하면서, 경로만 안전하게 인코딩하는 함수를 추가했다. 두 건뿐인 작은 버그였지만, 실제로 실패했던 그 URL 두 개로 직접 검증까지 끝냈다.
포팅이 덜 됐던 걸 데이터가 알려줬다
SSL 관련 실패 71건(핸드셰이크 실패와 키 길이 문제를 합친 수)은 아까 가져온 SSL 완화 로직을 거치고도 그대로 남아 있었다. 원본 코드를 다시 열어보니, 인증서 검증만 완화하고 암호화 방식(cipher) 수준을 낮추는 줄 하나를 가져오는 과정에서 빠뜨렸다는 걸 발견했다. 그 줄을 다시 채워 넣고, 타임아웃 값도 원본 프로젝트와 다르게 8초로 짧게 잡혀 있던 걸 15초로 맞췄다 — 처음 포팅할 때 검증이 충분하지 않았던 부분을, 그다음 실제 실행에서 나온 데이터가 알려준 셈이다.
인프라부터 확인하고 코드를 붙였다
가장 많이 실패한 403(896건)에는 다른 대응이 필요했다. VPS의 IP가 자주 차단당한다는 건 뉴스 자동화 쪽에서 이미 겪어봐서, 그쪽엔 집 PC를 거치는 SSH 터널 프록시(주거용 IP)가 이미 붙어 있었다. 코드부터 옮기지 않고, 실제로 403이 났던 URL 하나를 그 터널로 직접 요청해봤다 — VPS에서 직접 요청하면 403, 같은 요청을 집 프록시를 거치면 200이 돌아왔다. 인프라가 실제로 작동한다는 걸 확인하고 나서, 다른 방법이 다 실패했을 때 마지막으로 시도하는 경로로 붙였다. 붙인 뒤에도 실제로 실패했던 URL을 다시 그 경로로 흘려보내서 본문이 제대로 나오는지까지 재확인했다.
배운 점
오늘 고친 것 중 값비싼 부분(크롤링, LLM 요약)은 어차피 건건이 캐시에 남기 때문에, 이번에 새로 만든 캐싱은 그 값비싼 결과를 다음 실행에서 그대로 재사용하기 위한 것이었다. 그리고 포팅해온 코드는 포팅했다고 끝난 게 아니라, 실제 실행 데이터로 한 번 더 검증받고 나서야 제대로 된 것이었다 — SSL 완화 로직도, 인코딩 문제도, 프록시 연결도 전부 “일단 붙였다”가 아니라 “실패 로그로 확인했다”에서 끝났다.