소셜 스키마 v4

⚠️ 이 문서는 schema-v5.html 로 대체됐다

v4 전수조사(메모 22건 + 추가 발견 7건)의 결과가 v5 에 반영돼 있다. 필드 구성·인덱스·정체성 축이 달라졌으므로 이 문서를 근거로 코드를 읽거나 고치지 말 것.

이 문서는 v4 시점의 기록으로 남긴다 — v5 의 각 판정이 "무엇을 바꾼 것인지" 를 확인할 때 참조한다. 판정 근거는 docs/features/social-recording-refactor/schema-refactor-plan.md 에 있다.

토큰 ↔ 소셜 연결의 확정 명세. v3 대비 링크 도착점 구조전 컬렉션 필드 판정이 바뀌었습니다. 미결은 웹 하나뿐이고 별도 작업으로 이관했습니다. 근거 문서는 §7 문서 지도에 전부 연결해 두었습니다.

v3 에서 무엇이 바뀌었나 — 한 문단

v3 는 엣지가 contents 만 가리키게 설계했습니다. 그 결과 토큰이 건 링크 중 Content 가 아니거나 fetch 에 실패한 것은 기록 자체가 남지 않았습니다 — audit 실측 7,742 URL 중 약 38.3% 가 종류 때문에, 그 위에 유료 게이트·IP 차단으로 상당수가 더 탈락합니다. v4 는 모든 URL 을 객체로 만들고, 엣지가 contents · accounts · venues 를 다형으로 가리키게 바꿉니다. 정체성은 문자열 비즈니스 키 대신 _id 대리키가 맡아, 나중에 정체가 밝혀져도 링크가 끊기지 않습니다.

§1ERD

이 절이 답하는 것 — 컬렉션 7개가 서로를 어떻게 가리키는가.

객체 3종 — 링크·원본·시계열이 가리키는 대상 accounts _id ● platform ● platform_key ○ handles[] source_urls[] account_created_at ○ bio known_wallets[] subtype 없음 platform 이 종류를 결정하므로 contents _id ● platform ● subtype ● platform_key ○ source_urls[] creator_id ○ ─→ accounts venue_id ○ ─→ venues parent_content_id ○ (자기) text · links[] · tags[] mentions[] · data subtype: tweet·video·post·repo· search·intent·shortlink·unknown·site venues _id ● platform ● subtype ● platform_key ○ source_urls[] creator_id ○ ─→ accounts name · description metrics.members ○ subtype 유지 — tg 가 channel· portal·shell 로 갈린다 parent_content_id object + object_id 3종 중 하나를 가리킴 content_raw object ● object_id ● payload ● observed_at ● 무손실 원본 · 1:1 upsert metric_series object ● object_id ● platform ● points[] { at, metrics } 대상당 1행 · 관측마다 $push sites 도메인 층 지문 (RDAP) 별도 작업으로 이관 — §6 잠정: 웹은 contents(platform=web) token_links object ● object_id ● source_url ● linked_at ● status ● lineage[] platform · subtype (복사) append-only 관측 로그 — 덮어쓰지 않는다 tokens address ● (unique) first_transfer_at ○ symbol ○ price·ath·alpha_score 는 두지 않는다 token_address
ERD 가 말하는 것 세 가지

첫째, 링크의 도착점이 3종입니다. v3 에서 contents 하나였던 것이 object + object_id 다형 포인터로 바뀌었습니다. 프로필·채널 링크가 이제 기록됩니다.

둘째, 위성 3종(content_raw · metric_series · token_links)이 같은 방식으로 객체를 가리킵니다. 참조 규칙이 하나라 새 객체 종류가 생겨도 위성은 바뀌지 않습니다.

셋째, accountssubtype 이 없습니다. platform 을 아는 순간 종류가 결정되기 때문입니다(gh→owner, rd→user). venues 는 Telegram 이 같은 URL 에서 channel·portal·shell 로 갈리므로 유지합니다.

