소셜 스키마 v3 — URL 구조 audit 반영

6개 소셜 수기 audit(2026-07-23~29) 결과를 스키마에 반영 · 2026-07-29 · 이전: schema-structure-v2.html · 근거: social-url-structure-audit
관련 문서
분류문서역할
계보 schema-structure.md v1 골든 트라이앵글(tokens↔contents↔accounts) 최초 정의 · §5 시그널 로직
schema-structure-v2.html v2 계층 분리(관측로그/마스터/파생) · content_token_links 신설 · 순번 미저장 원칙
근거 social-url-structure-audit 7개 소셜 URL 구조 감사 — 조인키·필드 decision 의 출처
available-queries.md Category A~E 14경로 — 이 스키마가 답해야 할 질문 목록
content-reaction-observation.html t_c / t_0 / T 시간축 · pull↔push 수집 모델 논의(§⑤ 근거)
정밀 대조 idea-schema-matching.html 아이디어 111개 ↔ 스키마 전수 대조(93행). S+F / S only / F only / 없음 / 불확실 5분류 — "필드가 없는 게 아니라 enum 이 값을 거부"를 밝혀낸 문서
structural-idea-matching.html 구조 축 88개 대조(횡단면·엔티티·네트워크). AF 는 쿼리 시점 조인 전제로 소셜 입력만 대조
문제 정의 problem-definition.md 무엇을 푸는가 — 소셜 연결 비용이 0 이라 "연결됐다"보다 "선택됐다"가 신호
ideation-log.md 제품 당위성·형태 논의 로그(1차)
research-findings.html 외부 조사 4갈래 종합 — 검증 대기열이지 채택 목록이 아님
검증 schema-v3-fitness-review.html 쿼리 31개 전수 대조 + 모델링 점검 — 이 문서의 결함 목록
information-inventory.html 스키마 ↔ 실제 수집 현황 매핑(F only / S only 갭)
코드 반영 REFINEMENT-WORKFLOW.md 전체 TODO · 소셜별 audit 반영 절차
HANDOFF-manual-tasks.html 수동 작업(API 키·유료 검증) · fetcher 검증 방법
⚠ 대상 — 이 스키마는 별도 repository 용이다

현재 repo(sol-alpha-finder-tracker)의 social-fetcher·라우터는 구현 편의상 먼저 만든 POC 이고 최종적으로 신규 repo 로 이식된다. 이 문서의 컬렉션·필드는 신규 repo 기준이다.

항목신규 repo 전제 (확정)
스택·골격동일 — NestJS + typegoose + BaseModel 스켈레톤, MongoDB 7.0. 따라서 §⑧ 의 time-series 미채택 근거가 그대로 유효
토큰 정보현 repo 로부터 받아서 기록. 가격·OHLCV·AF 는 소유하지 않고 현 repo 서버 호출
수집 트리거현 repo 가 보내준다(AF 임계 돌파 등) — 신규 repo 가 직접 감지하지 않음

그래서 tokens 는 현 repo 의 Token(OHLCV·ath_price·alpha_score·차트패턴)과 다른 것이다 — 신규 repo 에서는 얇은 상태 테이블이다(§④).

이 문서의 성격 — v2를 뒤엎지 않는다

v2는 아이디어 181개 매칭에서 구조를 먼저 세운 문서다. 이후 6개 소셜을 실제 API 응답으로 감사한 결과, v2의 핵심 결정 4개가 전부 실측으로 검증됐다. v3는 그 위에 (a) 검증 결과 기록 (b) audit이 드러낸 갭 3개 보완 (c) 소셜별 차이를 담는 규칙을 추가한다.

v2에서 유지되는 부분(계층 분리 원칙, content_token_links, 순번 미저장, 파생값 computedAt·inputCutoff)은 반복하지 않는다.

① v2 검증 결과 audit이 사후 확인해준 것

v2 결정audit 실측판정
accounts._id = platform:accountId
"핸들은 변경·재선점 가능해 같은 _id가 다른 주체를 가리킬 수 있다"
6개 중 5개가 숫자 id로 수렴. TikTok은 30일마다 변경 + 옛 핸들 타인 재할당, GitHub은 변경 + 재할당 + 리다이렉트마저 새 주인의 동명 repo에 덮어써져 깨짐 검증됨
username은 이력 배열로 내린다 필요성 확인 + 보강 필요: 재할당 때문에 handle→id시점 종속. 이력에 기간이 없으면 과거 관측이 새 주인에게 잘못 붙음 보강
genealogy.parentContentId
"스키마는 이미 지탱. fetcher가 인용 원본 id를 안 담는 게 병목"
병목이 아님을 실측 증명 — X quoted_tweet의 키 집합이 최상위 트윗과 100% 동일하고 quoted_tweet.author도 완전한 프로필. 추가 fetch 0회로 계보 전개 가능 검증됨
observedAt · fetchStatus · absenceReason 전 소셜에서 필요 확인 + 보강 필요: 같은 문서 안에서도 필드 그룹별 관측 시각이 다름(§⑪) 보강
예외 1건 — Reddit

Reddit username은 변경 불가(대소문자조차)이고 계정 삭제 후에도 재사용 불가다. 즉 이름 자체가 불변 id다. platform:accountId에서 accountId가 숫자가 아닌 문자열인 케이스를 허용해야 한다.

② 갭 3개와 확정 v2에 없던 것

왜 문제인가확정
Venue 계층 부재 v2 컬렉션은 contents/links/tokens/accounts뿐. 그런데 audit에서 계정도 콘텐츠도 아닌 장소가 3곳 나옴 — X community, Reddit subreddit, Telegram 채널. X는 communityInfo로 Content↔Venue 링크까지 확인됨 신규 venues 컬렉션 (§⑩)
"무엇을 최상위로 올릴지"의 규칙 부재 v2는 "raw는 data에 보존, 규격화 메타만 최상위 추출"까지만 정하고 목록을 비워뒀음. audit의 decision=key/keep이 정확히 그 목록 3층 + 공통 의미축 (§⑥⑦⑨) + 저장 매핑 §⑬
sourceType enum 불일치 v2가 5→14로 늘렸으나 audit 최종 타입과 다름. 신규 분리·병합이 여럿 생김 enum 재정의 (§⑫) 코드 반영 완료

③ 컬렉션 전체도 Tier 1 확정 반영

관측 로그 · append-only — 소급 불가 content_token_links linkedAt · status · observedAt ContentMetricSnapshot metrics(polymorphic) · observed_at content_raw _id 만 — 보조 인덱스 없음 마스터 · 재생성 가능 accounts Creator _id = platform:id handles[] 는 이력(as-of) contents Content core · ext · author{} fetchStatus = 최신값 캐시 venues Venue · 신규 _id = platform:venueId tokens 기존 _id = mint creatorId venueKey contentKey ↔ tokenAddress 파생 · computedAt + inputCutoff — 언제든 버리고 재생성 metricRanks(소셜 내 백분위) · entities[] · fingerprint 군집 · 순번 · 코호트 순번(ordinal)은 저장하지 않는다 — 질의 시점에 linkedAt ≤ T count 로 파생 미해결 — telegram Venue · Venue(subreddit) 조달 (website 는 tokens.web{} 로 해소)
보라 = 관측 로그(소급 불가) · 파랑 = 마스터 · 점선 = 파생/미해결. 조인은 전부 숫자 id(Reddit 만 이름)

④ ERD 엔티티 · PK/FK · 카디널리티

