제품 카탈로그 사이트를 하나 돌리고 있습니다. 주간 봇이 공개 리스팅에서 제품을 모아오고, 정적 사이트로 692페이지를 빌드해 배포합니다. 배포가 2주 동안 안 돌았던 이야기는 지난번에 썼습니다. 그건 고쳤습니다.
이번엔 그 사이트의 데이터 상태를 점검하려고 열었습니다. 제품 474개, 가격 결측 0, 점수 결측 0. 이미지 커버리지 97%. 표로 보면 건강한 카탈로그였습니다.
질문 하나 드리겠습니다. 당신의 사이트가 사용자에게 보여주는 숫자는, 당신이 그 숫자를 설명해 둔 방식대로 계산되고 있습니까? 저는 확인해 본 적이 없었습니다.
사이트는 점수를 팔고 있었습니다
제품마다 100점 만점 배지가 붙어 있었습니다. 배지에 마우스를 올리면 이런 설명이 떴습니다.
검색 수요, 성분 관련성, 비교 유용성을 섞은 신호. 실험실 평가는 아닙니다.홈에는 이 설명에 섹션 하나를 통째로 할애해 뒀습니다. "01 검색 수요 — 사람들이 실제로 검색하는 제품에 가중치를 줍니다." 그럴듯했습니다. 제가 그렇게 써 뒀으니까요.
그래서 그 가중치를 어디서 계산하는지 보려고 점수 함수를 열었습니다.
let score = 76 + brandBoost;
if (category === "sunscreen") score += 1;
if (ingredients.some(...)) score += 1;
if (concerns.includes(...)) score += 1;
return Math.max(72, Math.min(88, score));전부입니다. 76에서 시작해 브랜드 가산점을 얹고, 조건 세 개에 각각 +1, 그리고 72~88로 자릅니다.
검색 수요를 읽는 줄이 없습니다. 검색 데이터에 접근하는 코드 자체가 이 파일에 없습니다. 저는 존재하지 않는 입력을 사이트 홈에 방법론으로 적어놓고 몇 주를 방치했습니다.
분포를 세어보니 더 나빴습니다
방법론이 틀린 것과 별개로, 그 숫자가 정보를 담고 있는지 확인해 봤습니다. 한 줄이면 됩니다.
collections.Counter(round(p['score']) for p in products)결과는 이랬습니다.
88점: 204개 (43%)
87점: 85개
89점: 73개
83점: 45개
80점 미만: 0개474개 제품이 80~92 사이에 몰려 있고, 그중 43%가 정확히 같은 점수입니다. 100점 만점이라고 써 놓은 지표의 실사용 폭이 12점이고, 최빈값 하나가 전체의 절반에 가깝습니다.
이런 숫자로는 두 제품 중 무엇을 살지 고를 수 없습니다. 구간을 만들지 못하는 지표는 판단에 못 씁니다.
여기서 당신이라면 어떻게 하시겠습니까
선택지가 셋 있었습니다.
- 점수를 제대로 만든다. 검색 수요 데이터를 붙이고, 가중치를 튜닝해서 설명한 대로 동작하게 한다.
- 설명을 코드에 맞춘다. "브랜드와 카테고리 태그에서 뽑은 휴리스틱"이라고 정직하게 다시 쓰고 숫자는 남긴다.
- 점수를 지운다.
1번을 오래 생각했습니다. 그런데 그 사이트의 실측 유입은 이랬습니다.
Search Console 28일: 노출 3, 클릭 0방문자가 없습니다. 점수를 정교하게 만들어서 좋아지는 사람이 아직 없다는 뜻입니다. 2번도 잠깐 유혹적이었는데, 라벨만 정직해지고 형태는 그대로 남습니다. 43%가 88점인 100점 만점 배지는 라벨을 어떻게 바꿔도 여전히 "평가받은 제품"처럼 보입니다.
그래서 3번을 골랐습니다. 정보량이 0인 지표는 고칠 대상이 아니라 지울 대상입니다.
배지 자리에는 카탈로그가 실제로 가진 데이터를 넣었습니다. 대표 가격입니다. ~$18. 홈의 "점수는 이렇게 계산됩니다" 섹션은 사이트가 실제로 하는 일 세 가지로 바꿔 썼습니다. 공개 리스팅 데이터, 성분 태그, 리테일러 검색 링크. 점수 필드 자체는 내부 정렬 키로만 남겼습니다.
덤으로 하나 더 드러났습니다. 성분 목록이 빈 제품이 474개 중 **99개(21%)**였고, 그 제품들은 상세표에 "성분 테마" 행이 텅 빈 채로 렌더되고 있었습니다. 성분 비교를 내세운 사이트에서 5분의 1이 성분을 모릅니다. 이제 그 자리엔 "리테일러 리스팅에 공개되지 않았습니다"라고 적힙니다. 없는 걸 없다고 쓰는 게 빈 칸보다 낫습니다.
지운 것은 되살아납니다. 그래서 가드를 걸었습니다
카피는 사람이 다시 씁니다. 몇 달 뒤에 저는 이 결정을 기억하지 못할 겁니다. 그래서 지우는 커밋에 검사를 같이 넣었습니다. 링크 체커가 이미 빌드 산출물 전체를 돌고 있었으니, 거기에 네 줄을 얹었습니다.
const bannedClaims = [/Radar score/i, /blended signal/i, /aggregateRating/, /ratingValue/];
// dist의 HTML 693개 중 하나라도 매치되면 exit 1가드는 통과하는 걸 확인하는 것만으로는 부족합니다. 일부러 깨 봐야 합니다.
$ printf '<html>Radar score 88/100</html>' > dist/_guardtest.html
$ node scripts/check-links.mjs
Rating claims are not allowed (1 files):
- _guardtest.html matches /Radar score/i
exit=1이제 이 문구가 어떤 경로로든 다시 발행되면 CI가 막습니다. 프롬프트로 금지한 규칙이 지켜지지 않았던 일을 겪은 뒤로, 검사 가능한 규칙은 문장이 아니라 코드로 남깁니다.
확인해서 다행이었던 것 하나: 구조화 데이터에는 aggregateRating을 넣지 않고 있었습니다. 시각적으로만 가짜 평점이었고, 검색엔진에 평점 마크업으로 제출하고 있지는 않았습니다. 그건 운이었습니다. 설계가 아니었습니다.
자가진단 3개
지금 당신 프로젝트에서 확인할 수 있는 것들입니다.
- 점수·등급·퍼센트를 발행하고 있다면, 그 옆에 적힌 설명의 입력값 하나하나에 대응하는 코드 경로를 짚어 보세요. 없는 입력이 하나라도 있으면 그건 거짓 주장입니다.
- 분포를 세어 보세요. 최빈값이 전체의 30%를 넘거나 실사용 폭이 명목 범위의 20% 미만이면, 그 지표는 아무것도 구분하지 못합니다.
- 비어 있는 필드가 어떻게 렌더되는지 실제 페이지에서 보세요. 빈 배열이 빈 칸으로 나가고 있다면, 사용자는 그걸 "정보 없음"이 아니라 "고장"으로 읽습니다.
솔직한 부분
이 사이트는 클릭이 0입니다. 그래서 이 수정으로 늘어난 매출은 0원이고, 앞으로도 당분간 0원입니다. 트래픽이 없는 자산에 시간을 쓴 것 맞습니다.
그래도 이건 고쳐야 했습니다. 트래픽 0은 "거짓 주장을 발행해도 된다"는 뜻이 아니라 "아직 아무도 그 거짓 주장에 속지 않았다"는 뜻이기 때문입니다. 그리고 같은 결함이 제 다른 앱에도 있을 수 있습니다. 점수와 궁합 퍼센트를 뱉는 앱을 여러 개 돌리고 있으니까요. 그건 다음 점검 목록에 올렸습니다.
같은 날 같이 정리한 것: 이미지 후보 소스 세 개 중 두 개가 15/15 전건 실패(404, 403)인데 워크플로는 계속 success였습니다. 고치지 않고 삭제했습니다. 그리고 클릭 0인 사이트를 주간으로 갈던 크론 두 개를 월간으로 내리고, 판정일을 하나 박았습니다. 10월 11일에 클릭이 여전히 0이면 스케줄을 떼고 정적 동결합니다. 날짜를 안 박으면 좀비가 됩니다.
지금 딱 하나만 해보시길 권합니다. 당신 서비스가 사용자에게 보여주는 숫자 하나를 골라서, 그 숫자를 설명하는 문구와 그 숫자를 만드는 함수를 나란히 띄워 보세요. 두 개가 같은 이야기를 하고 있습니까?