§2판정 규칙 — 필드를 어디에 둘지

이 절이 답하는 것 — 새 필드가 생겼을 때 어디에 놓을지 어떻게 정하는가.

질문 1 · 이 필드로 쿼리하는가?  (filter · sort · join · group · index)
          아니오 →  개별(data)          예 → 질문 2
질문 2 · 값의 의미가 모든 subtype 에서 같은가?
          아니오 →  개별(data)          예 → 질문 3
질문 3 · 이 값의 원본(SoT)이 다른 곳에 있는가?
          예     →  최적화용            아니오 → 공용
구분정의계약
공용최상위 필드. 우리가 이름을 정하고 타입을 강제한다snake_case · 타입 명시 · 쿼리 축이면 인덱스
개별data 안. 플랫폼이 준 원래 이름 그대로이름을 바꾸지 않는다. 쿼리 대상이 아니다. 네임스페이스 없음platform 이 최상위 필드라 접두어가 중복
최적화용원본이 다른 곳에 있는 사본·파생값원본 위치·갱신 시점·사용 제한을 반드시 명시. 1차 필터 전용, 정밀 판단 금지

채움률은 기준이 아닙니다. account_created_at 은 Instagram 이 구조적으로 주지 않지만 "계정 나이는 rug 1순위 신호" 라 공용입니다. 반대로 채움률이 100% 여도 의미가 갈리면 개별입니다 — views 는 TikTok 이 자동재생, X 가 노출, YouTube 가 30초 시청이라 이름을 통일하면 오염됩니다.

이름 규칙

_id  로 끝나면  →  항상 ObjectId. 우리가 발급하고, 우리 DB 안의 다른 행을 가리킨다
_key 로 끝나면  →  항상 외부 문자열. 남이 발급하고, 우리가 만들지 않는다

_id · creator_id · venue_id · parent_content_id · object_id       ObjectId
platform_key   '2070140594496626759'   그 플랫폼 안에서 이 대상을 가리키는 값
url_key        'foo.com'               URL 에서 뽑은 식별자 (sites 용, 웹 작업)

예외 token_address — address 는 ObjectId 로 오인될 수 없어 이름을 그대로 둔다

두 종류를 섞으면 함수 시그니처가 (id: string) 일 때 둘 다 통과해서 조용히 틀립니다.

필수 여부 3단계

필수 ●   값이 없으면 저장 자체가 안 된다
기본값   필수지만 기본값이 있어 항상 존재한다 (배열 → [], 객체 → {})
선택 ○   없을 수 있다

"기본값" 을 따로 두는 이유는 인덱스 때문입니다. 필드가 항상 존재하면 partial index 를 만들 수 없습니다 — partialFilterExpression$ne$size 도 지원하지 않아 "빈 배열 제외" 를 표현할 방법이 없습니다.

§3전제

이 절이 답하는 것 — 이 스키마가 성립하기 위해 파이프라인이 지켜야 할 약속.

  1. URL 에 대응하는 객체는 관측 시점에 무조건 존재한다. unknown 도 객체로 저장한다.
  2. 객체는 그 시점에 확인 가능한 모든 것을 확인한 상태로 만들어진다. fetch 가 가능하면 fetch 해서 platform_key 까지 풀어 기록하고, 불가능하면 URL 로 알 수 있는 데까지만 채운다.
  3. 링크는 _id 로 연결한다. 어디에서(object) + 어떤 것(object_id).
  4. 각 객체는 자신을 유래시킨 URL 목록을 관리한다. source_urls[].
  5. 나중에 매핑이 바뀌면 재할당한다. 링크를 다시 끼운다.
  6. _id 참조는 우리가 관측한 대상에만 쓴다. 관측하지 않은 언급(mentions[])은 값 그대로 저장한다 — 그러지 않으면 트윗 3,879건의 멘션마다 빈 계정 행이 생긴다.

기존 행을 찾는 순서