tokens · 얇은 상태 PK _id = mint createdAt(발행) ← as-of 절단축 symbol · discoveredAt · triggerReason fingerprints[] "kind:value" 평면배열 unclassifiedLinks[] ← 분류실패 URL 원문 가격·AF 없음 — 현 repo 호출 tokens.web{} · 임베드 엔티티 아님 — 작성자·발행시각·참여지표 없음 domain ← 공유 카운트의 키 domainCreatedAt ← 주력(731일+) domainExpiryAt ← 등록기간 파생 certValidFrom ← 재사용 도메인 탐지 scanTime ← urlscan 계열 as-of 앵커 scanUuid ← 미저장분 복구 경로 isNews ← rug 80/89/100% ogTitle · ogSiteName ← 토큰명 대조 server ← SPA 판별 · ip · asn registrar · certIssuer ← 재검정 생존 techCount ← 배열을 int 로 hasTracking · hasTokenTool ← bool 로 검증된 신호만 검증된 형태로 · 66B/토큰 content_token_links PK (contentKey, tokenAddress, linkedAt) FK contentKey → contents FK tokenAddress → tokens linkedAt ← as-of 절단축 status · sourceField · observedAt tokenCreatedAt ← 불변만 비정규화 lineage[] = 자기 + 조상 (깊이2) linkDepth 0=직접 · 1~2=인용 경유 accounts · Creator PK _id = platform:accountId handles[] {value, firstSeen, lastSeen} createdAt · bio · links[] declaredHandles[] · metrics{} knownWallets[] ← AF 조인 observedAt · sourceRef{via,depth} contents · Content PK _id = platform:contentId FK creatorId → accounts FK venueKey → venues (nullable) FK parentContentId → self (nullable) core{publishedAt,text,links,tags, mentions,form,moderation} author{} ← 관측시점 스냅샷 ext.<코드>{} · metricsLatest{} sourceKey(관측) · fetchStatus(캐시) linkStats{linkCount, firstLinkedAt} ← 파생 venues · Venue PK _id = platform:venueId FK creatorId → accounts name · createdAt · description metrics{members, moderators} X community · reddit subreddit · TG ContentMetricSnapshot PK (content_key, observed_at) FK content_key → contents platform metrics{} ← polymorphic append-only · 이름 통일 안 함 content_raw PK _id = contentKey (1:1) data{} ← 응답 원문 무손실 보조 인덱스 없음 — id 로만 조회 N 1 N : 1 creatorId N : 1 N : 1 자기참조 계보(quote/fork) N : 1 1 : 1 아직 엔티티가 없는 것 website ✓ 해소 — 엔티티가 아니라 tokens.web{} 속성 telegram Venue — audit 미실시 Venue(subreddit) 조달 — actor 가 post 반환 파생층(metricRanks·entities)은 재생성 가능해 ERD 제외 보라 = 관측 로그(append-only, 소급 불가) · 파랑 = 마스터(재생성 가능) · 점선 = 자기참조/미해결 FK 는 물리 제약이 아니라 애플리케이션 레벨 규약 — MongoDB 는 강제하지 않는다 모든 _id 는 platform:id 접두 형태 — 소셜이 달라도 키 공간이 충돌하지 않게
조인은 전부 숫자 id(Reddit 만 이름 — 변경 불가라 안전). handle 은 handles[] 이력으로 내려가 as-of 해석에만 쓴다

카디널리티 요약

관계카디널리티비고
contentsaccountsN : 1한 계정이 여러 콘텐츠. creatorIdfetch 응답에서 나온다(추가 쿼리 없음)
contentsvenuesN : 1 nullableX community · reddit subreddit 만. 나머지 소셜은 null
contentscontentsN : 1 자기참조계보 — X quote/retweet · GitHub fork. 깊이 2 제한
venuesaccountsN : 1개설자. 응답 키가 불안정해(userName/screen_name) id 로만 조인
content_token_linkscontents·tokensN : 1 · N : 1
= M:N 연결 테이블
엣지 컬렉션 — 배열이 아닌 이유는 원소에 시각(linkedAt)이 필요해서.
contents 쪽 N:1 의 주 원인은 여러 토큰이 같은 콘텐츠를 재사용(quote 때문이 아님) + append-only 재관측
ContentMetricSnapshotcontentsN : 1append-only. 콘텐츠당 100~200 포인트 예상
content_rawcontents1 : 1_id 공유. 분리 이유는 문서 단위 I/O(§⑥ L3)

왜 배열이 아니라 엣지인가 토큰당 링크 3~8개인데도

토큰 하나에 소셜 링크가 여러 개 생긴다. tokens.socialLinks[] 배열로 임베딩하는 안과 엣지 컬렉션 안을 비교하면:

A. 배열 임베딩B. 엣지 컬렉션 채택
토큰 → 소셜 전부조인 0 문서 1건1홉 {tokenAddress} 인덱스
콘텐츠 → 토큰들multikey 스캔{contentKey} 인덱스
as-of 절단(linkedAt ≤ T)인덱스로 못 자름range scan
재관측 이력덮어쓰거나 무한 성장append-only
문서 수토큰 수토큰 × 링크 ≈ 5배 (하루 1~2만)
결정적 지점 — 핵심 질문이 콘텐츠 기준이다

Q1(이 콘텐츠를 쓴 몇 번째 토큰인가) · Q2(이 콘텐츠에 지금 몇 개 경쟁 중인가) · content-reuse 연구(as-of 카피 2nd+ 는 ≥1M 도달 0%) — 셋 다 콘텐츠 기준이다. 콘텐츠 재사용 여부가 곧 신호이기 때문이다.

배열이면 이 셋이 "모든 토큰 문서를 뒤져 배열 안을 본다"가 된다. socialLinks.contentKey 에 multikey 인덱스를 걸어도 contentKey 조건과 linkedAt 조건이 같은 원소를 가리킨다는 보장이 없어 $elemMatch 가 필요하고, multikey + range 조합이라 선택도가 나쁘다. → as-of 절단이 인덱스로 안 잘린다.

반대로 배열의 이점(토큰 기준 1홉 절약)은 링크가 3~8개라 실질 비용이 거의 없다. 구조적 손해와 미미한 이득을 맞바꾸는 셈이다.

배열이 옳으려면 셋 다 참이어야 한다 — 이 도메인은 셋 다 거짓

"콘텐츠당 최대 200개로 상한을 두고 배열로" 안도 같은 이유로 기각한다 — ①as-of 가 인덱스로 안 잘리고($filter+$size aggregation) ②재관측 이력(ok→deleted)을 못 담으며 ③상한 절단이 하필 가장 강한 신호를 자른다(200개 넘게 재사용된 콘텐츠 = 최강 loser 신호).

하이브리드(tokens.socialKeys[] 캐시 병행)도 가능하나 SoT 이중화라 권장하지 않는다. 대신 contents.linkStats(스칼라 파생)가 배열의 이점 일부를 대체한다 — 콘텐츠 재사용 여부를 1차 필터에서 조인 없이 거른다.

계보를 링크에 담는다 — lineage quote 가 주 경로이기 때문

실무 패턴 — 토큰이 거는 건 원본이 아니라 "인용한 트윗"인 경우가 많다
elon 원본 트윗 X
   ↓ 배포자가 자기 계정으로 인용
배포자 트윗 Y  (quote of X)
   ↓ 토큰이 Y 를 소셜 필드에 걺
토큰 T

토큰이 건 건 Y 지만 실질 소재는 X 다. 직접 링크만 세면 X 기준 재사용 카운트가 0 이 되어 신호를 통째로 놓친다.

