지난 글 이후, 이 코드베이스는 원래 목적(고객사별 뉴스 리포트)에서 갈라져 나와 특정 영업팀 타깃팅용으로 훨씬 무거운 버전을 만드는 별도 저장소로 옮겨갔다. 검색·크롤링·요약·표 조립까지 이어지는 파이프라인 구조는 그대로다. 번호도 그대로 이어서 쓴다.
1년치를 다시 돌려야 했던 이유
이 저장소로 넘어온 뒤 처음 돌린 1년치 리포트에서, 유독 몇몇 대기업 계열사(특히 삼성 계열)만 2025년 데이터가 거의 없다는 게 눈에 띄었다. 원인을 찾아보니 네이버 검색 API가 결과를 날짜 최신순으로 정렬해서 주는데, 한 번에 가져올 수 있는 결과 수를 200건으로 잡아놨더니 검색량이 많은 키워드는 최근 몇 주치만으로 200건이 다 차버려서 1년 전 기사까지 아예 도달하지 못하고 있었다. 공식 문서를 확인해서 이 API가 실제로 허용하는 상한이 1000건이라는 걸 확인하고, 그 값으로 올렸다.
다섯 시간 넘게 멈춰 있었다
상한을 1000으로 올린 뒤 리포트를 다시 돌렸다. 아침 7시 56분에 시작했는데, 다섯 시간이 넘도록 끝날 기미가 안 보였다. CPU 사용률은 계속 95~96%를 찍고 있었는데, 결과 파일이 나오는 출력 폴더는 텅 비어 있었고, 캐시 DB도 3시간 반 넘게 갱신된 흔적이 없었다.
이 조합이 이상했다. LLM 호출을 기다리는 단계라면 네트워크 대기가 대부분이라 CPU가 이렇게 계속 높게 나올 이유가 없다. CPU가 쉬지 않고 도는데 아무 결과도 안 나온다는 건, 어딘가 순수 연산으로 막혀 있다는 뜻이었다.
원인을 코드에서 찾았다
증거를 체크해 보았다. 첫 번째 증거는 캐시 DB의 쓰기 패턴이었다 — 크롤링 단계도, 요약 단계도 기사 하나를 처리할 때마다 바로바로 캐시에 기록하도록 짜여 있는데, 그 캐시가 3시간 반째 멈춰 있다는 건 둘 중 어느 단계도 실제로 진행되고 있지 않다는 뜻이었다.
두 번째 증거는 중복 제거 코드 자체였다. 직접 읽어보니 새 기사가 들어올 때마다 지금까지 쌓인 기사 전체와 하나씩 비교하는 구조였다 — 후보 기사가 수만 건 단위(나중에 확인한 실제 숫자는 53,347건)까지 쌓인 상태에서 이런 전수 비교를 반복하면, 몇 시간이 걸려도 이상하지 않은 계산량이었다.
죽이기 전에, 안전한지부터 따졌다
멈춰 있는 프로세스를 그냥 죽이면 안 되는 이유가 있는지부터 따졌다. 캐시 DB는 기사 하나를 크롤링할 때마다, 그리고 요약할 때마다 그 자리에서 바로 SQLite에 기록하는 구조라 — 걱정했던 “디스크에 계속 무겁게 쓰고 있는” 상황은 아니었다. 매번 몇 KB짜리 행 하나를 갱신하는 가벼운 쓰기였다. 죽여서 실제로 날아가는 건 메모리에만 있던 중복 제거 진행 상황뿐이었고, 그 계산 자체가 지금 이렇게 느린 게 문제였으니 처음부터 다시 돌려도 손해랄 게 없었다. “메모리에 다 두느냐, 디스크에 계속 쓰느냐” 둘 중 하나를 골라야 하는 문제가 아니라, 비싼 부분(크롤링·요약)은 이미 건건이 저장하고 있고 싼 부분(중복 비교)만 다시 하면 그만이었다.
로그부터 심었다
고치기 전에, 다음 실행에서는 지금 어느 단계인지 최소한 알 수 있게 만들어야 했다. 검색 → 크롤링 → 중복 제거 → 요약 → 리포트 작성, 각 단계의 시작·종료·건수를 타임스탬프와 함께 찍는 로그를 추가하고, 중복 제거 루프 안에는 200건마다 진행 상황을 한 줄씩 남기게 했다. 지금 멈춰 있는 프로세스는 건드리지 않았다 — 파이썬은 이미 메모리에 옛날 코드를 올려둔 상태라 파일만 고쳐서는 어차피 반영되지 않는다. 이 로그는 다음 실행을 위한 것이었다.
진짜 고친 건 날짜로 묶은 것
핵심 수정은 중복 비교 범위를 통째로 좁힌 것이었다. 같은 보도자료가 여러 매체에 거의 그대로 재게재되는 건 며칠 안에 몰려서 일어나지, 몇 주씩 시차를 두고 일어나지 않는다. 그런데 기존 코드는 새 기사 하나를 지금까지 쌓인 전체 기사와 비교하고 있었다 — 애초에 겹칠 가능성이 없는 것들까지 전부 비교 대상에 넣고 있었던 셈이다.
중복 제거 모듈의 인터페이스를 통째로 바꿨다. 원래는 리스트 전체를
받아 인덱스를 찾는 find_duplicate_index(리스트, 내용) -> 인덱스 | None 형태였는데(이 인덱스 값은 정작 호출하는 쪽에서 쓰지도 않고
있었다), 발행일 기준으로 날짜별 버킷에 나눠 담고 앞뒤 3일 범위
안에서만 비교하는 구조로 바꿨다. 이 모듈을 검증하는 테스트도 이번에
처음 추가했다 — 같은 날이나 며칠 이내에 나온 중복은 잘 잡히는지, 3일
넘게 떨어진 완전히 같은 텍스트는 (원래 의도대로) 중복으로 잡히지
않는지까지 확인하게 했다.
다시 돌려서 확인했다
멈춰 있던 프로세스를 종료하고(정상 종료 신호로 깔끔하게 끝난 걸 확인했다), 고친 코드로 다시 실행했다. 검색 단계(21개 타깃 대상)가 5분 19초 만에 끝나면서 고유 후보 URL 53,347건이 나왔다. 이 숫자를 보고 나서야, 왜 예전 방식의 전수 비교가 몇 시간씩 걸려도 이상하지 않았는지가 거꾸로 납득이 됐다 — 문제를 고친 다음에야 문제가 얼마나 컸는지 보이는 순서였다.
배운 점
이번에 가장 크게 남은 건 날짜 버킷 자체가 아니라, 그 전에 심어둔 로그였다. 재실행 이후 각 단계가 언제 시작하고 언제 끝나는지 실시간으로 보이니까, 예전처럼 몇 시간을 “지금 뭐 하고 있는 건지” 모른 채 기다릴 필요가 없어졌다. 이 로그를 보면서 나온 말이 있었다 — “이거가 있었으면 앞에서 언제 끝날지 답답함이 없었을텐데… 시간이 앞에서 많이 걸리는 것을 알면서도 이걸 만들 생각을 빨리 못했네.” 이번 한 번의 수정으로 끝낼 일이 아니라고 판단해서, 몇 분 이상 걸릴 걸로 예상되는 스크립트는 막히고 나서 로그를 추가하는 대신 처음부터 단계별 로그를 넣는 걸 습관으로 삼기로 했다. 이번 건에서 진짜 오래 남을 교훈은 날짜 버킷팅(이 리포트 하나에만 맞는 수정)이 아니라, 그 로그를 만든 습관 쪽이었다.