1. source_urls.url 로 찾는다        이미 본 URL 이면 여기서 끝
2. 없으면 platform_key 로 찾는다    다른 URL 로 같은 대상을 본 경우
3. 그래도 없으면 새 행

Instagram 처럼 나중에 platform_key 가 shortcode → 숫자 pk 로 바뀌어도 1번이 먼저라 기존 행을 찾습니다.

설계로 풀 수 없는 한계 — 인정하고 간다

관측 시점에 해결하지 못한 매핑은 나중에 소급 확정하지 않습니다. t3 에 tiktok.com/@doge 를 봤는데 그때 fetch 를 못 했다면, 그 시점 소유자가 누구였는지는 어떤 스키마로도 복원되지 않습니다. 관측하지 않은 사실이기 때문입니다.

§4컬렉션 명세

이 절이 답하는 것 — 각 컬렉션이 어떤 필드를 갖고, 각 필드가 왜 그 자리에 있는가.

4.1 contents — 플랫폼 콘텐츠 공간의 좌표

트윗·영상만이 아니라 검색·미분류·단축링크까지 담습니다. TikTok 에서 유행하는 것은 search 로도 video 로도 표현되는데, 나누면 "이 소셜에서 무엇이 주목받나" 를 보려고 두 컬렉션을 확인해야 합니다. 그리고 unknown 은 모르는 사이트가 아니라 "아는 플랫폼인데 그 안 어디인지 모르는 것" 입니다 — 라우터가 모르는 호스트를 전부 website 로 떨어뜨리기 때문이고, 실측 398건이 전부 x.com(282)·tiktok.com(97)입니다.

필드타입필수구분설명
_idObjectId공용정체성. 링크·원본·시계열이 이 값을 가리킨다
platformenum공용x·tt·ig·yt·rd·gh + web(잠정, §6)
subtypeenum공용tweet·video·photo·post·reel·tv·story·repo·gist·search·intent·shortlink·unknown + site(잠정). 기존 form·productType 을 흡수한다
platform_keystring공용그 플랫폼 안에서 이 대상을 가리키는 값. 응답이 주면 응답 값, 없으면 URL 에서 뽑은 값. search 는 검색어
source_urls[]object[]공용{ url, status, observed_at }. 기존 행을 찾는 1차 수단
creator_idObjectId공용accounts. 검색·미분류·fetch 실패는 작성자가 없다
venue_idObjectId공용venues. X community · Reddit subreddit 만
parent_content_idObjectId공용→ 자기 참조. 계보 역추적 인덱스가 account_edges 컬렉션을 대체한다
parent_relationenum공용quote·retweet·fork. 참조하는 쪽의 속성 — 원본 트윗은 자기가 인용당했는지 모른다
published_atDate공용발행 시각. 저장 시각(created_at)과 반드시 구분
observed_atDate공용회귀 방지 가드
text.primarystring공용ig caption · yt/rd title · tt/x text · gh description. 검색어·해시태그도 여기 들어간다 — 그래서 모든 subtype 이 값을 가지므로 Q2 를 통과한다
text.bodystring공용yt description · rd body. GitHub README 는 당분간 채우지 않는다 — 별도 호출 1회가 드는데 비용 대비 가치가 미측정이다. 필드는 두고 값만 비운다
text.from_mediastring공용ig alt · tt transcriptionLink. 시각 콘텐츠의 유일한 무료 진입점
links[]string[]기본값공용본문 아웃바운드 링크. drainer·CA 축
tags[]string[]기본값공용해시태그 · GitHub topics
mentions[]object[]기본값공용{ platform_key, screen_name }. _id 참조 아님(전제 6)
author{}object최적화관측 시점 작성자 스냅샷. accounts의도된 중복 — 마스터는 덮이므로 "그때 팔로워 수" 를 잃는다
metrics_latestMixed최적화SoT 는 metric_series. 1차 필터 전용
link_stats{}object최적화{ link_count, first_linked_at, computed_at, input_cutoff }. computed_at 없이 읽으면 안 된다
dataMixed기본값개별플랫폼 원래 이름 유지. conversationId·musicId·pushedAt·moderation.*
created_at / updated_atDate공용BaseModel