그리고 fan-out 으로 나오는 두 Creator 의 성격이 완전히 다르다Y.author=배포자(팀, 신생 가능), X.author=원작자(토큰과 무관한 유명인). "배포자가 유명인 콘텐츠를 차용했다"가 러그 판별의 핵심 패턴인데 구분이 없으면 섞인다.

content_token_links {
  contentKey: "x_tweet:Y",        // 토큰이 실제로 건 것
  lineage:    ["x_tweet:Y",       // ← 자기 자신
               "x_tweet:X"],      // ← 조상 (깊이 2 제한이라 최대 2개)
  linkDepth: 0,                // 0=직접 · 1~2=인용 경유
  tokenAddress, linkedAt, status, tokenCreatedAt
}

lineage 에 자기 자신을 넣나 — $or 를 없애려고

parents(조상만)lineage(자기 포함) 채택
콘텐츠 X 의 전체 토큰 조회 {$or: [{contentKey:X}, {parents:X}]} {lineage: X}
인덱스브랜치마다 따로 — {contentKey,linkedAt} + {parents,linkedAt} 하나 — {lineage:1, linkedAt:1}
실행2회 스캔 + dedup1회 스캔
$or 가 전수조사가 되는 조건

lineage 는 배열이지만 linkedAt배열 밖 스칼라$elemMatch 가 필요 없다 → 복합 인덱스에서 equality prefix + range 로 양쪽 다 바운드가 잡힌다. (배열을 기각했던 사유는 "배열 원소끼리 조건이 같은 원소를 가리켜야 하는" 경우였고, 여기엔 해당하지 않는다.)

두 질문이 두 필드로 갈린다

질문쿼리의미
Q1 토큰이 직접 선언한 몇 번째인가{contentKey: X, linkedAt: ≤T}오염 없는 원본 카운트
실질 재사용 몇 회인가{lineage: X, linkedAt: ≤T}인용 경유까지 포함
토큰 A → X 직접           →  contentKey=X, lineage=[X]
토큰 B → Y(quote of X)    →  contentKey=Y, lineage=[Y, X]

{contentKey: X} → 1건   (직접)
{lineage:    X} → 2건   (실질)  ← 재사용 신호가 여기서 잡힘

두 숫자가 다르다는 것 자체가 정보다 — "직접 1, 실질 2"는 배포자가 원본을 우회 차용했다는 뜻이다.

인덱스용도
{lineage: 1, linkedAt: 1}콘텐츠 기준 — 실질 재사용(주 경로)
{contentKey: 1, linkedAt: 1}콘텐츠 기준 — 직접 선언(Q1 정밀)
{tokenAddress: 1, linkedAt: 1}토큰 기준 — "이 토큰의 소셜 전부"

contentKeylineage[0] 과 항상 같아 중복 저장이지만, 두 질문을 인덱스로 분리하는 값이 그 비용보다 크다. contents.parentContentId 는 그대로 둔다 — 계보는 콘텐츠 자체의 속성이고(토큰 없는 콘텐츠도 계보가 있다), lineage 는 링크 조회 최적화용 비정규화다. 계보는 불변이라 동기화 문제가 없다.

네트워크 축 판정 — account_edges 는 만들지 않는다 star topology 가설

관측 사실 — 계정 그래프는 별(star) 모양이다
저시총 배포자 A ──quote/mention──▶ KOL·인플루언서 X
저시총 배포자 B ──quote/mention──▶ KOL·인플루언서 X
저시총 배포자 C ──quote/mention──▶ KOL·인플루언서 Y
                A · B · C 사이엔 엣지가 거의 없다

인용당하는 계정은 KOL/인플루언서이고, 인용하는 계정은 매우 저시총이다. 그 둘 사이 상호작용이 거의 없으므로 그래프는 단방향 star 다.

star 에서 네트워크 축이 어떻게 되나

star 에서판정
E27 삼자 폐쇄 속도삼각형이 생기지 않음측정 불가
E28 k-core 깊이리프가 전부 degree 1 → core 미형성무의미
E30 유효 지름항상 2 (A→X←B)상수 = 정보 0
E9 생성일 assortativityKOL(오래됨·큼) ↔ 저시총(신생·작음) → 강한 disassortative자명
E8 브리지 매개중심성KOL 이 전부 브리지자명
E31 엣지 버스트니스유효하나 그래프가 필요 없음기존 구조로 가능

남는 질문은 전부 기존 구조로 답한다

알고 싶은 것필요한 구조
이 KOL 이 몇 개 토큰에 인용됐나parentContentId → author.id 집계
여러 계정이 같은 KOL 을 동시에 인용(조율)parentContentId + publishedAt 시각 분포
같은 계정이 여러 KOL 을 반복 인용(시리얼 배포자)creatorIdlinks (= Q3)
결론 — 엣지 컬렉션 불필요, 구조는 현 상태로 닫힌다

account_edges{fromId,toId,type,contentKey,observedAt} 신설을 검토했으나 철회한다. star 에서는 추가로 얻는 게 없고, 필요한 질문은 contents 자기참조와 links 로 이미 답할 수 있다.

다만 mentions 에 계정 id 는 담아야 한다 — 그래프 때문이 아니라 KOL 단위 집계 때문이다. 인용은 author.id 를 갖는데 멘션만 handle 이면 같은 KOL 이 두 경로에서 다른 키로 잡히고, 핸들 변경 시 깨진다.

이 판정의 지위 — 가설이다

가설: 소셜 계정 그래프는 star topology 다 — 저시총 배포자가 KOL 을 단방향 차용하고 배포자 간 상호작용은 드물다.

반증되면(배포자 무리가 서로 밀어주는 사례가 실제로 관측되면) 재검토한다. 그때는 account_edges파생층(contents 에서 결정론적으로 재생성 가능)이므로 소급 생성할 수 있다 — 지금 안 만들어도 손해가 없다는 뜻이다.

structural-idea-matching 은 규칙상 "데이터 유무만 표시, 유효성은 평가하지 않음" 이라 "데이터가 있어도 구조상 무의미" 는 걸러내지 못한다. 이 절이 그 판단을 보완한다. 또한 그 문서의 축들은 research-findings 가 명시하듯 채택 목록이 아니라 검증 대기열이다.

비정규화 원칙 — 불변만 복사한다

필드복사이유
links.tokenCreatedAt권장불변 + Δt_token(§5.1-b) 계산 축. 조인 0회로 as-of 판정
symbol · name선택표시용. 거의 안 변하지만 SoT 는 tokens
가격 · ATH · alpha_score금지계속 변함 → 복사 즉시 stale + 백테스트 오염. 현 repo 호출로 해결

기준: linkedAt 시점에 확정된 불변 사실만 복사한다. 가변값 복사는 동기화 지옥이다.

ERD 로 드러나는 것 — 확인해 주세요

⑤ 수집 흐름 언제 · 누가 · 어떤 순서로 쓰나

이 문서에 없던 것

①~④ 는 "무엇을 어떤 모양으로 저장하나"만 정의했다. 그런데 content_token_links.linkedAt 이 as-of 의 축인데 그 값이 언제 찍히는지가 없었다. 구조만 있고 데이터가 어떻게 도착하는지가 비어 있던 셈이다.

