| 분류 | 문서 | 역할 |
|---|---|---|
| 계보 | 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 검증 방법 |
현재 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는 아이디어 181개 매칭에서 구조를 먼저 세운 문서다. 이후 6개 소셜을 실제 API 응답으로 감사한 결과, v2의 핵심 결정 4개가 전부 실측으로 검증됐다. v3는 그 위에 (a) 검증 결과 기록 (b) audit이 드러낸 갭 3개 보완 (c) 소셜별 차이를 담는 규칙을 추가한다.
v2에서 유지되는 부분(계층 분리 원칙, content_token_links, 순번 미저장, 파생값 computedAt·inputCutoff)은 반복하지 않는다.
| 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 |
전 소셜에서 필요 확인 + 보강 필요: 같은 문서 안에서도 필드 그룹별 관측 시각이 다름(§⑪) | 보강 |
Reddit username은 변경 불가(대소문자조차)이고 계정 삭제 후에도 재사용 불가다. 즉 이름 자체가 불변 id다.
platform:accountId에서 accountId가 숫자가 아닌 문자열인 케이스를 허용해야 한다.
| 갭 | 왜 문제인가 | 확정 |
|---|---|---|
| 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 재정의 (§⑫) 코드 반영 완료 |
handles[] 이력으로 내려가 as-of 해석에만 쓴다| 관계 | 카디널리티 | 비고 |
|---|---|---|
contents → accounts | N : 1 | 한 계정이 여러 콘텐츠. creatorId는 fetch 응답에서 나온다(추가 쿼리 없음) |
contents → venues | N : 1 nullable | X community · reddit subreddit 만. 나머지 소셜은 null |
contents → contents | N : 1 자기참조 | 계보 — X quote/retweet · GitHub fork. 깊이 2 제한 |
venues → accounts | N : 1 | 개설자. 응답 키가 불안정해(userName/screen_name) id 로만 조인 |
content_token_links → contents·tokens | N : 1 · N : 1 = M:N 연결 테이블 | 엣지 컬렉션 — 배열이 아닌 이유는 원소에 시각(linkedAt)이 필요해서.contents 쪽 N:1 의 주 원인은 여러 토큰이 같은 콘텐츠를 재사용(quote 때문이 아님) + append-only 재관측 |
ContentMetricSnapshot → contents | N : 1 | append-only. 콘텐츠당 100~200 포인트 예상 |
content_raw ↔ contents | 1 : 1 | _id 공유. 분리 이유는 문서 단위 I/O(§⑥ L3) |
토큰 하나에 소셜 링크가 여러 개 생긴다. 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회 스캔 + dedup | 1회 스캔 |
$or 가 전수조사가 되는 조건$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} | 토큰 기준 — "이 토큰의 소셜 전부" |
contentKey 는 lineage[0] 과 항상 같아 중복 저장이지만,
두 질문을 인덱스로 분리하는 값이 그 비용보다 크다.
contents.parentContentId 는 그대로 둔다 — 계보는 콘텐츠 자체의 속성이고(토큰 없는 콘텐츠도 계보가 있다),
lineage 는 링크 조회 최적화용 비정규화다. 계보는 불변이라 동기화 문제가 없다.
account_edges 는 만들지 않는다 star topology 가설저시총 배포자 A ──quote/mention──▶ KOL·인플루언서 X
저시총 배포자 B ──quote/mention──▶ KOL·인플루언서 X
저시총 배포자 C ──quote/mention──▶ KOL·인플루언서 Y
A · B · C 사이엔 엣지가 거의 없다
인용당하는 계정은 KOL/인플루언서이고, 인용하는 계정은 매우 저시총이다. 그 둘 사이 상호작용이 거의 없으므로 그래프는 단방향 star 다.
| 축 | star 에서 | 판정 |
|---|---|---|
| E27 삼자 폐쇄 속도 | 삼각형이 생기지 않음 | 측정 불가 |
| E28 k-core 깊이 | 리프가 전부 degree 1 → core 미형성 | 무의미 |
| E30 유효 지름 | 항상 2 (A→X←B) | 상수 = 정보 0 |
| E9 생성일 assortativity | KOL(오래됨·큼) ↔ 저시총(신생·작음) → 강한 disassortative | 자명 |
| E8 브리지 매개중심성 | KOL 이 전부 브리지 | 자명 |
| E31 엣지 버스트니스 | 유효하나 그래프가 필요 없음 | 기존 구조로 가능 |
| 알고 싶은 것 | 필요한 구조 |
|---|---|
| 이 KOL 이 몇 개 토큰에 인용됐나 | parentContentId → author.id 집계 |
| 여러 계정이 같은 KOL 을 동시에 인용(조율) | parentContentId + publishedAt 시각 분포 |
| 같은 계정이 여러 KOL 을 반복 인용(시리얼 배포자) | creatorId → links (= 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 시점에 확정된 불변 사실만 복사한다. 가변값 복사는 동기화 지옥이다.
accounts 마스터 + contents.author{} 스냅샷.
의도된 중복(as-of 정확성)이지만 마스터 갱신 규칙이 아직 없다(Tier 2 #8)tokens 와 contents 사이에 직접 관계가 없다 — 반드시 content_token_links를 거친다.
이것이 A-3(계정→포트폴리오)이 3홉인 이유parentContentId 하나면 되지만 깊이 제한이 없으면 순환 위험venues 는 참조가 적다 — contents 1개, accounts 1개뿐. Tier 2 #6(accounts 통합) 검토 근거①~④ 는 "무엇을 어떤 모양으로 저장하나"만 정의했다. 그런데
content_token_links.linkedAt 이 as-of 의 축인데 그 값이 언제 찍히는지가 없었다.
구조만 있고 데이터가 어떻게 도착하는지가 비어 있던 셈이다.
fetchToken(token) → TokenSocialRecord 하나다| 대상 | 방식 | 이유 |
|---|---|---|
contents · accounts · venues | upsert | 마스터 — 최신값으로 갱신. 이미 있으면 덮어쓴다 |
content_token_links | append | linkedAt 이 다르면 다른 관측. 덮으면 as-of 순번(Q1)이 깨진다 |
ContentMetricSnapshot | append | 이걸 빼먹으면 velocity 를 못 본다 — "이미 있으니 링크만"의 가장 큰 함정 |
content_raw | upsert | 같은 콘텐츠의 최신 원문 1벌 |
마스터(contents)는 이미 있으면 링크만 걸어도 되지만, 지표는 다시 찍어야 한다.
같은 콘텐츠를 여러 토큰이 시차를 두고 참조하는 게 정상이고, 그 사이의 참여 증가가 곧 velocity 다.
content_token_links·ContentMetricSnapshot 을 append 로 설계한 건 velocity 를
구조적으로 담을 수 있게 하기 위해서다. 하지만 그 그릇에 두 번째 값이 채워지려면
같은 콘텐츠를 다시 fetch 하는 트리거가 있어야 하는데, 그 트리거는 이 문서의 범위 밖이며
지금 아무것도 정하지 않는다 — 스케줄러 유무·주기·대상 선정 전부 미정으로 남긴다.
결과적으로 트리거가 생기기 전까지는 (content_key, token) 당 관측이 사실상 1건만
쌓인다. 이 상태 자체는 스키마 결함이 아니다 — append 구조가 미리 준비돼 있으니 트리거를
나중에 붙여도 과거 데이터를 마이그레이션할 필요가 없다. 지금 할 일은 그릇을 정확히 두는 것까지고,
"언제 다시 찍을지"는 별도 결정으로 미룬다.
| 선택 | 근거 |
|---|---|
| 토큰이 링크를 걸 때 발견(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 전환(콘텐츠 상시 모니터링)은 지금 불필요하며,
스키마는 나중에 전환하더라도 그대로 지탱한다.
핵심 질문은 "공통으로 올릴 것과 소셜 고유로 남길 것을 무엇으로 가르나"다. 기준은 하나 — 크로스플랫폼 쿼리가 그 필드를 필요로 하는가.
core.* — 모든 소셜 공통판단 기준: "소셜이 달라도 같은 사건을 가리키는가?" — 작성 시각·작성자·본문·외부링크는 정의가 일치한다.
참여 지표(metrics)는 L1이 아니다 — 정의가 소셜마다 달라 통일이 오염을 만든다(§⑦). 게다가 시계열이라 애초에 다른 컬렉션에 산다(§⑧).
ext.<코드>.* — 소셜 고유 키 이름 확정keep으로 확정했으나 공통축에 의미가 안 맞는 필드. 네임스페이스로 격리해 인덱스는 가능하되 충돌은 없다.코드: ig · yt · tt · x · rd · gh · tg
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에서 빠졌다(§⑥).
같은 판단이 Creator에도 적용된다 — followers·contentCount는 소셜이 달라도 같은 사건이라 L1이지만,
"누적 참여"(YouTube viewCount · TikTok heart · Reddit totalKarma)는
누적 조회/좋아요/카르마로 제각각이라 L2다.
| core 필드 | youtube | tiktok | x | github | ||
|---|---|---|---|---|---|---|
| id 키 | shortCode | videoId | id | id | parsedId | id(repo) |
| creatorId 키 | ownerId | snippet.channelId | authorMeta.id | author.id | authorName | owner.id |
| publishedAt | timestamp | publishedAt | createTimeISO | createdAt | createdAt | createdAt |
| text.primary | caption | title | text | text | title | description |
| text.body | — | description | — | — | body | README (별도 호출) |
| text.fromMedia 미디어→텍스트 | alt (Meta AI 시각설명) | — | videoMeta.transcriptionLink | — | — | — |
| tags[] | hashtags[] | — (tags는 skip) | hashtags[].name | entities.hashtags | — | topics[] |
| mentions[] | mentions[] | — | mentions[] | user_mentions | — | — |
| links[] 드레이너·CA 축 | — | — | — | entities.urls[].expanded_url | domain·outboundUrlHost | homepage |
| form 형식 구분자 | type/productType | — | isSlideshow | isQuote/isRetweet | — (postType 제거) | fork |
| genealogy.parentId 파생 관계 | — | — | — | quoted_tweet.id·retweeted_tweet.id | — (crosspost 미채택) | fork → parent repo |
| moderation | — | — | — | — (creator쪽 unavailable) | removedBy*·bannedBy | archived |
| venueKey | — | — | — | communityInfo | subreddit name | — |
| observedAt | fetch 시각 | 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)은
인용(의견 추가)과 단순 재공유를 더한 것이라 근거가 약했다.
비교 가능한 것은 절대 카운트가 아니라 "소셜 내 상대 위치"다. 비교는 파생층에서 한다(§⑦ 하단).
| 의미 그룹 (참고) | youtube | tiktok | x | github | ||
|---|---|---|---|---|---|---|
| 노출·조회 | videoViewCountvideoPlayCount | viewCount | playCount | viewCount | — | — |
| 호응 | likesCount | likeCount | diggCount | likeCount | upVotes · score · upvoteRatio | stars (북마크 성격) |
| 대화 | commentsCount | commentCount | commentCount | replyCount | commentsCount | open_issues_count |
| 재확산 | — | — | shareCount · repostCount | retweetCount · quoteCount | numCrossposts | forks_count |
| 보관 | — | — | collectCount | bookmarkCount | — | — |
이 표는 어떤 소셜이 무슨 지표를 갖는지 보는 인벤토리이지 매핑 규칙이 아니다.
GitHub의 open_issues_count(이슈)·forks_count(포크)는 "대화"·"재확산"과 행위 성격이 다르고,
audit 결론상 GitHub은 모멘텀 채널이 아니라 러그 검증 채널이라 같은 축에 세울 이유가 없다.
| core 필드 | youtube | tiktok | x | github | ||
|---|---|---|---|---|---|---|
| id 키 | id(숫자) | channelId | authorMeta.id | id(숫자) | username (불변) | id(숫자) |
| handles[] 이력 배열 | username | handle·customUrl·옛핸들 | name | userName | = id (이력 불필요) | login |
| createdAt 러그 1순위 tell | — (IG 미제공) | snippet.publishedAt | authorMeta.createTime | createdAt | createdAt | created_at |
| displayName | — (fullName skip) | snippet.title | — (nickName skip) | name | — | — (name skip) |
| bio | biography | snippet.description | signature | description | bio | bio·company·email |
| links[] | externalUrls[] | — | bioLink | — | — | blog |
| declaredHandles[] 타 플랫폼 선언 | — | — | — | — | — | twitter_username |
| metrics.followers | followersCount | subscriberCount | fans | followers | followersCount | followers |
| metrics.contentCount | postsCount | videoCount | video | statusesCount | — | public_repos |
| (누적 참여) L2로 내림 정의가 제각각 | — | viewCount 누적 조회 | heart 누적 좋아요 | — | totalKarma·linkKarma·commentKarma 카르마 | — |
| badges | — (verified 제거) | — (조달 불가) | — (verified 제거) | isVerified·isBlueVerified | — (skip) | — (조달 불가) |
| type | — (isBusinessAccount skip) | — | — | — | — | type (User/Organization) |
| moderation | — | — | — | unavailable·unavailableReason | — | — |
alt와 TikTok transcriptionLink 둘뿐 — 시각 콘텐츠 해석의 유일한 무료 진입점| 문제 | 내용 |
|---|---|
| 변화량 소실 | 덮어쓰는 순간 이전 값이 사라진다. 그런데 밈 신호의 핵심은 절대값이 아니라 속도(velocity)다 |
| 쓰기 증폭 | 관측마다 문서 전체를 갱신 → 본문·data(무손실 원본)까지 같이 재작성. 원본이 클수록 낭비가 커진다 |
| 계층 원칙 위반 | v2는 관측 로그(소급 불가) / 마스터(재생성 가능)를 나눴다. 지표는 지나가면 복원 불가라 로그층 소속이다 |
v2가 token_metrics_ts·account_metrics_ts는 제거하면서
콘텐츠 지표만 "보류"로 남긴 게 이 맥락이다. 여기서 되살리되 time-series 컬렉션은 쓰지 않는다(아래).
time-series의 이득(압축·버킷팅)은 버킷 하나에 많은 포인트가 담길 때 나온다. 버킷은 metaField(=contentKey) + 시간 범위로 묶인다.
time-series가 확실히 유리해지는 구간은 보통 초 단위 고빈도 × 장기간(콘텐츠당 수만~수십만 포인트)이다.
| 제약 | 내용 |
|---|---|
| 트랜잭션 쓰기 불가 | MongoDB 7.0(현 서버)에서 time-series 컬렉션은 멀티도큐먼트 트랜잭션 안에서 쓸 수 없다.
레포 규칙은 "All write operations must use transactions" — contents 생성과 첫 지표 기록이 원자적이지 않게 된다 |
BaseModel 전제와 충돌 |
buildSchema가 timestamps:{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 없음 ✓ · 스키마 검증 ✓ · 나중에 마이그레이션 가능 |
스토리지 압축 — 이 규모에선 미미 |
| 나눴을 때 잃는 것 | 내용 |
|---|---|
| 가장 빈번할 쿼리가 소셜 무관 | "이 토큰의 소셜 반응 전체 타임라인"이 6-way 조회가 된다. 토큰 하나에 6개 소셜이 붙는 게 정상 상태 |
content_token_links 무력화 | 엣지의 contentKey가 어느 컬렉션인지 알 수 없어 결국 platform으로 라우팅 분기. 그럴 거면 한 컬렉션이다 |
| v2 1번 원칙 위반 | "조인 배제(Zero-Join Exploration) — 1~2홉 이내 빈번한 탐색에서 $lookup 배제" |
| 소셜 추가 = 스키마 변경 | 단일 컬렉션 + platform 필드면 무변경. 소셜별 인덱스가 필요하면 partial index로 건다 |
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로 역할을 물어보지 필드명을 직접 알지 않는다.
| 내용 | |
|---|---|
| 저장 | 원래 이름 그대로 → playCount와 viewCount가 절대 같은 칸에 안 들어간다 |
| 해석 | "이 소셜에서 도달 지표는 무엇인가"를 지정 → 같은 소셜 안에서만 유효 |
레지스트리는 "playCount = viewCount"라고 말하지 않는다. "TikTok에서 도달을 보려면 playCount를 봐라"라고만 말한다. 크로스 소셜 비교는 여전히 소셜 내 백분위로만 한다.
gh가 전부 null인 게 이 설계가 정직하다는 증거다 — GitHub은 모멘텀 채널이 아니라 러그 검증 채널이라 억지 매핑 없이 "없음"으로 남는다.
구조적으로 기존 레포 패턴과 같다 — SocialFetcherRouter.classify()가 소셜 분기를 한 곳에서 흡수하고
각 fetcher가 handles: SocialSourceType[]로 담당을 선언하는 것과 동일하게, 소셜 지식을 레지스트리 1곳에 가두고 나머지는 타입으로만 다룬다.
metricRanks: { // 파생 — 언제든 버리고 재생성 cohort: "platform:x · tokenAge 0~5m", reachPct: 0.92, // 같은 소셜·같은 시간대 코호트 내 백분위 computedAt, inputCutoff, ruleVersion }
백분위는 0~1 스케일이라 크로스 소셜로 놓아도 오염이 없고, v2의 파생층 규칙(computedAt·inputCutoff 필수, 언제든 재생성)에 그대로 들어간다.
raw 카운트는 소셜별로 두고, 비교는 정규화된 파생값으로 한다 — 이것이 "같은 소셜끼리만 비교 가능"에 대한 답이다.
contents.metricsLatest에 최신값을 덮어써 두면 "현재 상태 목록 조회"에서 조인이 없어진다.
덮어쓰기지만 원본이 스냅샷 컬렉션에 남아 있어 되감기 손실이 없다(파생층 성격).
| 코드 | Content | Creator |
|---|---|---|
ig | productType(feed/clips 세부) | — |
yt | channelTitle | viewCount(채널 누적 조회) |
tt | isAd/isSponsored(광고 tell) · musicMeta.musicId(사운드 확산축) · slideshowImageLinks[] · webVideoUrl(canonical) | heart(누적 좋아요) |
x | conversationId · inReplyToId/inReplyToUserId(답글 체인) | following · isVerified/isBlueVerified |
rd | subredditSubscribers(게시 시점 스냅샷) | totalKarma/linkKarma/commentKarma |
gh | pushedAt(방치 tell) · language | — |
audit의 keep 필드 대부분이 L1 공통축에 흡수됐다. 소셜이 달라도 트레이더가 던지는 질문은 같기 때문이다 —
누가(identity) · 언제(time) · 뭐라고(text) · 얼마나 퍼졌나(metrics) · 어디로 유도하나(links) · 지워졌나(moderation).
남은 L2는 대부분 그 소셜에만 존재하는 확산 메커니즘이다(TikTok 사운드, X 스레드, Reddit 투표비율, GitHub 코드 활동).
// _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) }
| 소셜 | venueId | Content→Venue 링크 | 비고 |
|---|---|---|---|
| x | community_info.id | communityInfo 신규 채택 | 개설자 creator.id로 조인. 응답 키가 userName/screen_name으로 불안정해 handle 폴백은 위험 |
name (slug) | post의 subreddit | 이름이 불변이라 그대로 키. id/parsedId는 audit이 제거 | |
| telegram | 미확정 | — | 보류 — audit 섹션 없음. Venue만 있고 Creator·Content가 없는 유일한 소셜 |
X 트윗 응답의 author는 33키 완전 프로필이라 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 }
followers가 여러 경로로 들어올 때 어느 값이 더 최신인지 판별해야 한다. observedAt 비교만으로 충분하되, 신뢰도가 다를 수 있어 출처를 남긴다crawledAt을 직접 준다 → fetch 시각 대신 그 값을 쓴다(실제 스크랩 시각이 더 정확)resolved(실측) / inferred(handle 추정) 구분 필요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 와 같은 성격이다.
TikTok·GitHub은 옛 핸들이 타인에게 재할당된다. 따라서 handles[]를 "현재값 1건"으로 들고 있으면
과거 관측이 새 주인에게 잘못 붙는다. 이력에 기간이 필요하다.
handles: [ { value: "knowyourmeme", firstSeen: …, lastSeen: … } ]
보조 관찰: contents 레코드가 이미 (authorHandle, publishedAt, creatorId)를 한 행에 갖고 있어
contents 자체가 handle 이력 로그 역할을 한다. 별도 이력 테이블 없이 as-of 해석이 가능하다.
| 플랫폼 | 타입 | sourceKey | 상태 |
|---|---|---|---|
instagram (profile) | handle → id | 유지 html_fragment 정제 후 편입 | |
instagram_post | shortCode | 병합 post/reel/tv/embed 단일화, type으로 형식 구분 | |
instagram_story | storyId | fetch 보류 24h 소멸 | |
| youtube | youtube_channel | alias → channelId | 키 통일 |
youtube_video | videoId | host 확장 music·nocookie | |
youtube_playlist | playlistId | 타입만 리터럴 키 버그 교정 | |
| tiktok | tiktok_profile | handle → authorMeta.id | 키 통일 |
tiktok_video | postId | embed/v2 편입 photo도 동일 네임스페이스(실측) | |
tiktok_shortlink | shortCode | 안전망 상류에서 canonical 정규화 | |
tiktok_search | 쿼리 | 유지 fetcher 미구현 | |
| x | x_profile | handle·numericId → id | 키 통일 /i/user/·하위탭 편입 |
x_tweet | tweetId | 유지 | |
x_community | communityId | 유지 → venues | |
x_tweet_search | 쿼리·태그 | 신규 search+hashtag 통합 | |
x_intent | — | 신규·의심 플래그 | |
reddit (subreddit) | name | 유지 → venues | |
reddit_post | postId | 신규 redd.it 편입 | |
reddit_user | username | 신규 | |
reddit_share | shareId | 신규 해석 불가(IP 차단) | |
| github | github_owner | login → id | 신규 분리 |
github_repo | owner/repo → id | 신규 분리 | |
gist | gistId | gap 현재 website로 샘 | |
| telegram | — | — | 보류 |
| 공통 | *_search · empty · unknown | — | 미정 7개 완료 후 통합 패스 |
audit 은 소셜을 Creator / Content / Venue 3객체로 정리했고, 라우터는 URL 에서 sourceType 을 낸다.
그런데 그 결과를 어느 컬렉션에 무슨 _id 로 넣을지가 정의돼 있지 않았다.
sourceKey ≠ 저장 키audit 전체의 결론이 "조인키는 fetch 응답 안에 있다" 였다. 그래서 URL 에서 뽑은 sourceKey 와 실제 저장 키가 다르다.
| sourceType | sourceKey (URL 유래) | _id (응답 유래) |
|---|---|---|
x_profile | untaxedsolana (handle) | data.id = 2020402210912509952 |
youtube_channel | nateherk 또는 UCabc… | data.channelId — 두 형태가 하나로 수렴 |
github_repo | rohitg00/agentmemory | data.id = 1166408297 |
tiktok_video | 7649280049534799137 | data.postId (= 같음) |
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
즉 매핑은 sourceType → 컬렉션 1:1 이 아니라 "결과 → 레코드 목록"을 만드는 규칙이다.
| 층 | 담당 | 이유 |
|---|---|---|
SOURCE_TYPE_OBJECT중앙 상수 맵 |
sourceType → objectType (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 는 외부 API 계약에, ContentGenerator 는 우리 스키마에 종속된다.
합치면 스키마가 바뀔 때 이미 테스트가 붙어 있는 fetcher 까지 흔들린다.
그리고 generator 가 _id 를 만들어 올려야 저장 계층이
data.id/data.channelId/data.authorId/data.parsedId 같은
소셜별 다른 이름을 파헤치지 않는다. 현재 SocialFetchResult 는 canonical 키를 envelope 에 올리지 않는다.
| sourceType | objectType | 컬렉션 | _id 추출 |
|---|---|---|---|
x_profile | creator | accounts | data.id |
x_tweet | content | contents | data.id + fan-out 4 |
x_community | venue | venues | data.id |
youtube_channel | creator | accounts | data.channelId |
youtube_video | content | contents | data.videoId + creator(channelId) |
tiktok_profile | creator | accounts | data.authorId |
tiktok_video | content | contents | data.postId + creator |
instagram | creator | accounts | data.id |
instagram_post | content | contents | data.shortCode + creator(ownerId) |
github_owner | creator | accounts | data.id |
github_repo | content | contents | data.id + creator(ownerId) |
reddit | venue | venues | data.communityName ⚠ Venue 자체 미조달 |
reddit_post | content | contents | data.parsedId + creator(authorName) |
reddit_user | creator | accounts | data.username |
tg_channel · tg_guard_group | venue | venues | 미확정 telegram audit 대기 |
website | tokens.web{} | 확정 | 4번째 객체를 만들지 않는다 — 작성자·발행시각·참여지표가 없어 Content 가 아니다. 토큰의 속성 + 지문이다. 필드 인벤토리·소거 근거는 website-image.html |
tiktok_shortlink · reddit_share | unresolved | — | links 에만 잠정 키 |
*_search · x_intent · unknown | none | — | tokens.unclassifiedLinks[] (원문 URL) |
| 종류 | 예 | 처리 |
|---|---|---|
| unresolved | tiktok_shortlink · reddit_share |
fetch 가 안 되므로 data 가 없어 레코드를 못 만든다. 그러나 "어떤 토큰이 이 URL 을 걸었나"는 남겨야 하므로
content_token_links 에 잠정 키(tiktok_shortlink:ZNRTtUjSM)로 기록.
나중에 정규화되면 실제 키로 교체하거나 별칭 연결 |
| none | x_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)을 추가할 때 원본을 다시 받아와야 한다.
| 항목 | 결정 | 위치 |
|---|---|---|
| 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 키 부재 | SocialFetchResult 가 sourceKey(URL 유래)만 올리고 _id 는 data 안에 소셜별 다른 이름으로 묻혀 있다 | ContentGenerator 구현 시 해소 — 저장 계층이 소셜 지식을 몰라야 함 |
| Venue(subreddit) 조달 | Apify 가 subreddit URL 에 dataType='post' 를 뱉어 개설일·description 미확보 | actor 입력 옵션 탐색 필요(실측 확인됨) |
| telegram | Venue 전용 소셜. 공개 채널 / 초대전용 가드그룹 2종 | 수기 audit 섹션이 없음 — 조인키·필드 결정 입력 부재 |
| 공통 타입 통합 | search·empty·html_fragment류의 크로스플랫폼 정의 | 7개 소셜 완료 후 마지막 패스로 예정 |
| quote 전개 깊이 | X 재귀 전개를 2단계로 제한 | 순환 방지용 임의값. 실제 카피캣 체인 깊이 분포 미측정 |
| README 저장 정책 | 전문 / 앞부분 N자 / 해시 | 본문 길이 분포 미조사. 별도 호출 1회가 필요하다는 것만 확정 |
| 🔴 재관측 트리거 | 이미 관측한 콘텐츠를 언제·누가·왜 다시 fetch 하는가 — Cron? 인터벌? 이벤트? | 이 문서 범위 밖으로 명시 보류. 지금은 아무것도 트리거하지 않는다 — 구조(append)만 준비해 두고, 트리거는 별도 결정으로 미룬다. 이게 없으면 §⑤의 velocity 전제 자체가 성립 안 한다(§⑤ 참조) |
| 지표 관측 주기 | ContentMetricSnapshot에 몇 초/분 간격으로 찍을 것인가 | 소셜별 유료 게이트·rate limit이 달라 비용 대비 해상도를 아직 못 정함. 컬렉션 설계는 §⑦에서 확정됨(주기만 미정). 위 트리거 자체가 먼저 결정돼야 의미가 생기는 하위 질문 |
| 백분위 코호트 정의 | 소셜 내 비교의 모집단·시간 윈도우를 무엇으로 자를 것인가 | §⑦에서 비교를 백분위로 하기로 확정했으나 코호트 기준은 미정 — 실험으로 정해야 함 |
| Reddit 실캡처 | 전 필드 dropped | about.json 100% 403. 위 매핑은 Apify actor 전환 전제이며 라이브 재검증 안 함 |
| YouTube 실캡처 | objectFields가 문서 기반 | Data API v3 키가 없어 한 번도 호출 못 함. 필드 완전성이 문서 지식에 의존 |
parseStatus(현재)와 decision(목표) 사이 간격이 곧 구현 작업량이다.shares 합산(TikTok share+repost, X retweet+quote)은 인용과 단순 재공유를 더한 것이라 근거가 약했고 철회했다. 대신 비교는 소셜 내 백분위라는 파생값에 의존하는데, 그 코호트 정의(시간 윈도우·모집단)는 아직 미확정이다.text.primary에 무엇을 넣을지(Reddit은 title, GitHub은 description)는 소셜마다 "대표 텍스트"의 무게가 달라 재검토 여지가 있다.