제거content_key(정체성이 _id 로 이동) · source_type(platform+subtype 으로 분해) · source_key(source_urls[] 로 대체) · ext(data 로 개명, 네임스페이스 제거) · form · fetch_status(source_urls[].status 와 중복) · data.productType

4.2 accounts — 누가

필드타입필수구분설명
_idObjectId공용
platformenum공용subtype 없음 — platform 이 종류를 결정한다
platform_keystring공용x id · gh login. Reddit 은 username 문자열 — 불변·재사용 불가라 이름 자체가 id
source_urls[]object[]공용
handles[]object[]기본값공용{ value, first_seen, last_seen }. 구간이 핵심 — TikTok 은 핸들을 회전시키고 옛 핸들을 재할당한다
account_created_atDate공용rug 1순위 신호. 6개 중 5개가 준다. Instagram 만 구조적 부재
display_namestring공용사실상 x·yt
biostring공용지갑 주소가 실제로 여기 노출된다 — 실측 "… BNB: 0x0Ae024D3C20…"
links[]string[]기본값공용프로필 아웃바운드 링크
declared_handles[]object[]기본값공용{ platform, value }. 사실상 gh twitter_username
known_wallets[]string[]기본값공용온체인 ↔ 소셜 조인 축. bio 에서 추출한 파생 키 — 원본·파생 관계라 bio 로 대체 불가
metrics.followersnumber공용의미가 전 플랫폼 동일. contents.author.followers 의 원본
moderation.unavailableboolean공용계정 정지·삭제. "런치 후 계정이 사라졌다"
observed_atDate공용중첩 스냅샷은 부모 문서 값을 상속
dataMixed기본값개별following·totalKarma·heart·viewCount·stars·content_count·badges·account_type·source_ref
created_at / updated_atDate공용

제거subtype · moderation.unavailable_reason · account_key · ext. account_type(gh User/Organization)과 source_ref(프로필 직접 조회가 아님을 남기는 값)는 data 로 내렸다 — 조직 계정 여부가 신호인지 검정 기록이 없고, 신뢰도 정보는 쿼리 축이 아니다. metrics.content_countdata 로 내렸다 — Reddit 이 안 주고 세는 대상이 플랫폼마다 다르다(x statusesCount 는 리트윗 포함).

4.3 venues — 어디서

필드타입필수구분설명
_idObjectId공용
platformenum공용x(community) · rd(subreddit) · tg
subtypeenum공용community·subreddit·channel·portal·shell·guard_group. 유지하는 이유 — tg 가 같은 URL 에서 셋으로 갈린다(포털 54%·빈껍데기 25%·활성 4%)
platform_keystring공용x community id · rd slug(불변) · tg 는 groupId. 채널명은 mutable 이라 조인키로 부적합(이관 실측 2건)
source_urls[]object[]공용
namestring공용tg title. 가변이므로 as-of 판단에 쓸 때 주의
descriptionstring공용토큰 CA 가 실제로 여기 노출된다
venue_created_atDate공용"런치 직전 급조" 신호. Reddit 은 현재 수집 경로 없음
creator_idObjectId공용accounts. 핸들 fallback 금지 — 응답 키가 userName/screen_name 으로 갈린다
metrics.membersnumber공용tg subscriberCount · rd subscribers
observed_atDate공용
dataMixed기본값개별moderators · adminHandle
created_at / updated_atDate공용

제거channel_state(subtype 이 담당) · data.nameMatch(token_links 로 이동) · data.subredditSubscribers(metrics.members 와 중복) · venue_key · ext

4.4 token_links — 토큰 ↔ 객체 · append-only

이 컬렉션만의 제약