① 트리거 토큰 임계 돌파(AF 3명 등) ② 링크 수집 · 정규화 토큰 소셜 필드 → dedup ③ 라우팅 · 병렬 fetch router → fetcher (내부) ④ 2차 발견 폐지 — 발견 링크 50.3% 가 그 사이트 자신 계정 ⑤ ContentGenerator × N — URL 1건마다 1개 x_tweet Content2 + Creator2 x_profile Creator 1 x_community Venue 1 tiktok_shortlink 객체 0 · 링크만 ⑥ 모으기 + dedup 같은 creator 가 여러 URL 에서 중복 등장 → _id 기준 병합 ⑦ 1 트랜잭션 저장 contents·accounts·venues = upsert | links·snapshot·raw = append
router/fetcher 분리는 내부 구현이고, 소비자 경계는 fetchToken(token) → TokenSocialRecord 하나다

쓰기 방식이 갈린다 "이미 있으면 링크만"의 함정

대상방식이유
contents · accounts · venuesupsert마스터 — 최신값으로 갱신. 이미 있으면 덮어쓴다
content_token_linksappendlinkedAt 이 다르면 다른 관측. 덮으면 as-of 순번(Q1)이 깨진다
ContentMetricSnapshotappend이걸 빼먹으면 velocity 를 못 본다 — "이미 있으니 링크만"의 가장 큰 함정
content_rawupsert같은 콘텐츠의 최신 원문 1벌
"이미 있으면 링크 연결만" 은 절반만 맞다

마스터(contents)는 이미 있으면 링크만 걸어도 되지만, 지표는 다시 찍어야 한다. 같은 콘텐츠를 여러 토큰이 시차를 두고 참조하는 게 정상이고, 그 사이의 참여 증가가 곧 velocity 다.

🔴 이 문서는 구조만 정의한다 — 재관측을 언제·누가 시키는지는 지금 결정하지 않는다

content_token_links·ContentMetricSnapshot 을 append 로 설계한 건 velocity 를 구조적으로 담을 수 있게 하기 위해서다. 하지만 그 그릇에 두 번째 값이 채워지려면 같은 콘텐츠를 다시 fetch 하는 트리거가 있어야 하는데, 그 트리거는 이 문서의 범위 밖이며 지금 아무것도 정하지 않는다 — 스케줄러 유무·주기·대상 선정 전부 미정으로 남긴다.

결과적으로 트리거가 생기기 전까지는 (content_key, token) 당 관측이 사실상 1건만 쌓인다. 이 상태 자체는 스키마 결함이 아니다 — append 구조가 미리 준비돼 있으니 트리거를 나중에 붙여도 과거 데이터를 마이그레이션할 필요가 없다. 지금 할 일은 그릇을 정확히 두는 것까지고, "언제 다시 찍을지"는 별도 결정으로 미룬다.

왜 pull 인가 — 그리고 무엇을 포기하는가

선택근거
토큰이 링크를 걸 때 발견(pull) 콘텐츠 모집단은 사실상 무한이라 전수 모니터링은 비용이 폭발한다. 그리고 라벨(수익)은 토큰 가격에서만 나오므로 콘텐츠 단독으로는 정답이 없다. "토큰이 이 콘텐츠를 걸었다"는 사실 자체가 밈화 후보 필터라 무작위 표본보다 농도가 높다
포기하는 것실제 영향
t_c ~ t_0 궤적(시계열) 제한적 — 궤적은 없지만 첫 관측의 누적값이 그 구간을 요약한다. 실측 예: 인용 트윗이 createdAt=07-25, 관측 07-29, viewCount=674,342 → "게시 후 4일에 67만"이라는 요약이 t_0 이전 반응을 포함한다
t_0 이후 증분의 순수성 실재 위험 — "콘텐츠가 인기라 조회수가 늘었다"와 "토큰이 나서 보러 왔다"가 섞인다(역인과). 진입 판단에 그대로 쓰면 토큰 자신이 만든 트래픽을 보고 판단하게 된다
역인과 절단 — 추가 구조 없이 가능
첫 관측(linkedAt 근처) 스냅샷   →  t_c~t_0 요약   →  상대적으로 clean
그 이후 스냅샷의 증분          →  오염 구간       →  별도 취급

ContentMetricSnapshot(content_key 기준, 토큰 무관) + content_token_links.linkedAt 으로 자르는 게 이미 가능하다. push 전환(콘텐츠 상시 모니터링)은 지금 불필요하며, 스키마는 나중에 전환하더라도 그대로 지탱한다.

⑥ 3층 구조 소셜별 차이를 담는 규칙

핵심 질문은 "공통으로 올릴 것과 소셜 고유로 남길 것을 무엇으로 가르나"다. 기준은 하나 — 크로스플랫폼 쿼리가 그 필드를 필요로 하는가.

L1 · core.* — 모든 소셜 공통
의미축으로 이름을 통일한다. 인덱스·조인·계보 탐색은 여기서만 돈다.

판단 기준: "소셜이 달라도 같은 사건을 가리키는가?" — 작성 시각·작성자·본문·외부링크는 정의가 일치한다.

참여 지표(metrics)는 L1이 아니다 — 정의가 소셜마다 달라 통일이 오염을 만든다(§⑦). 게다가 시계열이라 애초에 다른 컬렉션에 산다(§⑧).

L2 · ext.<코드>.* — 소셜 고유 키 이름 확정
audit이 keep으로 확정했으나 공통축에 의미가 안 맞는 필드. 네임스페이스로 격리해 인덱스는 가능하되 충돌은 없다.

코드: ig · yt · tt · x · rd · gh · tg

L3 · content_raw — 원본 무손실 별도 컬렉션
응답 전체를 변형 없이 보존. _id(=contentKey) 하나만 두고 보조 인덱스는 만들지 않는다 — 조회는 오직 id 로만. audit의 skip·미발견 필드가 전부 여기 남는다.

audit 내내 반복된 교훈 — 문서가 절단된 덤프를 기록해 alt·isSlideshow·author.id를 놓쳤다. raw 보존은 재해석 가능성이다.

왜 분리하나: X 트윗 raw 는 author 33키 + quoted_tweet 전체로 수십 KB 다. MongoDB 는 문서 단위 I/O 라 contents 에 붙어 있으면 목록 조회마다 안 쓰는 raw 까지 읽힌다(projection 으로 필드는 빼도 캐시·스토리지 효율은 손해). 나중에 옮기면 전량 마이그레이션이라 지금 분리한다.

L1로 올리면 안 되는 것 — 의미가 다른데 이름만 같은 필드

강제 매핑하면 조용히 오염된다. 애매하면 L1에 올리지 않는다. 참여 지표 전체가 이 사유로 L1에서 빠졌다(§⑥).

같은 판단이 Creator에도 적용된다 — followers·contentCount는 소셜이 달라도 같은 사건이라 L1이지만, "누적 참여"(YouTube viewCount · TikTok heart · Reddit totalKarma)는 누적 조회/좋아요/카르마로 제각각이라 L2다.

⑦ 공통 의미축 × 6소셜 매핑 L1 core

Content

core 필드instagramyoutubetiktokxredditgithub
id shortCodevideoIdididparsedIdid(repo)
creatorId ownerIdsnippet.channelIdauthorMeta.idauthor.idauthorNameowner.id
publishedAttimestamppublishedAtcreateTimeISOcreatedAtcreatedAtcreatedAt
text.primarycaptiontitletexttexttitledescription
text.bodydescriptionbodyREADME (별도 호출)
text.fromMedia
미디어→텍스트
alt (Meta AI 시각설명)videoMeta.transcriptionLink
tags[]hashtags[](tags는 skip)hashtags[].nameentities.hashtagstopics[]
mentions[]mentions[]mentions[]user_mentions
links[]
드레이너·CA 축
entities.urls[].expanded_urldomain·outboundUrlHosthomepage
form
형식 구분자
type/productTypeisSlideshowisQuote/isRetweet(postType 제거)fork
genealogy.parentId
파생 관계
quoted_tweet.id·retweeted_tweet.id(crosspost 미채택)fork → parent repo
moderation(creator쪽 unavailable)removedBy*·bannedByarchived
venueKeycommunityInfosubreddit name
observedAtfetch 시각fetch 시각fetch 시각fetch 시각crawledAt (actor 제공)fetch 시각

