증상
조회 결과 0건. 여기서 갈라지는 두 해석이 정반대 결론을 만든다.
| 관측 | 해석 A (없음) | 해석 B (실패) |
|---|---|---|
| 검색 0건 | 그 대상이 없음 | 무작위 봇 차단 |
| 메일 0건 | 새 메일 없음 | SINCE·LIMIT 필터에 걸림 |
| 카탈로그에서 항목 미발견 | 단종 | 페이지를 덜 긁음 |
| 배치 처리 0건 | 대상 없음 | 대상 목록의 소스가 다름 |
| 텍스트 0자 | 본문 없음 | 본문이 이미지 태그뿐 |
products.json 404 | 카탈로그 없음 | 그 플랫폼이 아닐 뿐 |
| 도메인 미발견 | 사이트 없음 | 추측으로 두드렸을 뿐 |
| 집계에 "카탈로그 없음" | 수집 안 됨 | 수집은 됐는데 매핑 테이블에 미등록 |
판별법
0/N에서 N을 모르면 0의 의미도 모른다대가
이 구분을 안 해서 사흘간 치른 값: 4개 로케일 "재고 없음" 오판 · 메일 12건을 0건으로 판정(3회 반복) · 배치 대상 0건 · "단종" 판정(실제로는 도메인이 달랐다).
차단은 회차·로케일마다 무작위라 단발 조회로는 판정할 수 없다
같은 쿼리를 두 번 돌렸는데 1회차는 한 로케일만 성공(1.1MB)하고, 2회차는 그 로케일만 실패(2.2KB)하고 나머지가 성공했다. 로케일 고정 문제가 아니라 요청 단위 무작위다. 4개 로케일 0건으로 "소싱처 없음"을 판정한 것이 전부 재검증 대상이 됐다.
len 임계(50KB 미만이면 차단) + 핵심 마커(결과 항목 태그) 존재를 같이 검사한다메일 0건·크래시·미추출 — 수집기 전환에서 한 번에 밟은 것들
| 증상 | 원인 | 해법 |
|---|---|---|
| 주문번호 미추출 | 정규식 {6,20} — 어떤 거래처는 5자리 | ID 길이를 가정하지 않는다. {4,20} |
| 크래시 | 헤더 charset unknown-8bit → 디코더 예외 | utf-8 → cp949 → euc-kr → latin-1 폴백 체인 |
| 메일 0건 | SINCE 7일 · 최근 120통이 코드에 기본값으로 박힘 | 기본값은 환경변수로 뺀다 |
★ ID 길이·문자셋·조회 기간은 전부 상대 쪽의 사실이다. 내 코드에 박은 가정은 상대가 바뀌는 순간 0건이나 크래시로 나타나고, 0건은 정상처럼 보인다.
★★★ 0을 해석하려면 대조군이 필요하다 — 그리고 쓰기가 붙으면 판정에 세 번째 값을 둬라
배송비 견적 API가 한 국가에 대해 0개를 반환했다. "그 나라로는 못 보낸다"로 읽고 60건의 배송 경로를 바꿨다. 틀렸다 — 그 스토어는 결제 단계에서 배송비를 계산해서, 이 API로는 어떤 나라를 물어도 0개가 나온다.
★★★ 같은 호출을 다른 입력으로 한 번 더 해보는 것만으로 두 경우가 갈린다.
| 관측 | 해석 |
|---|---|
| 대상 국가만 0개, 다른 나라는 나옴 | 진짜 불가 |
| 모든 나라가 0개 | 이 스토어가 이 API를 안 쓴다 → 판정 불가 |
★★★ 0을 해석하려면 "0이 아닐 것으로 기대되는 입력"이 필요하다. 그게 대조군이다. 대조군 없이는 "내 질의가 틀렸다"와 "그 기능을 안 쓴다"가 똑같이 0으로 보인다.
★★★ 그래서 판정은 2값이 아니라 3값이다
True # 확인됨 (또는 과거 실적이 있음) → 실측값 사용
False # 대조군은 응답했고 이 대상만 0 → 진짜 불가
None # 대조군도 전부 0 → 판정 불가
★★★ 그리고 None의 행동은 "유보"가 아니라 "쓰기 금지"다. 기존값을 그대로 두고 아무것도 하지 않는다. 이 컬렉션의 다른 분류기에서 판정 불가는 재시도였지만, 저기는 읽기였고 여기는 기존 데이터를 덮어쓸 수 있는 자리다.
★★ 읽기 판정의 유보보다 쓰기 판정의 유보가 더 강해야 한다. 모르면 읽지 말라가 아니라, 모르면 고치지 말라다.
★★★ 이 컬렉션에는 "못 찾음을 없음으로 읽은" 사례가 이미 셋 있다 — 조회 실패를 품절로, 검색 차단을 0건으로, 레이트리밋을 고위험으로. 앞 셋은 오판에서 멈췄고, 이번엔 오판이 데이터를 바꿨다. ★ 판정 로직에 쓰기를 붙이기 전에 None 경로를 먼저 만들어라. 같은 버그라도 쓰기가 붙으면 대가가 전혀 다르다.
★★ 실적 기록은 어떤 정적 판정보다 강하다 — 점수에 더하지 말고 앞에서 가로채라
그 스토어에는 실제로 그 나라로 받은 배송 이력이 있었다. 추적 기록을 먼저 보고 있으면 API 판정을 건너뛰게 고쳤다.
★★★ 순서가 핵심이다. 같은 신호를 앞선 항목의 점수판에서는 +1점 한 항목으로 쓰고 있었는데, 가장 강한 근거를 가장 약한 근거와 같은 단위로 더하면 강함이 사라진다. 그 점수판에서 최고점을 받은 대상이 실제로는 거래가 불가능했던 것이 증거다.
★ 정적 분석은 "그럴 것이다"를 모으고, 실적은 "그랬다"를 말한다. 후자가 있으면 전자를 볼 이유가 없다 — 점수 항목이 아니라 override로 둬라.
⚠️ ★★★ 단, override가 차단 규칙을 덮으면 안 된다
그 분류기에는 차단 목록(과거에 실제 피해를 입힌 상대)이 감점 항목 중 하나로 들어 있었다. override가 "정적 판별을 건너뛴다"면 차단 목록도 건너뛴다.
★★★ 한 번 정상 거래한 뒤 사기로 전환한 상대를 그대로 통과시킨다. 실제 피해 사례는 "결제 후 미배송"이었고 — 그 전에 정상 배송된 주문이 하나라도 있었다면 오늘 최고 신뢰로 판정된다.
→ 우선순위를 명시적으로 고정하라.
① 차단 목록 — 무조건 거부
② 별도 축의 금지 — 점수와 무관 (권리 문제 등)
③ 실적 override — 여기서 비로소 건너뛴다
④ 정적 점수
★★ override는 "안전의 증거"이지 "차단 해제 권한"이 아니다. ★★★ 무엇을 건너뛰는지 명시하지 않은 skip은 차단까지 건너뛴다.
⚠️ ★★★ 그리고 우선순위를 센티넬 값으로 인코딩하면 상쇄된다
자연스러운 구현은 차단에 -99, override에 +99를 주는 것이다. 깔끔해 보이고, 가산 스코어러에서는 망가진다.
-99 + 99 = 0
★★★ 0은 중간 밴드다. 차단도 아니고 확증도 아닌 값이 나온다. 그리고 그 조합은 이론적인 게 아니다 — "차단 목록에 있고 과거 거래 이력도 있는 상대"가 정확히 그 경우고, 한 번 받아보고 나서 차단 목록에 올린 상대는 두 조건을 항상 동시에 만족한다. 가장 위험한 조합이 가장 애매한 점수를 받는다.
→ ★★★ 우선순위는 값으로 표현할 수 없다. 조기 반환으로 표현한다.
if blocked(x): return Verdict.BLOCKED # 즉시 반환
if prohibited(x):return Verdict.PROHIBITED # 즉시 반환
if has_record(x):return Verdict.CONFIRMED # 즉시 반환
return static_score(x)
★★ 센티넬 값은 "이 숫자는 다른 숫자와 더해지지 않는다"는 약속에 기대고 있다. 가산 스코어러에서 그 약속은 누군가 += 한 줄을 추가하는 순간 깨진다 — 그리고 그 사람은 센티넬을 모른다.
★ 값으로 둘 수밖에 없다면 더하기가 아니라 곱하기를 쓰라. 0은 무엇을 곱해도 0이고, 상쇄되지 않는다. ★ 더 나은 건 애초에 등급과 점수를 다른 타입으로 두는 것이다 — 차단은 Verdict, 신뢰도는 int. 같은 축에 올려놓으면 언젠가 더해진다.
⚠️ ★★★ 타입을 나누면 호출부가 전부 깨진다 — 그리고 그게 목적이다
score가 None일 수 있게 되는 순간 score < THRESHOLD 같은 비교가 TypeError를 낸다. 호출부를 하나씩 고쳐야 한다.
★★★ 깨지는 것이 이 변경의 성과다. 전에는 조용히 0을 반환해 중간 밴드로 흘러가던 것이, 이제 크래시로 드러난다. 시끄러운 실패가 조용한 실패보다 낫다 — 이 장의 첫 항목이 말하는 것의 정반대편이다.
⚠️ ★★★ 단, score or 0으로 막으면 상쇄 버그가 그대로 부활한다. None을 0으로 접는 순간 차단이 중간 밴드가 된다 — 방금 고친 바로 그 값이다. 급하게 TypeError를 없애려는 사람이 가장 먼저 손에 잡는 수가 그것이라 특히 위험하다.
verdict, score = check(x)
if score is None: # 기본값으로 메우지 않는다
return verdict # 등급이 곧 결론이다
★ or 기본값은 0·""·[]까지 같이 삼킨다. 판정값은 is None으로 검사한다. ★★ 크래시를 없애는 것과 원인을 고치는 것은 다른 작업이다 — 여기서는 전자가 후자를 정확히 되돌린다.
⚠️ ★★ 그리고 실적은 과거의 사실이고 현재의 보증이 아니다
★ 1년 전 성공 기록으로 오늘을 보증하지 마라. 실적의 최근성을 함께 보고, 오래된 것은 override에서 가점으로 강등한다.
★ override 값을 점수 체계 안의 큰 수(임계를 압도하는 99점 같은)로 두는 구현은 좋다 — 임계를 확실히 넘으면서도 차단 감점이 얹히면 내려간다. ①②를 앞세우면 이 설계가 안전해진다.
★★★ 또 하나 — 새 분류기는 과거에 비싸게 얻은 사실을 잃는다
None(판정 불가) 목록을 보니 과거에 이미 확정한 대상이 들어 있었다. 문서상으로는 "정책은 가능하다고 하는데 실제로는 그 나라가 목록에 없다"까지 확인하고 32건을 등록했다가 전량 중지한 곳이다. 그 비싼 사실이 새 분류기에서는 "모름"으로 되돌아왔다.
★★★ None은 "아직 안 본 것"이어야 하고, "전에 봤는데 잊은 것"이면 안 된다. None = 기존값 유지라 당장 피해는 없지만, None을 그대로 저장하면 과거의 실증이 덮인다.
★★ 수동 확정값을 분류기보다 위에 둬라 — manual_verdict 같은 필드 하나. 자동 판정이 아무리 정교해져도 사람이 실험으로 확인한 사실을 이길 수는 없다. 분류기를 새로 쓸 때마다 과거의 결론을 어디에 보관했는지 먼저 확인한다.
★★ 그리고 키를 맞추는 것이 매칭보다 먼저다
앞서 말한 이름/도메인 불일치는 각 저장소가 자기 맥락에서 옳은 키를 골랐기 때문에 생긴다 — 사람이 읽는 기록은 브랜드명을, 기계가 긁는 카탈로그는 도메인을 쓴다. 둘 다 맞는 선택이고, 문제는 둘을 잇는 순간 생긴다.
★★★ 별칭 표는 과거 데이터용 보정 수단이고, 미래 데이터의 해법이 아니다. 기록하는 쪽에 공통 키 필드를 추가해 저장 시점에 채워라. 그렇게 하지 않으면 매칭 정확도가 영구 부채로 남는다.
★★ 덤 — 두 저장소가 같은 대상을 다른 이름으로 부른다
주문 이력에는 브랜드명(현지어 표기 포함), 카탈로그에는 도메인으로 적혀 있었다. 문자열 매칭으로는 영원히 안 닿는다. 별칭 표를 정본으로 두고, 이름 첫 단어 매칭은 보조로만 쓴다 — ★ 순서를 뒤집으면 이 컬렉션의 이름으로 매칭하다 3연속 오매칭이 그대로 재현된다.
수집은 됐는데 집계가 "없음"이라고 한다
카탈로그를 수집해 놓고 브랜드 매핑 테이블에 안 넣으면 집계는 "카탈로그 없음"을 보고한다. 상위 25개 브랜드가 전부 ✘로 나왔는데 실제로는 여럿이 이미 카탈로그에 있었다. 수집과 매핑은 별개 작업이다. 수집 직후 매핑 테이블에 등록하는 것까지가 수집이다.