절대 덮어쓰지 않습니다. statusok → deleted 로 바뀐 시각이 신호이고, 그건 append 로만 얻어집니다. 리포지토리에 update/upsert 메서드를 아예 만들지 않아 타입 레벨에서 막습니다. contents.fetch_status 를 제거했으므로 상태 이력은 여기에만 남습니다.

필드타입필수구분설명
_idObjectId공용
token_addressstring공용mint 주소. ObjectId 를 쓰지 않는다 — 토큰은 재분류·병합이 없어 대리키가 할 일이 없다
objectenum공용contents·accounts·venues. $lookupfrom 이 상수여야 하므로 이 값으로 먼저 $match 한다
object_idObjectId공용{object_id, linked_at} 이 "이 객체를 건 토큰 수" 의 유일한 경로
source_urlstring공용재할당의 안전장치. object_id 는 바뀌어도 이 값은 안 바뀐다 — 그래서 재할당 이력 컬렉션이 따로 필요 없다
linked_atDate공용as-of 절단축. 덮어쓰면 순번이 복원 불가
observed_atDate공용
statusenum공용ok·deleted·unavailable·unresolved. 상태 이력의 SoT
name_matchboolean공용tg 채널명에 토큰명이 들어있나. title 이 가변이라 나중에 재계산 불가 → 관측 시점에 남긴다
platformenum최적화대상에서 복사. 플랫폼별 노출 집계를 이 컬렉션 1회 조회
subtypeenum최적화대상에서 복사. "tiktok search vs video 비교" 가 여기서 끝난다
first_transfer_atDate최적화Δt 계산 조인 0회. token_created_at 을 대체 — 입력 계약에 발행 시각은 없다
lineage[]ObjectId[]기본값최적화자기 + 조상, 길이 상한 2. 자기를 0번에 넣어 $or 를 없앤다
link_depthnumber최적화lineage.length − 1. 인자로 받지 않는다 — 둘이 어긋나면 에러 없이 틀린 수가 나온다
created_at / updated_atDate공용

제거content_key(유실 문제의 원인) · source_field(발행자가 칸을 구분해 쓰지 않는다 — website 칸에 Reddit 18·GitHub 15·TikTok 검색 6건. discovered 는 출처 불명, adhoc 은 실측 0건)

4.5 metric_series — 지표 시계열 · 대상당 1행

관측마다 새 행이 아니라 한 행의 배열에 쌓습니다. 속도 계산이 읽기 1회로 끝나고 배열이 이미 순서대로입니다. 한 포인트가 약 150 B 라 설계 상한인 200 포인트여도 30 KB 입니다.

필드타입필수구분설명
_idObjectId공용
object / object_idenum / ObjectId공용다형 확정. 콘텐츠뿐 아니라 계정 팔로워·베뉴 멤버 시계열까지 한 컬렉션이 덮는다 — B-3(팔로워 급증 Z-Score 선행 탐지)이 계정 시계열을 요구하고, 대상당 1행 구조라 추가 비용이 없다
platformenum최적화대상에서 복사. 코호트 필터
points[]object[]기본값공용관측마다 $push
points[].atDate공용관측 시각. rd·tt 는 actor 의 crawledAt — 저장 시각을 쓰면 배치 지연이 속도에 섞인다
points[].metricsMixed개별플랫폼 원래 이름. 쓰기 시점 키 화이트리스트 필요Mixed 는 검증하지 않아 오타 키가 조용히 저장된다
created_at / updated_atDate공용

감수한 것 — 플랫폼 코호트 백분위를 인덱스 range scan 으로 못 낸다($unwind 필요). 백분위는 원래 파생 레이어(metric_ranks)에서 주기적으로 계산할 값이다.

4.6 content_raw — 무손실 원본 · 1:1