참여 지표 — 이름을 통일하지 않는다

결정: 공통축 매핑 철회 · 소셜 원래 이름 그대로 저장

이전 초안은 core.metrics{views, likes, comments, shares, saves}로 통일했으나 철회한다. 같은 이름에 넣으면 서로 다른 사건이 한 축에 섞인다.

"views" 하나만 봐도실제 의미
tiktok.playCount스크롤 중 자동재생도 카운트
x.viewCount타임라인 impression
youtube.viewCount유의미 시청(대략 30초 규칙)
instagram.videoViewCount / videoPlayCount둘이 따로 존재 — 어느 쪽이 views인지 결정 불가

→ 10만이 소셜마다 다른 사건을 뜻하게 된다. 통일의 이득(크로스 소셜 랭킹)은 허상이고 오염은 실제다. 특히 초안의 shares 합산(TikTok share+repost, X retweet+quote)은 인용(의견 추가)과 단순 재공유를 더한 것이라 근거가 약했다.

비교 가능한 것은 절대 카운트가 아니라 "소셜 내 상대 위치"다. 비교는 파생층에서 한다(§⑦ 하단).

소셜별 지표 인벤토리 저장은 원래 이름 그대로 · 아래는 참고용 분류

의미 그룹 (참고)instagramyoutubetiktokxredditgithub
노출·조회videoViewCount
videoPlayCount
viewCountplayCountviewCount
호응likesCountlikeCountdiggCountlikeCountupVotes · score · upvoteRatiostars (북마크 성격)
대화commentsCountcommentCountcommentCountreplyCountcommentsCountopen_issues_count
재확산shareCount · repostCountretweetCount · quoteCountnumCrosspostsforks_count
보관collectCountbookmarkCount

이 표는 어떤 소셜이 무슨 지표를 갖는지 보는 인벤토리이지 매핑 규칙이 아니다. GitHub의 open_issues_count(이슈)·forks_count(포크)는 "대화"·"재확산"과 행위 성격이 다르고, audit 결론상 GitHub은 모멘텀 채널이 아니라 러그 검증 채널이라 같은 축에 세울 이유가 없다.

Creator

core 필드instagramyoutubetiktokxredditgithub
id id(숫자)channelIdauthorMeta.idid(숫자)username (불변)id(숫자)
handles[]
이력 배열
usernamehandle·customUrl·옛핸들nameuserName= id (이력 불필요)login
createdAt
러그 1순위 tell
(IG 미제공)snippet.publishedAtauthorMeta.createTimecreatedAtcreatedAtcreated_at
displayName(fullName skip)snippet.title(nickName skip)name(name skip)
biobiographysnippet.descriptionsignaturedescriptionbiobio·company·email
links[]externalUrls[]bioLinkblog
declaredHandles[]
타 플랫폼 선언
twitter_username
metrics.followersfollowersCountsubscriberCountfansfollowersfollowersCountfollowers
metrics.contentCountpostsCountvideoCountvideostatusesCountpublic_repos
(누적 참여) L2로 내림
정의가 제각각
viewCount 누적 조회heart 누적 좋아요totalKarma·linkKarma·commentKarma 카르마
badges(verified 제거)(조달 불가)(verified 제거)isVerified·isBlueVerified(skip)(조달 불가)
type(isBusinessAccount skip)type (User/Organization)
moderationunavailable·unavailableReason
이 표가 말해주는 것 — 축의 채움률이 곧 소셜의 성격

⑧ 지표는 별도 컬렉션 contents에서 분리 · 단 time-series는 안 씀

왜 contents 안에 두지 않나 — 분리는 맞다
문제내용
변화량 소실덮어쓰는 순간 이전 값이 사라진다. 그런데 밈 신호의 핵심은 절대값이 아니라 속도(velocity)
쓰기 증폭관측마다 문서 전체를 갱신 → 본문·data(무손실 원본)까지 같이 재작성. 원본이 클수록 낭비가 커진다
계층 원칙 위반v2는 관측 로그(소급 불가) / 마스터(재생성 가능)를 나눴다. 지표는 지나가면 복원 불가라 로그층 소속이다

v2가 token_metrics_ts·account_metrics_ts는 제거하면서 콘텐츠 지표만 "보류"로 남긴 게 이 맥락이다. 여기서 되살리되 time-series 컬렉션은 쓰지 않는다(아래).

MongoDB time-series 컬렉션은 쓰지 않는다 규모 미달 + 레포 제약

① 규모가 이득 구간이 아니다

time-series의 이득(압축·버킷팅)은 버킷 하나에 많은 포인트가 담길 때 나온다. 버킷은 metaField(=contentKey) + 시간 범위로 묶인다.

time-series가 확실히 유리해지는 구간은 보통 초 단위 고빈도 × 장기간(콘텐츠당 수만~수십만 포인트)이다.

② 이 레포에서 쓰기 어렵다 — 이쪽이 더 결정적
제약내용
트랜잭션 쓰기 불가 MongoDB 7.0(현 서버)에서 time-series 컬렉션은 멀티도큐먼트 트랜잭션 안에서 쓸 수 없다. 레포 규칙은 "All write operations must use transactions"contents 생성과 첫 지표 기록이 원자적이지 않게 된다
BaseModel 전제와 충돌 buildSchematimestamps:{created_at, updated_at}를 강제하는데 time-series는 update가 제한적. _id: ObjectId + id: string 규약도 안 맞아 이 컬렉션만 예외가 된다
조용한 실패 footgun mongoose 공식 문서 경고 — "if you insert a document before creating the collection, MongoDB will create a normal collection without timeseries". 이 레포는 MongooseModule.forFeatureAsync지연 등록하므로 첫 write가 먼저 나가면 일반 컬렉션이 조용히 만들어지고 아무도 모른다
typegoose 지원 schemaOptions로 통과는 되나 데코레이터 모델이 일반 컬렉션 전제라 검증·훅이 어긋난다 — "가능하지만 우대받지 못함"

확인 버전: MongoDB 7.0 · mongoose 8.19.3 · @typegoose/typegoose 12.20.0 · @nestjs/mongoose 10.1.0

채택 — 일반 컬렉션 + 복합 인덱스

@index({ content_key: 1, observed_at: 1 })      // 콘텐츠 타임라인
@index({ platform: 1, observed_at: 1 })        // 소셜 코호트(백분위 계산)
export class ContentMetricSnapshot extends BaseModel {
  @prop({ required: true }) content_key: string;   // "x_tweet:2070..."
  @prop({ required: true }) platform: string;
  @prop({ required: true }) observed_at: Date;
  @prop({ type: Schema.Types.Mixed, required: true })
  metrics: Record<string, number>;               // polymorphic — 소셜 원래 이름
}
얻는 것잃는 것
트랜잭션 ✓ · BaseModel 일관성 ✓ · footgun 없음 ✓ · 스키마 검증 ✓ · 나중에 마이그레이션 가능 스토리지 압축 — 이 규모에선 미미

