LLM WikiAccess-protected knowledge portal
🔒 내부용 · 비배포 · 출처: ai/wiki/concepts/crawler-collection-count-log.md · ai/worklog/2026/2026-W24.md (2026-06-10)

크롤러 배치 수집량 로그 (product/version insert·update)

5개 라이브러리 크롤러에 "이번 배치에 뭘 얼마나 수집했나"를 남기는 로그를 붙인 작업의 온보딩 상세

0한눈에

1왜 했나

기존 로그는 "이번 배치에서 패키지 N개 처리"라는 누적 카운트뿐이라, 그 배치가 실제로 새로 들어온 데이터를 모은 건지 기존 데이터를 다시 덮어쓴 건지 구분되지 않았다. 백필·재수집·증분 수집을 함께 돌리는 운영 환경에서 "오늘 신규 유입이 얼마였나"를 로그만 보고 알 수 없었던 것이 출발점이다. 그래서 배치 단위로 insert/update를 분리해 보이게 만드는 게 목표였다.

2핵심 제약: upsert는 insert/update를 안 알려준다

배치는 1,000행(golang V2는 이벤트 단위)을 한 방의 멀티로우 INSERT로 보낸다. MySQL이 ON DUPLICATE KEY UPDATE에서 돌려주는 affected_rows는 행마다 신규=1, 변경 update=2, 변화 없음=0인데, 배치 전체가 한 statement라 돌아오는 건 합쳐진 숫자 하나뿐이다. 거기서 insert가 몇·update가 몇인지는 역산되지 않는다(무변화 update가 0으로 잡혀 산수마저 깨진다). 게다가 현재 실행 계층은 rowcount를 읽지도 않는다.

그래서 선택지를 셋으로 정리하고 가장 가벼운 정확 방식을 골랐다:

방식정확도비용채택
A. 배치 전 PK 조회 — upsert 직전 그 배치 PK를 SELECT, DB에 있으면 update·없으면 insert정확배치당 SELECT 1회(가벼움)채택
B. 한 행씩 실행 + rowcount가장 정확(무변화까지 구분)배치당 수백~천 번 왕복 → 배치 INSERT 장점 상실기각
C. affected_rows 산수부정확(무변화 update에서 깨짐)공짜기각
의사결정 기록: 사용자가 "쿼리 날릴 때 insert/update를 알 수 있지 않냐"고 짚었고, "한 행씩이면 가능하지만 지금처럼 배치로 보내면 합계 하나뿐이라 불가"임을 확인한 뒤 가벼운 A로 합의했다.

3어떻게 구현했나

  1. 공통 베이스 클래스(baseCralwer.py)에 집계 헬퍼 6개 추가: _resetCollectStats / _entityForTable / _selectExistingKeys / _recordCollectStats / _collectBatchSummary / _collectTotalSummary.
  2. setInsertIntoDB 진입부(upsert 직전)에서 _recordCollectStats(table_name, datas) 호출. 대상이 PRODUCT_TABLE / VERSION_TABLE일 때만 집계(status·list·license 테이블은 무시).
  3. 분류: product는 PRODUCT_KEY, version은 (PRODUCT_KEY, VERSION) 기준으로 500개씩 묶어 WHERE PRODUCT_KEY IN (...)(파라미터 바인딩) 조회 → 있으면 update, 없으면 insert.
  4. 배치 진입 시 _resetCollectStats()로 배치 카운터·중복제거 set 리셋. 배치 종료 시 [batch summary], 런 종료 STOP 로그 직전 [total summary].
중복제거 포인트: golang/dotnet/ruby는 SORT_ORDER를 채우려 같은 version을 한 번 더 upsert한다. 배치 단위 _collectSeen set으로 이미 센 키를 건너뛰어, 이 재삽입이 update로 이중 집계되는 것을 막았다.

크롤러별 주입 위치 — php/ruby/dotnet은 dataCrawling() 상단 리셋 + 말미 배치요약, run() STOP 직전 총합. swift(cocoapods.py)는 반복 배치 루프가 없는 단일 패스라 crawl 메서드 전체를 1배치로 보고 finally:에서 둘 다 출력(중간 예외에도 보장). golang은 쓰기 경로가 setInsertIntoDB(V1 정렬)와 _setInsertIntoDBWithConnection(V1-core·V2) 둘로 갈려 양쪽 다 훅했고(인스턴스 단위 dedup이라 겹쳐도 1회), V1·V2 각 루프와 STOP 3곳을 모두 처리한다.

4로그 포맷 (5개 크롤러 동일)

메시지 문자열은 5개 크롤러에서 완전히 동일하다(공통 헬퍼의 f-string을 그대로 복사). 로그 라인의 파일명:라인 prefix만 호출 위치에 따라 다르다.

[batch summary] product insert=12 update=3, version insert=45 update=8
[total summary] product insert=1340 update=210, version insert=5021 update=880

전체 라인 포맷도 5개 공통: %(asctime)s %(levelname)s %(process)s %(filename)s:%(lineno)d %(message)s. 파싱 정규식: \[(batch|total) summary\] product insert=(\d+) update=(\d+), version insert=(\d+) update=(\d+) 하나로 5개 모두 처리.

5검증 / 안전장치

아직 실컨테이너 스모크는 미실시(실DB·네트워크 의존). 운영 반영 전 1 윈도우 실행 권장. golang V2는 이벤트 단위라 집계 SELECT가 배치당 1회가 아니라 이벤트당 소량 여러 번 나간다(각각 가벼움) — 더 줄이려면 기존 _batchSkipExistingEvents 존재체크 결과 재사용이 후보.

6온보딩 포인트

7공개(포트폴리오)와의 차이

이 문서는 내부 식별자(repo id crawler-lib-*, 내부 테이블 TB_*·gatheringdb, 메서드명)를 그대로 쓴 본인 참고/온보딩용이다. 포트폴리오 공개가 필요하면 식별자를 일반화하고 "upsert에서 insert/update 가시화"라는 문제·접근·결과 중심으로 다시 쓴다. 일반 기술 명칭(MySQL, ON DUPLICATE KEY UPDATE 등)은 공개에도 유지 가능.