필드타입필수구분설명
_idObjectId공용
object / object_idenum / ObjectId공용기존 object_key+object_type 을 대체
payloadMixed개별응답 전체. 기존 이름 data 에서 개명 — contents.data 와 겹쳤다. 원본 보존이 곧 재해석 가능성: audit 이 alt·isSlideshow·author.id 를 놓친 원인이 잘라 저장한 덤프였다
observed_atDate공용upsert 유지 — 최신 1건만. append 로 바꾸면 원본 이력이 쌓이지만 문서 하나가 수십 KB 라 관측 횟수만큼 곱해진다
created_at / updated_atDate공용

제거platform(키 단건 조회만 하는 컬렉션이라 필터 필드가 필요 없다) · object_key · object_type. 컬렉션 이름이 내용과 어긋난다 — content_raw 인데 content·creator·venue 셋을 담는다. object_raw 로 개명할지는 §6.

4.7 tokens — 얇은 상태

필드타입필수구분설명
_idObjectId공용참조는 address 로 한다
addressstring공용mint 주소 unique. token_links.token_address 의 조인 대상
symbolstring최적화SoT 는 tracker. 표시와 name_match 계산 입력
first_transfer_atDate최적화나이 앵커. FetchOptions.launchAt 주석이 "age anchor (= firstTransferAt)"
discovered_atDate공용수집·백필 배치의 진입점
fingerprints[]string[]미결웹 작업까지 현행 유지. domain:X 가 도메인 공유 카운트의 축이다 — §6 잠정 규칙
web{}object미결웹 작업까지 현행 유지. §6
created_at / updated_atDate공용

제거trigger_reason · unclassified_links[](모든 URL 이 객체가 되므로 역할 소멸 — 이 필드의 존재 자체가 "비객체를 담을 곳이 없다" 는 증상이었다) · price·ath_price·alpha_score(계속 변하므로 사본은 쓰는 순간 낡았고 백테스트를 오염시킨다)

§5인덱스 초안

이 절이 답하는 것 — 확정된 쿼리 축에서 직접 따라 나오는 인덱스.

컬렉션인덱스답하는 질문
contents{ platform, subtype }"이 플랫폼에서 무엇이 주목받나". draft 의 핵심 요구
{ source_urls.url } multikey unique기존 행 찾기 1차. URL 중복 방지
{ platform, subtype, platform_key }기존 행 찾기 2차
{ creator_id, published_at −1 }"이 KOL 의 최신 콘텐츠" — 연쇄 배포 탐지
{ parent_content_id } partial계보 역추적. 대부분 null
{ venue_id } partial커뮤니티별 콘텐츠. 두 플랫폼만 값이 있다
accounts{ source_urls.url } multikey unique기존 행 찾기
{ platform, platform_key } unique대상 동일성
{ handles.value } multikey핸들 → 계정 역조회. 반드시 구간으로 as-of 해석
{ known_wallets } multikey지갑 → 계정. partial 불가 — 기본값 []라 항상 존재
venues{ platform, subtype, platform_key }대상 동일성
{ creator_id } partial"이 계정이 반복 개설한 커뮤니티"
token_links{ object_id, linked_at } 주 경로"이 객체를 건 토큰 수" as-of
{ token_address, linked_at }"이 토큰의 모든 소셜"
{ lineage, linked_at } multikey"실질 재사용 횟수". lineage 가 유일한 배열이라 복합 인덱스 합법
{ platform, subtype, linked_at }플랫폼별 노출 집계. 비정규화 복사본 덕에 조인 0회
metric_series{ object, object_id } unique대상당 1행 보장. 다형이라 object 를 앞에 둔다
{ platform }코호트 필터
content_raw{ object, object_id } unique — 이것만문서가 수십 KB 라 목록 조회를 만들면 캐시가 오염된다
tokens{ address } unique조인 대상
{ fingerprints } multikey도메인 공유 카운트. 웹 작업까지 유지
{ discovered_at −1 }최근 유입 토큰

§6미결 · 선행 과제

이 절이 답하는 것 — 아직 정하지 않은 것과, 구현 전에 고쳐야 할 코드.

6.1 웹 — 별도 작업으로 이관