1개 컬렉션 vs 소셜별 분리 → 1개

나눴을 때 잃는 것내용
가장 빈번할 쿼리가 소셜 무관"이 토큰의 소셜 반응 전체 타임라인"이 6-way 조회가 된다. 토큰 하나에 6개 소셜이 붙는 게 정상 상태
content_token_links 무력화엣지의 contentKey가 어느 컬렉션인지 알 수 없어 결국 platform으로 라우팅 분기. 그럴 거면 한 컬렉션이다
v2 1번 원칙 위반"조인 배제(Zero-Join Exploration) — 1~2홉 이내 빈번한 탐색에서 $lookup 배제"
소셜 추가 = 스키마 변경단일 컬렉션 + platform 필드면 무변경. 소셜별 인덱스가 필요하면 partial index로 건다

소셜 차이를 아는 파일은 하나 METRIC_SPEC 레지스트리

metrics가 polymorphic이라 읽는 쪽에 소셜별 지식이 필요하다. 그게 호출부마다 흩어지면 if (platform === 'tt') … else if ('x') …가 코드베이스 전역에 복제되고, 지표가 하나 늘 때마다 전부 찾아 고쳐야 한다. 소셜별 지표 스펙을 한 곳에만 둔다.

// 이 파일 하나만 소셜 차이를 안다
const METRIC_SPEC = {
  x:  { reach: 'viewCount', engage: ['likeCount','replyCount'],
        spread: ['retweetCount','quoteCount'], save: 'bookmarkCount' },
  tt: { reach: 'playCount', engage: ['diggCount','commentCount'],
        spread: ['shareCount','repostCount'],  save: 'collectCount' },
  rd: { reach: null,        engage: ['upVotes','commentsCount'],
        spread: ['numCrossposts'],           save: null },
  gh: { reach: null, engage: null, spread: null, save: null },  // 모멘텀 채널 아님
  …
}

읽는 쪽은 spec(platform).reach역할을 물어보지 필드명을 직접 알지 않는다.

이게 "이름 통일 안 한다"와 모순 아닌가 — 아니다
내용
저장원래 이름 그대로 → playCountviewCount절대 같은 칸에 안 들어간다
해석"이 소셜에서 도달 지표는 무엇인가"를 지정 → 같은 소셜 안에서만 유효

레지스트리는 "playCount = viewCount"라고 말하지 않는다. "TikTok에서 도달을 보려면 playCount를 봐라"라고만 말한다. 크로스 소셜 비교는 여전히 소셜 내 백분위로만 한다.

gh가 전부 null인 게 이 설계가 정직하다는 증거다 — GitHub은 모멘텀 채널이 아니라 러그 검증 채널이라 억지 매핑 없이 "없음"으로 남는다.

구조적으로 기존 레포 패턴과 같다 — SocialFetcherRouter.classify()가 소셜 분기를 한 곳에서 흡수하고 각 fetcher가 handles: SocialSourceType[]로 담당을 선언하는 것과 동일하게, 소셜 지식을 레지스트리 1곳에 가두고 나머지는 타입으로만 다룬다.

비교는 파생층에서 raw가 아니라 상대 위치

metricRanks: {                // 파생 — 언제든 버리고 재생성
  cohort: "platform:x · tokenAge 0~5m",
  reachPct: 0.92,            // 같은 소셜·같은 시간대 코호트 내 백분위
  computedAt, inputCutoff, ruleVersion
}

백분위는 0~1 스케일이라 크로스 소셜로 놓아도 오염이 없고, v2의 파생층 규칙(computedAt·inputCutoff 필수, 언제든 재생성)에 그대로 들어간다. raw 카운트는 소셜별로 두고, 비교는 정규화된 파생값으로 한다 — 이것이 "같은 소셜끼리만 비교 가능"에 대한 답이다.

최신 스냅샷 역정규화 — 두는 쪽 권장

contents.metricsLatest에 최신값을 덮어써 두면 "현재 상태 목록 조회"에서 조인이 없어진다. 덮어쓰기지만 원본이 스냅샷 컬렉션에 남아 있어 되감기 손실이 없다(파생층 성격).

time-series로 전환을 재검토할 조건

⑨ ext 네임스페이스 L2 — 소셜 고유 keep 필드

코드ContentCreator
igproductType(feed/clips 세부)
ytchannelTitleviewCount(채널 누적 조회)
ttisAd/isSponsored(광고 tell) · musicMeta.musicId(사운드 확산축) · slideshowImageLinks[] · webVideoUrl(canonical)heart(누적 좋아요)
xconversationId · inReplyToId/inReplyToUserId(답글 체인)following · isVerified/isBlueVerified
rdsubredditSubscribers(게시 시점 스냅샷)totalKarma/linkKarma/commentKarma
ghpushedAt(방치 tell) · language
L2가 얇다는 게 핵심 결과다

audit의 keep 필드 대부분이 L1 공통축에 흡수됐다. 소셜이 달라도 트레이더가 던지는 질문은 같기 때문이다 — 누가(identity) · 언제(time) · 뭐라고(text) · 얼마나 퍼졌나(metrics) · 어디로 유도하나(links) · 지워졌나(moderation).

남은 L2는 대부분 그 소셜에만 존재하는 확산 메커니즘이다(TikTok 사운드, X 스레드, Reddit 투표비율, GitHub 코드 활동).

⑩ venues 컬렉션 신규

// _id = platform:venueId
{
  _id: "x:1952999882635264271",     // reddit: "reddit:wallstreetbets"
  platform: "x",
  venueId: "1952999882635264271",
  name: "Sheep Wif Hat",
  createdAt: ISODate(...),          // 개설일 — 런칭 직전 급조 tell
  creatorId: "x:1283966981297680384",  // accounts 참조 (id로, handle 아님)
  description: "... CA: 21Xp...pump",   // 토큰 CA가 실제로 여기 노출됨(실측)
  metrics: { members: 4720, moderators: 11 },
  observedAt: ISODate(...),
  ext: { x: { adminHandle: ... } },   // L2 — platform(구분)과 키 충돌 방지
  // raw 는 여기 없다 → content_raw 컬렉션(_id = venueKey)
}
소셜venueIdContent→Venue 링크비고
xcommunity_info.idcommunityInfo 신규 채택개설자 creator.id로 조인. 응답 키가 userName/screen_name으로 불안정해 handle 폴백은 위험
redditname (slug)post의 subreddit이름이 불변이라 그대로 키. id/parsedId는 audit이 제거
telegram미확정보류 — audit 섹션 없음. Venue만 있고 Creator·Content가 없는 유일한 소셜

⑪ 관측 시각 규칙 v2 observedAt 보강

같은 문서 안에서도 관측 시각이 다르다

X 트윗 응답의 author33키 완전 프로필이라 Creator 레코드를 만들 수 있다. 그런데 그 스냅샷의 시각은 "프로필 조회 시점"이 아니라 "트윗 조회 시점"이다. 같은 구조가 TikTok authorMeta, X quoted_tweet.author에도 있다.

규칙: 중첩 임베딩된 스냅샷은 부모 문서의 observedAt을 상속하고, 그 사실을 sourceRef로 남긴다.

accounts/x:1534395199958306816
  observedAt: 2026-07-29T…      // 이 레코드가 관측된 시각
  sourceRef: {
    via: "x_tweet",             // 직접 프로필 조회가 아니라 트윗 응답에서 파생
    contentKey: "x_tweet:2070140594496626759",
    depth: 1                     // 0=최상위 author, 1=quoted_tweet.author
  }