잠정 규칙
1. 웹 URL 도 contents 에 넣는다.   platform='web' · subtype='site'
2. tokens.web{} · fingerprints[] 는 현행 그대로 둔다
3. contents(web) 에 도메인 층 필드를 만들지 않는다 (site_id 도 아직 없음)

2번이 핵심입니다. 잠정 기간에도 도메인 공유 카운트가 기존 경로로 계속 작동합니다 — 검증된 주력 신호(knowyourmeme.com 22건 rug 100% · axiom.trade 21건 90% · orynth.dev 18건 0%)가 끊기지 않습니다.

왜 이 순서가 안전한가. "귀속" 을 기본값으로 두면 나중의 "분리" 가 전부 덧붙이기입니다 — sites 를 만들고 contents(web) 행에 site_id 를 채우는 백필이면 끝이고 링크는 손대지 않습니다. 반대 순서는 파괴적입니다.

남는 근거. 웹은 지문의 출처부터 두 층입니다 — RDAP(도메인: domainCreatedDate 81.7%)와 HTML/OG·urlscan(페이지: isNews 76.0%, rug 80/89/100%). 도메인으로만 접으면 페이지 층이 뭉개지고 페이지로만 두면 도메인 지문이 복사됩니다. 확정하지 못한 이유는 website URL 중 apex 만인 비율을 모르기 때문이고, audit 샘플에 website 파일이 없어 지금은 측정할 수 없습니다.

6.2 판정 세션 이후 확정한 6건

필드 판정 문서(§7)를 얼린 뒤 남아 있던 미결입니다. 이 문서가 최신이고, 판정 문서에는 "보류" 로 남아 있습니다.

항목확정근거
contents.text.primary공용검색어·해시태그도 여기 들어가므로 모든 subtype 이 값을 갖는다 → Q2 통과
contents.text.body공용필드는 두되 GitHub README 는 당분간 채우지 않는다 — 별도 호출 1회 대비 가치가 미측정
accounts.account_type개별조직 계정 여부가 신호인지 검정 기록이 없다
accounts.source_ref{}개별값의 신뢰도 정보이지 쿼리 축이 아니다
metric_series.object공용 · 다형B-3 이 계정 시계열을 요구한다. 대상당 1행 구조라 추가 비용이 없다
content_raw.platform제거키 단건 조회만 하는 컬렉션이라 필터 필드가 불필요. upsert 도 유지 — append 는 수십 KB × 관측 횟수

6.3 선행 과제 — 구현 전에 고쳐야 하는 코드

막고 있는 것 — v4 구현 전 필수

normalize() 가 실제로 정규화하지 않는다. social-fetcher.router.ts:110 의 주석은 "소문자 host · www 제거 · trailing 정리" 인데, host 변수만 처리하고 반환값 urlu.toString() 원본입니다. v4 는 source_urls.urlunique 인덱스를 걸고 이를 "기존 행 찾기 1차 수단" 으로 쓰므로, www.foo.com/promofoo.com/promo 가 다른 문자열이면 같은 대상이 두 행이 됩니다. 함수 하나 수정이지만 v4 의 중복 방지 전제가 여기 걸려 있습니다.

v4 때문에 새로 생긴 것

Reddit 의 /r/{sub}/search?q=X 가 아직 subreddit(venue) 으로 분류됩니다 (reddit.fetcher.ts:66/r/<sub>[/new|/search 등] 이 venue 로 떨어짐). v4 에서 subtype: searchcontents 에 생겼으므로, 이제 검색어를 키로 하는 contents(search) 로 가야 맞습니다. 지금은 ?q=wendys?q=wendys+dumpster 가 같은 venue 행으로 접힙니다.

위치시점내용
router.ts normalize()필수반환값 url 에 www 제거·추적 파라미터 정리·trailing 정리가 반영되지 않는다
reddit.fetcher.ts classify()필수/r/{sub}/search?q= 를 venue 가 아니라 search 로 분류해야 한다
fetcher mentions[]필수{id, screenName} 쌍으로 채워야 한다. screenName 만 담으면 같은 KOL 이 인용 경로와 멘션 경로에서 다른 키로 잡힌다
METRIC_SPEC필수쓰기 시점 키 화이트리스트. Mixed 는 검증하지 않아 오타 키(playCont)가 조용히 저장된다
router.ts apexDomain()웹 작업마지막 두 라벨만 봐서 shop.foo.co.ukco.uk. Public Suffix List 필요
reddit.fetcher.ts catch-all별건코드 주석이 스스로 경고한다 — reddit.com/settings 가 가짜 subreddit 키가 되고 Reddit 은 단가가 가장 높은데다 이 키는 subreddit 이라 그 1건이 $0.04(글 URL 은 $0.022) 다. 예약어 목록을 둘지는 관측 집계가 없어 미정
이미 고쳐진 것 — 기록으로 남긴다

audit 샘플에서 발견했던 classifier 오접기 2건은 현재 코드에서 이미 수정되어 있습니다. 샘플이 옛 구현 시점의 데이터라 샘플만 보면 여전히 버그로 보입니다.

instagram  /stories/{handle}/{storyId}   sourceKey = segments[2]      storyId 로 수정됨
reddit     /r/{sub}/s/{shareId}          sourceType = reddit_share    share 로 분리됨

교훈samples/ 는 관측 시점 스냅샷이지 현재 동작이 아닙니다. 코드 판정은 코드로 확인해야 합니다.

6.4 이 설계로도 풀리지 않는 것

§7문서 지도

이 절이 답하는 것 — 어느 판단이 어느 문서에 근거를 두고 있는가.

링크 도착점 모델 비교
문제 정의와 후보 4안 비교. 엣지케이스 10건 × 4안 매트릭스, 단계 재생 3케이스.
"왜 다른 안을 택하지 않았나"
→ 열기
URL ↔ 객체 ↔ 링크 구조
구조 결정 기록. 전제 6개, 판단 기록 3개(urls 미채택 · source_field 제거 · 배열 아닌 별도 컬렉션), 쿼리 적합성 15경로.
"왜 이 구조인가"
→ 열기
필드 판정 — contents
후보 38개. 판정 기준 3질문을 필드마다 적용한 근거.
→ 열기
필드 판정 — accounts
후보 29개. subtype 제거 근거, known_wallets 유지 근거.
→ 열기
필드 판정 — venues
후보 20개. tg 키를 groupId 로 정한 근거.
→ 열기
필드 판정 — token_links
후보 17개. token_address 문자열 유지, name_match 이관 근거.
→ 열기
필드 판정 — 나머지 3개
metric_series · content_raw · tokens. 후보 27개.
→ 열기
v3 (상위 스펙)
URL 구조 audit 산출물. 이 문서가 대체한다 — 특히 §⑬(조인 키는 응답 유래)와 §1371(website 는 4번째 객체가 아니다)은 v4 에서 판단이 바뀌었다.
→ 열기
쿼리 경로 15개
요구사항 목록. 스키마 제안은 무시 — 양방향 배열 구조라 v4 가 채택하지 않았다.
→ 열기
website 필드 인벤토리
웹 지문 실측(fetch ok 1,886건 / 토큰 4,870개). 웹 별도 작업의 출발점.
→ 열기
Telegram 가드 재사용
표본 69개 라이브 조사. username 이 조인키로 부적합하다는 근거.
→ 열기
기존 구현 설계
v3 기반 7컬렉션 설계와 모델·리포지토리 규약. §1(DB 스키마)은 이 문서로 대체되고, §3(클래스 설계)·§6(제외 항목)은 유효하다.
→ 열기

이 문서가 다루지 않는 것 — 모델·리포지토리 클래스 설계(기존 be-system-design.md §3 이 유효), 마이그레이션(실데이터가 없어 해당 없음), 파생 계층(metric_ranks·엔티티 해석·지문 클러스터).