상태의 SoT — 이력은 links, contents 는 최신값 캐시 Tier 1 확정

같은 사실이 두 곳에 있었다

v2 Q6("링크가 살아있나 / 삭제됐나 / 애초에 없었나")은 links{observedAt ≤ T} → status 로 답한다. 그런데 contents.fetchStatus 도 있어 어느 쪽이 진실인지 정하지 않은 상태였다.

더 큰 문제: contents.observedAt단수다. 재fetch 시 덮어쓰면 ok → deleted 전이가 사라진다. "지금 상태"만 남고 "언제 죽었나"가 소실된다 — 관측 로그는 소급 불가라는 계층 원칙과 정면 충돌.

어디무엇성격
content_token_links.status + observedAt 상태 이력 — 관측할 때마다 append SoT · 소급 불가
contents.fetchStatus + observedAt 최신값 캐시 — 덮어쓰기 파생 · 언제든 재생성

캐시를 두는 이유는 "현재 살아있는 것만" 같은 목록 조회에서 조인을 없애기 위해서다. 덮어써도 원본이 links 에 남아 있어 되감기 손실이 없다metricsLatest 와 같은 성격이다.

handle 이력의 as-of 문제

TikTok·GitHub은 옛 핸들이 타인에게 재할당된다. 따라서 handles[]를 "현재값 1건"으로 들고 있으면 과거 관측이 새 주인에게 잘못 붙는다. 이력에 기간이 필요하다.

handles: [
  { value: "knowyourmeme", firstSeen: …, lastSeen: … }
]

보조 관찰: contents 레코드가 이미 (authorHandle, publishedAt, creatorId)를 한 행에 갖고 있어 contents 자체가 handle 이력 로그 역할을 한다. 별도 이력 테이블 없이 as-of 해석이 가능하다.

⑫ sourceType enum 최종 audit 확정분

플랫폼타입sourceKey상태
instagraminstagram (profile)handle → id유지 html_fragment 정제 후 편입
instagram_postshortCode병합 post/reel/tv/embed 단일화, type으로 형식 구분
instagram_storystoryIdfetch 보류 24h 소멸
youtubeyoutube_channelalias → channelId키 통일
youtube_videovideoIdhost 확장 music·nocookie
youtube_playlistplaylistId타입만 리터럴 키 버그 교정
tiktoktiktok_profilehandle → authorMeta.id키 통일
tiktok_videopostIdembed/v2 편입 photo도 동일 네임스페이스(실측)
tiktok_shortlinkshortCode안전망 상류에서 canonical 정규화
tiktok_search쿼리유지 fetcher 미구현
xx_profilehandle·numericId → id키 통일 /i/user/·하위탭 편입
x_tweettweetId유지
x_communitycommunityId유지 → venues
x_tweet_search쿼리·태그신규 search+hashtag 통합
x_intent신규·의심 플래그
redditreddit (subreddit)name유지 → venues
reddit_postpostId신규 redd.it 편입
reddit_userusername신규
reddit_shareshareId신규 해석 불가(IP 차단)
githubgithub_ownerlogin → id신규 분리
github_repoowner/repo → id신규 분리
gistgistIdgap 현재 website로 샘
telegram보류
공통*_search · empty · unknown미정 7개 완료 후 통합 패스

⑬ 저장 매핑 Tier 1 확정 — 라우터 산출을 어디에 넣나

빠져 있던 연결 고리

audit 은 소셜을 Creator / Content / Venue 3객체로 정리했고, 라우터는 URL 에서 sourceType 을 낸다. 그런데 그 결과를 어느 컬렉션에 무슨 _id 로 넣을지가 정의돼 있지 않았다.

URL 토큰 소셜 필드 router.classify() 순수함수 · 네트워크 없음 { sourceType, sourceKey } sourceKey = URL 유래 (저장 키 아님) fetch → data SOURCE_TYPE_OBJECT 상수 맵 — sourceType → objectType ContentGenerator 어댑터 — _id 추출 · fan-out · core 매핑 accounts contents venues content_token_links + content_raw unresolved / none — 마스터엔 안 들어가지만 관측은 남긴다 tiktok_shortlink · reddit_share → links 에 잠정 키 | *_search · x_intent → links 에만
초록 = 상수 분류 · 보라 = 소셜별 generator. sourceKey 는 저장 키가 아니다 — _id 는 fetch 응답에서 나온다

가장 까다로운 지점 — sourceKey ≠ 저장 키

audit 전체의 결론이 "조인키는 fetch 응답 안에 있다" 였다. 그래서 URL 에서 뽑은 sourceKey 와 실제 저장 키가 다르다.

sourceTypesourceKey (URL 유래)_id (응답 유래)
x_profileuntaxedsolana (handle)data.id = 2020402210912509952
youtube_channelnateherk 또는 UCabc…data.channelId — 두 형태가 하나로 수렴
github_reporohitg00/agentmemorydata.id = 1166408297
tiktok_video7649280049534799137data.postId (= 같음)

그리고 1개 결과 → N개 레코드 (fan-out)

x_tweet 1건 fetch
 ├─ Content  : 트윗 자신           _id = data.id
 ├─ Creator  : 작성자              _id = data.authorId
 ├─ Content  : 인용 원본           _id = data.quoted.id          ← fan-out
 ├─ Creator  : 인용 원본 작성자     _id = data.quoted.authorId    ← fan-out
 └─ Venue 링크: data.communityInfo
x_tweet 1건 fetch 1회 · 추가 호출 0 Content 트윗 자신 _id = data.id Creator 작성자 _id = data.authorId Content 인용 원본 _id = data.quoted.id Creator 인용 작성자 _id = data.quoted.authorId ← fan-out 깊이 2 제한 ← fan-out communityInfo 있으면 Venue 링크도 추가
보라 = 중첩 전개분. SDK 의 transformTweet 이 재귀라 추가 fetch 없이 원본이 완전한 객체로 들어온다

즉 매핑은 sourceType → 컬렉션 1:1 이 아니라 "결과 → 레코드 목록"을 만드는 규칙이다.

어디에 두나 — 상수 맵 + ContentGenerator 채택

담당이유
SOURCE_TYPE_OBJECT
중앙 상수 맵
sourceTypeobjectType (creator / content / venue / unresolved / none) 순수 분류 지식이라 라우터·타입 옆에 두는 게 자연스럽다. 한눈에 보인다
ContentGenerator
소셜별 · URL 1건당 1개
result.data객체 목록. _id 추출 · fan-out · core 필드 매핑
{ contents[], creators[], venues[], links[] }
소셜별 응답 구조 지식이고, 이미 "소셜 차이를 아는 파일 하나" 역할로 두기로 한 자리다. METRIC_SPEC 과 같은 위치

이름을 generator 로 한 이유 — 실제로 1 URL → N 객체 생성이라 "맵(1:1 대응)"보다 정확하다. METRIC_SPEC 과 같은 위치의 소셜별 지식이지만 역할이 변환이 아니라 생성이다.

기각한 대안 — 상수 맵 하나로 전부: fan-out 같은 구조를 표현할 수 없다. 각 fetcher 가 선언: fetcher 가 "가져오기 + 변환" 두 일을 하게 되고, 지표는 레지스트리로 모으면서 나머지는 흩뜨리는 비일관이 된다.

fetcher 와 분리한다

같은 소셜 지식이라 합치고 싶어지지만 종속 대상이 다르다fetcher외부 API 계약에, ContentGenerator우리 스키마에 종속된다. 합치면 스키마가 바뀔 때 이미 테스트가 붙어 있는 fetcher 까지 흔들린다.

그리고 generator 가 _id 를 만들어 올려야 저장 계층이 data.id/data.channelId/data.authorId/data.parsedId 같은 소셜별 다른 이름을 파헤치지 않는다. 현재 SocialFetchResult 는 canonical 키를 envelope 에 올리지 않는다.

전체 매핑

sourceTypeobjectType컬렉션_id 추출
x_profilecreatoraccountsdata.id
x_tweetcontentcontentsdata.id + fan-out 4
x_communityvenuevenuesdata.id
youtube_channelcreatoraccountsdata.channelId
youtube_videocontentcontentsdata.videoId + creator(channelId)
tiktok_profilecreatoraccountsdata.authorId
tiktok_videocontentcontentsdata.postId + creator
instagramcreatoraccountsdata.id
instagram_postcontentcontentsdata.shortCode + creator(ownerId)
github_ownercreatoraccountsdata.id
github_repocontentcontentsdata.id + creator(ownerId)
redditvenuevenuesdata.communityName ⚠ Venue 자체 미조달
reddit_postcontentcontentsdata.parsedId + creator(authorName)
reddit_usercreatoraccountsdata.username
tg_channel · tg_guard_groupvenuevenues미확정 telegram audit 대기
websitetokens.web{}확정4번째 객체를 만들지 않는다 — 작성자·발행시각·참여지표가 없어 Content 가 아니다. 토큰의 속성 + 지문이다. 필드 인벤토리·소거 근거는 website-image.html
tiktok_shortlink · reddit_shareunresolvedlinks 에만 잠정 키
*_search · x_intent · unknownnonetokens.unclassifiedLinks[] (원문 URL)
특수 케이스 2종 — 마스터엔 안 들어가지만 관측은 남긴다
종류처리
unresolvedtiktok_shortlink · reddit_share fetch 가 안 되므로 data 가 없어 레코드를 못 만든다. 그러나 "어떤 토큰이 이 URL 을 걸었나"는 남겨야 하므로 content_token_links잠정 키(tiktok_shortlink:ZNRTtUjSM)로 기록. 나중에 정규화되면 실제 키로 교체하거나 별칭 연결
nonex_tweet_search · x_intent · tiktok_search 애초에 3객체가 아니다 — content_token_links 처럼 contentKey FK 가 필요한 컬렉션에 넣을 대상이 없다. tokens.unclassifiedLinks[](평면 문자열 배열, fingerprints[] 와 동형)에 원문 URL 그대로 담는다. 단 x_intent 처럼 의심 플래그로서 기록 가치가 있는 것tiktok.com/oembed?url=(빈 값) 같은 수집 오염은 구분해야 한다 — 후자는 저장 전에 걸러낸다
unclassifiedLinks[] 를 남기는 목적 — 신호가 아니라 백필

실측(고유 URL 7,087개 전량 재분류): 토큰의 97.0%가 unclassifiedLinks 0개라 별도 컬렉션을 만들 조인 밀도가 안 나온다. 이 배열의 용도는 신호 수집이 아니라 재분류 백필이다 — 원본 소셜 필드는 다른(현재) repo가 갖고 있어서, 여기 원문을 안 남기면 나중에 새 sourceType (예: tiktok_music)을 추가할 때 원본을 다시 받아와야 한다.

⑭ 미해결 · 보류

해소됨 — Tier 1 구조 4건 (2026-07-30)
항목결정위치
raw 를 어디에 두나content_raw 별도 컬렉션 · _id(=contentKey)만, 보조 인덱스 없음§⑥ L3
상태 SoT이력은 links, contents.fetchStatus 는 최신값 캐시§⑪
sourceType → 컬렉션SOURCE_TYPE_OBJECT(상수) + ContentGenerator(생성기)§⑬
platform 키 충돌platform(구분 문자열) + ext(L2 네임스페이스)로 분리§⑥ L2 · §⑧ · §⑩
항목내용왜 지금 못 정하나
mentions 가 조인키가 아님 코드 버그 SDK 의 UserMentionEntity{id, name, screenName} 인데 fetcher 가 screenName 만 담는다. 핸들은 키로 쓰지 말라는 이 문서의 원칙을 스스로 위반 — 같은 KOL 이 인용(author.id)과 멘션(handle)에서 다른 키로 잡힌다 {id, screenName} 쌍으로 수정. 그래프용이 아니라 KOL 단위 집계용(§⑥ 네트워크 축 판정)
구조 축 88개 미대조 적합성 조사는 쿼리 31개만 봤고 structural-idea-matching 의 88축은 대조하지 않았다 — "전수조사"가 실제로는 부분조사였다 합치면 119축. 최대 병목은 엔티티 라벨 미생성(28축) 으로 구조가 아니라 입력 문제
linkStats 재계산 주기linkCount 는 파생값이라 computedAt·inputCutoff 가 필수인데, 언제 갱신할지 미정정밀 판정은 항상 links{linkedAt ≤ T} 로 — 스칼라는 1차 필터용
envelope 에 canonical 키 부재SocialFetchResultsourceKey(URL 유래)만 올리고 _iddata 안에 소셜별 다른 이름으로 묻혀 있다ContentGenerator 구현 시 해소 — 저장 계층이 소셜 지식을 몰라야 함
Venue(subreddit) 조달Apify 가 subreddit URL 에 dataType='post' 를 뱉어 개설일·description 미확보actor 입력 옵션 탐색 필요(실측 확인됨)
telegramVenue 전용 소셜. 공개 채널 / 초대전용 가드그룹 2종수기 audit 섹션이 없음 — 조인키·필드 결정 입력 부재
공통 타입 통합search·empty·html_fragment류의 크로스플랫폼 정의7개 소셜 완료 후 마지막 패스로 예정
quote 전개 깊이X 재귀 전개를 2단계로 제한순환 방지용 임의값. 실제 카피캣 체인 깊이 분포 미측정
README 저장 정책전문 / 앞부분 N자 / 해시본문 길이 분포 미조사. 별도 호출 1회가 필요하다는 것만 확정
🔴 재관측 트리거이미 관측한 콘텐츠를 언제·누가·왜 다시 fetch 하는가 — Cron? 인터벌? 이벤트?이 문서 범위 밖으로 명시 보류. 지금은 아무것도 트리거하지 않는다 — 구조(append)만 준비해 두고, 트리거는 별도 결정으로 미룬다. 이게 없으면 §⑤의 velocity 전제 자체가 성립 안 한다(§⑤ 참조)
지표 관측 주기ContentMetricSnapshot에 몇 초/분 간격으로 찍을 것인가소셜별 유료 게이트·rate limit이 달라 비용 대비 해상도를 아직 못 정함. 컬렉션 설계는 §⑦에서 확정됨(주기만 미정). 위 트리거 자체가 먼저 결정돼야 의미가 생기는 하위 질문
백분위 코호트 정의소셜 내 비교의 모집단·시간 윈도우를 무엇으로 자를 것인가§⑦에서 비교를 백분위로 하기로 확정했으나 코호트 기준은 미정 — 실험으로 정해야 함
Reddit 실캡처전 필드 droppedabout.json 100% 403. 위 매핑은 Apify actor 전환 전제이며 라이브 재검증 안 함
YouTube 실캡처objectFields가 문서 기반Data API v3 키가 없어 한 번도 호출 못 함. 필드 완전성이 문서 지식에 의존

정직한 한계