URL ↔ 객체 ↔ 링크 구조

토큰이 건 링크가 유실되는 문제에 대한 구조. 이 문제와 무관한 필드(텍스트·지표·프로필 상세)는 전부 뺀 최소 모델입니다. · 관련 문서: 링크 도착점 모델 비교 (문제 정의와 후보안 비교) · available-queries.md (쿼리 경로 15개)

§1전제

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

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

§2컬렉션

이름 규칙 — 식별자 두 종류를 이름으로 가른다
  _id  로 끝나면  →  항상 ObjectId. 우리가 발급하고, 우리 DB 안의 다른 행을 가리킨다
  _key 로 끝나면  →  항상 외부 문자열. 남이 발급하고, 우리가 만들지 않는다

  _id · creator_id · venue_id · parent_content_id · site_id · object_id     ObjectId
  platform_key   '2070140594496626759'   플랫폼 응답이 준 원 id
  url_key        'foo.com'               URL 에서 뽑은 식별자

두 종류를 섞으면 함수 시그니처가 (id: string) 일 때 둘 다 통과해서 조용히 틀립니다. 세션 초반에 "URL 유래 식별자와 응답 유래 식별자는 다르다"고 정리한 것이 이름에 그대로 드러나도록 platform_key·url_key 로 통일합니다.

contents · accounts · venues 객체 마스터 — 링크가 가리키는 3종
{
  _id          ObjectId            // 정체성. 링크가 가리키는 값
  platform     enum            // x · tt · ig · yt · rd · gh · tg
  subtype      enum            // 컬렉션별 어휘 — 아래 표
  platform_key  string          // 응답 유래 원 id. fetch 성공 시에만 채워진다
  source_urls  object[]        // 자신을 유래시킨 URL 목록
    ├ url         string  
    ├ status      enum         // ok · skipped_paid · error · not_found · blocked · unsupported
    └ observed_at Date    
}

subtypesource_urls[] 두 필드가 기존 대비 추가분입니다. 문자열 비즈니스 키 (content_key·account_key·venue_key)는 _id 가 대신하므로 사라집니다.

컬렉션답하는 질문subtype 어휘
contents이 플랫폼에서 무엇이 주목받나tweet · video · post · story · repo · gist · search · intent · shortlink · unknown
accounts누가profile · user · owner · channel_owner
venues어디서community · subreddit · channel
판단 기록 0 — 비객체를 별도 컬렉션으로 두지 않고 contents 에 합친다

search·unknown·shortlink·intentnonobjects 라는 별도 컬렉션에 두는 안을 검토했으나 합치기로 했습니다. 이유는 두 가지입니다.

첫째, 같은 질문에 답하는 좌표입니다. TikTok 에서 유행하는 것은 search 로도 video 로도 표현됩니다. 나누면 "이 소셜에서 무엇이 주목받나"를 보려고 두 컬렉션을 확인해야 하는데, 이 이원화 자체가 draft 가 제기한 문제였습니다.

둘째, unknown 은 모르는 사이트가 아니라 "아는 플랫폼인데 그 안 어디인지 모르는 것"입니다. 라우터는 호스트 조회표에 없는 URL 을 전부 website 로 떨어뜨립니다 (social-fetcher.router.ts:82). 그래서 unknown 이 되는 경우는 URL 파싱 실패이거나, 아는 소셜의 classify 가 경로를 못 알아본 경우뿐입니다. 실측이 이를 뒷받침합니다 — unknown 398건이 전부 x.com(282)과 tiktok.com(97)이고, 그중 200건이 X 검색·해시태그입니다. 즉 unknown 은 플랫폼 콘텐츠 공간의 미상 좌표입니다.

부수 효과가 더 큽니다. 재분류가 전부 컬렉션 안에서 끝납니다 — unknown → search, shortlink → video, reddit_share → post 가 모두 subtype 컬럼 갱신 한 번입니다. §4 의 "범위 3군데" 케이스가 분할 때문에 생긴 비용이었습니다.

sites 미결 — §7 결정 1
// 웹사이트를 어떻게 다룰지는 추가 분석이 필요해 보류한다. 지금까지 나온 것만 기록.
{
  _id          ObjectId
  url_key      string          // 등록 도메인(apex)
  domain_created_at · domain_expiry_at · registrar   // RDAP 유래 — 도메인 단위
}

웹 페이지 자체(ogTitle·isNews·server)는 contentsplatform:web 으로 두고 site_id 로 이 컬렉션을 참조하는 방향까지 논의했으나, 확정하지 않았습니다. 근거와 선행 과제는 §7 결정 1 에 정리했습니다.

token_links 엣지 — 토큰 ↔ 객체 · append-only
{
  _id           ObjectId
  token_address string   
  object        enum           // contents · accounts · venues                ← 어디에서
  object_id     ObjectId       //                                            ← 어떤 걸
  source_url    string         // 관측 시점에 실제로 본 URL. 재할당돼도 남는다
  platform      enum           // 객체에서 복사(비정규화). 아래 설명
  subtype       enum           // 객체에서 복사(비정규화)
  linked_at     Date           // as-of 절단축
  observed_at   Date     
}

platform·subtype 복사는 "이 토큰이 TikTok 을 걸었나", "플랫폼별 토큰 노출 집계" 같은 질문을 이 컬렉션 한 번 조회로 끝내기 위한 것입니다. 두 값은 재분류 때만 바뀌고 그 재분류는 이미 링크를 재연결하는 작업이라, 같은 작업 단위에서 함께 고쳐집니다. 기존 설계도 같은 논리로 token_created_at 을 링크에 복사해뒀습니다.

판단 기록 1 — urls 컬렉션을 두지 않는다

모든 URL 을 1행씩 담는 별도 컬렉션을 검토했으나 채택하지 않았습니다. 첫째, URL→객체 조회에 필요하지 않습니다. URL 을 파싱하면 platformsubtype 이 나오고 거기서 어느 컬렉션인지가 정해지므로, x.com/{handle}accounts 만 보면 됩니다. 둘째, 집계도 감당됩니다 — "TikTok 노출 전부"는 contents 한 컬렉션이고(판단 기록 0), token_linksplatform 을 복사해 두면 그마저 필요 없습니다. 셋째, 컬렉션 간 URL 유일성은 애플리케이션이 챙길 수 있습니다. 재분류는 옛 행을 손에 쥔 채 일어나므로(배치가 subtype:unknown 행을 훑으며 다시 분류함) 눈먼 중복이 아니라 의도적인 병합입니다. urls 를 두면 매핑이 두 곳에 생겨 동기화 부담이 영구히 남는데, 그 비용이 얻는 것보다 큽니다.

판단 기록 2 — source_field 를 제거한다

토큰의 어느 칸(website·twitter·telegram·discord)에 URL 이 적혀 있었는지를 담던 필드입니다. 세 가지 이유로 뺐습니다.

첫째, 발행자가 칸을 구분해서 쓰지 않습니다. audit 표본에서 website 칸에 Reddit 18건 · GitHub 15건 · TikTok 검색 6건 · X 트윗 3건이 들어 있었습니다. 칸 이름이 내용을 예측하지 못합니다.

둘째, discovered 가 무엇을 긁은 것인지 이 레포에서 확인되지 않습니다. 실물 URL 이 /embed/, utm_source=ig_embed, (이스케이프된 </a>) 형태라 HTML 에서 정규식으로 뽑아낸 것으로 보이지만, 라벨을 붙이는 코드가 sol-alpha-finder-tracker 쪽에 있어 정확한 출처를 모릅니다. 의미를 모르는 값으로 백테스트 게이트를 만들 수는 없습니다.

셋째, adhoc 은 데이터가 아닙니다. social-fetcher.service.ts:67 의 디버깅용 단건 진입점이 넣는 상수이고, audit 실측 7,742건 중 0건입니다.

discovered 의 출처가 tracker 쪽에서 확인되면, "선언 / 발견"을 가르는 축으로 다시 넣는 것이 맞습니다. 선언 링크는 런치 시점에 존재했고 발견 링크는 나중에 찾은 것이라, 섞으면 백테스트에 look-ahead 가 생깁니다.

판단 기록 3 — 링크를 tokens 안 배열이 아니라 별도 컬렉션으로 둔다

tokens.links[] 배열로 넣는 방안을 검토했습니다. 저장 정보는 같고 배치만 다릅니다. 별도 컬렉션을 택한 이유는 세 가지입니다.

첫째, 읽기 방향이 배열 방향과 반대입니다. 이 시스템의 신호 질문은 "이 계정이 몇 개 토큰에 붙었나", "이 콘텐츠가 몇 번 재사용됐나" 처럼 객체 기준 역방향이 대부분입니다. 별도 컬렉션이면 {object_id, linked_at} 복합 인덱스만 읽고 끝나지만, 배열이면 multikey 인덱스로 후보를 좁힌 뒤 토큰 문서를 실제로 열어야 합니다. multikey 인덱스는 배열 필드에 대한 쿼리를 커버할 수 없기 때문입니다.

둘째, 재관측이 append 라 배열이 시간에 비례해 자랍니다. 상태가 ok → deleted 로 바뀐 시각이 신호라 덮어쓸 수 없고, 그러면 관측할 때마다 엔트리가 쌓입니다. 다만 이 이점의 크기는 아직 모릅니다 — 재관측 트리거가 미정(v3 §⑭)이고 토큰당 링크 수 실측도 없습니다. 방향만 정해져 있습니다.

셋째, 쓰기 모드가 다릅니다. tokens 는 재관측 시 덮어쓰는 마스터이고 링크 기록은 절대 덮어쓰지 않는 관측 로그입니다. 한 문서에 두 모드를 섞으면 "이 문서는 덮어써도 되는가"의 답이 필드마다 달라집니다. 별도 컬렉션이면 그 리포지토리에 update 메서드를 아예 만들지 않는 것으로 타입 레벨에서 막을 수 있습니다.

배열이 유리한 점도 있습니다. 컬렉션이 하나 줄고, 한 토큰의 링크 여러 개를 한 번의 원자적 쓰기로 넣을 수 있습니다(트랜잭션이 없는 지금 상황에서 실질 이점). 다만 링크가 일부만 남는 것은 덜 기록된 것이지 틀린 기록이 아니라서, 마스터가 반쯤 갱신되는 것보다 피해가 작습니다.

§3실제 데이터 — 토큰 하나가 소셜 5개를 걸었을 때

토큰 ABC…pump 의 소셜 필드가 아래와 같고, 관측 시점에 X 와 Telegram 은 fetch 에 성공했으며 Instagram 은 유료 게이트로 실패했다고 하겠습니다.

// 입력
website    = foo.com
twitter    = x.com/kolguy                    fetch ok  → platform_key 555
telegram   = t.me/abcchat                    fetch ok  → platform_key 777
discovered = tiktok.com/search?q=abc         비객체 — fetch 대상 아님
discovered = instagram.com/p/Bjnx75lFFQp     skipped_paid → platform_key 없음

객체 — 5개. fetch 실패한 것도 객체가 생긴다

accounts
  A1 { platform:x,   subtype:profile, platform_key:"555",
       source_urls:[{ url:"x.com/kolguy",                status:ok,           observed_at:t1 }] }

venues
  V1 { platform:tg,  subtype:channel, platform_key:"777",
       source_urls:[{ url:"t.me/abcchat",                status:ok,           observed_at:t1 }] }

contents
  C1 { platform:ig,  subtype:post,    platform_key:null,
       source_urls:[{ url:"instagram.com/p/Bjnx75lFFQp", status:skipped_paid, observed_at:t1 }] }
       ↑ 유료 게이트라 id 를 못 얻었다. 그래도 객체는 존재한다 (전제 1·2)

  C2 { platform:tt,  subtype:search,  url_key:"abc",
       source_urls:[{ url:"tiktok.com/search?q=abc",     status:unsupported,  observed_at:t1 }] }
       ↑ 검색도 contents 다 — TikTok 콘텐츠 공간의 좌표이므로 video 와 같은 컬렉션

sites  — 미결(§7 결정 1)
  S1 { url_key:"foo.com" }   웹을 페이지/도메인 2층으로 둘지 결론 안 남

token_links — 5행

token_links
  { token:ABC, object:accounts, object_id:A1, platform:x,  subtype:profile, source_url:"x.com/kolguy"                }
  { token:ABC, object:venues,   object_id:V1, platform:tg, subtype:channel, source_url:"t.me/abcchat"                }
  { token:ABC, object:contents, object_id:C2, platform:tt, subtype:search,  source_url:"tiktok.com/search?q=abc"     }
  { token:ABC, object:contents, object_id:C1, platform:ig, subtype:post,    source_url:"instagram.com/p/Bjnx75lFFQp" }
  { token:ABC, … foo.com … }  ← 웹 링크가 무엇을 가리킬지는 미결(§7 결정 1)
현행 대비 무엇이 달라지나

현행 스키마에서는 이 5건 중 엣지가 생기는 것이 0건입니다. 프로필과 채널은 엣지가 닿을 길이 없고, 검색과 사이트는 tokens.unclassified_links[] 에 문자열로만 남으며, Instagram 은 platform_key 가 없어 content_key 를 조립할 수 없습니다. 이 구조에서는 5건 모두 엣지가 생깁니다.

§4재할당 — 나중에 매핑이 바뀔 때 무엇을 고치나

전제 5 가 말하는 재할당은 실제로 세 가지 형태로 나타나고, 고쳐야 할 범위가 각각 다릅니다. 아래 세 케이스는 모두 audit 실측 또는 fetcher 계약에 근거가 있는 것들입니다.

범위 1군데같은 컬렉션 안에서 종류만 정확해짐
t1  x.com/search?q=abc  → 파싱 실패
    contents N9 { platform:x, subtype:unknown, url_key:null }

t2  classifier 추가 → x 의 검색 URL 로 해석됨
    contents N9 { platform:x, subtype:search,  url_key:"abc" }   ← 컬럼만 갱신

token_links  subtype 복사본만 함께 갱신. object_id 는 그대로
실측 규모가 가장 큽니다. unknown 398건 중 196건이 x.com/search, 4건이 x.com/hashtag/{tag} 입니다. 즉 미분류의 절반은 classifier 만 붙이면 이 형태로 한 번에 정리됩니다. _id 가 정체성이라 종류가 바뀌어도 링크의 연결 자체는 끊기지 않습니다.
범위 1~3군데정체가 밝혀져 기존 행과 만남
t1  vm.tiktok.com/ZSABC123  → 해석 불가
    contents C7 { platform:tt, subtype:shortlink }
    token_links { object:contents, object_id:C7, source_url:"vm.tiktok.com/ZSABC123" }

t2  해석됨 — 영상 7412… 이었다

    ① 그 영상의 contents 행이 아직 없다면            범위 1군데
       C7 { subtype:video, platform_key:"7412…" }   ← 컬럼만 갱신
       token_links 는 손대지 않는다

    ② 이미 C9 로 존재한다면                          범위 3군데
       C9.source_urls 에 "vm.tiktok.com/ZSABC123" 추가
       token_links 재연결 { object_id:C9, subtype:video,
                            source_url:"vm.tiktok.com/ZSABC123" }  ← 그대로 남는다
       C7 삭제
비객체를 contents 에 합친 결과, 이 케이스가 컬렉션 간 이동에서 컬렉션 내 갱신으로 내려왔습니다. 기존 행과 만나는 경우에만 병합이 필요합니다. 어느 쪽이든 링크의 source_url 은 바뀌지 않아 "그때 우리가 본 것은 단축 링크였다"는 관측 사실이 보존되고, 재할당 이력 컬렉션이 따로 필요 없습니다.
범위 3군데두 객체가 사실은 하나였음 — 병합
t1  instagram.com/p/Bjnx75lFFQp            → contents C1 { platform_key:null }
t2  instagram.com/{handle}/reel/DZ8NYQ8xnRp → contents C2 { platform_key:null }
    둘 다 유료 게이트라 id 가 없어, 같은 게시물인지 알 수 없다

t3  유료 옵션을 켜고 재관측 → 둘 다 platform_key "17954…" 로 밝혀짐
    1. C2.source_urls 에 C1 의 URL 들을 합친다
    2. token_links 중 object_id=C1 인 것을 전부 C2 로 재연결
    3. C1 삭제
결제하지 않는 한 이 병합은 일어나지 않습니다. Instagram 은 audit 표본 20건이 전부 skipped_paid 였고, platform_key 없이는 두 URL 이 같은 게시물인지 판정할 근거가 아예 없습니다. 그동안은 두 객체로 남습니다 — 어떤 스키마를 써도 같습니다.
재할당의 원자성

범위 3군데짜리 재할당은 여러 컬렉션을 순차로 고칩니다. Phase 1.6 에서 트랜잭션 처리를 명시적으로 제외했으므로 (be-system-design.md §6), 중간에 프로세스가 죽으면 링크 일부만 재연결된 상태가 남습니다. 다만 그 상태도 링크가 유효한 객체를 가리키고 있다는 점은 유지되므로 조회가 깨지지는 않고, 재분류 배치를 다시 돌리면 수렴합니다.

§5쿼리 적합성 평가

이 구조가 못 하는 쿼리가 무엇인지, 그 쿼리가 실제로 필요한지를 따졌습니다. 요구사항은 available-queries.md 의 탐색 경로 15개를 그대로 썼습니다. 다만 그 문서가 제안한 스키마는 다릅니다tokens.contentRefs[] · contents.linkedTokenAddresses[] 양방향 배열 구조인데, 판단 기록 3 에서 검토해 채택하지 않은 방식입니다. 그래서 경로 목록만 요구사항으로 사용했습니다.

경로쿼리판정근거
A-1토큰 → 콘텐츠가능token_links 1회 + 객체 4회. 병렬로 던지면 라운드트립 2회
A-2콘텐츠 → 토큰가능{object_id, linked_at} 복합 인덱스 1회
A-3KOL → 콘텐츠 → 토큰가능$lookup 체인. from 이 전부 상수라 단일 파이프라인
B-1알파 계정 → 최근 콘텐츠 → 토큰가능구조상 A-3 과 동일. 승률 지표는 파생 계층이라 별도
B-2토큰 → 콘텐츠 → 작성자 → 계정가능{$match:{object:'contents'}} 를 앞에 두면 from 이 상수가 됨
B-3계정 지표 급등 → 토큰스코프 밖account_metrics 시계열 컬렉션이 아직 없음. 구조와 무관
C-1라벨/키워드 → 토큰불편아래 약점 1
C-2플랫폼별 영향력 비교개선아래 이 구조가 유일하게 고치는 경로
D-1콘텐츠 속도 → 토큰가능스냅샷 → 콘텐츠 → 링크 → 토큰
D-2시세 스파이크 → 시간대 콘텐츠가능linked_at 범위 인덱스
D-3언급량-시총 다이버전스스코프 밖토큰 시세 시계열은 tracker 레포 소유. 복사 금지
E-1인용 계보가능lineage[] 유지. ObjectId[] 로 타입만 변경
E-2크로스 플랫폼 + 지갑가능accounts.known_wallets multikey 그대로
E-3코-멘션 네트워크경계 필요아래 약점 2
C-2 — 이 구조가 유일하게 고치는 경로

"틱톡 바이럴로 뜬 토큰과 트위터 인용으로 뜬 토큰 중 어느 플랫폼이 나은가" 라는 질문입니다. 현행 스키마에서는 tiktok_search 57건이 contents 에 들어가지 못해 TikTok 쪽 분모가 틀린 답이 나옵니다. 이 구조에서는 검색도 객체가 되고, token_links.platform 복사본 덕분에 링크 컬렉션 한 번 group 으로 끝납니다. draft 가 제기한 문제가 정확히 이것이었습니다.

약점 1 — 텍스트 검색이 여러 컬렉션으로 흩어진다 (C-1)

토큰 CA 나 키워드가 나오는 곳이 콘텐츠 본문만이 아닙니다. 설계 문서 §1.5 는 venues.description 에 대해 "토큰 CA 가 실제로 여기 노출됨(실측)" 이라고 적고 있고, accounts.bio 도 마찬가지입니다. 따라서 "CA 가 노출된 모든 곳"을 찾으려면 $unionWith 로 세 컬렉션을 합쳐야 하고, 텍스트 인덱스도 세 벌이 필요합니다.

가능성과 효용은 둘 다 높습니다. C-1 은 내러티브 클러스터링의 진입점입니다. 다만 이번 구조가 만든 문제가 아닙니다. contents/accounts/venues 분리는 원래부터 있었고 텍스트가 세 곳에 흩어진 것도 원래부터입니다. 비객체를 contents 에 합쳐도 그 행들에는 텍스트가 없어 대상이 늘지 않습니다.

약점 2 — mentions[]_id 로 참조하면 안 된다 (E-3)

이건 이번 구조가 새로 만드는 문제라 전제 6 으로 올렸습니다. 전제 3(링크는 _id 로 연결)을 mentions[] 에도 적용하면, 트윗에 언급된 계정마다 accounts 에 빈 행을 만들어야 합니다. x_tweet 만 3,879건이고 트윗 하나에 멘션이 여럿이라 빈 행이 폭증합니다.

그런데 멘션은 관계가 아니라 관측된 텍스트 사실입니다. 우리가 그 계정을 fetch 한 적이 없고 존재 여부도 모릅니다. 그래서 값 그대로 저장하고, 나중에 그 계정을 실제로 관측하면 platform_key 로 이어지게 하는 것이 맞습니다. E-3 의 코-멘션 클러스터링은 platform_key 기준 group 으로 풀립니다.

mentions: [{ platform_key: "1534…", screen_name: "kolguy" }]   ← _id 참조 아님
정정 — $lookup 다형 조인 제약은 실제로 걸리지 않았다

논의 과정에서 제가 "$lookupfrom 이 상수여야 해서 다형 조인이 안 된다"를 이 구조의 비용으로 반복해 지적했습니다. 15개 경로를 실제로 훑어보니 걸리는 경로가 없습니다. 실제 쿼리는 대상 종류를 지목하기 때문입니다 — "이 토큰의 콘텐츠", "이 토큰의 계정". {$match: {object: 'contents'}} 를 앞에 붙이면 from 이 상수가 됩니다. 종류를 안 가리고 조인해야 하는 경우는 "이 토큰이 건 모든 것의 상세를 한 번에" 정도인데, 그건 앱에서 4회 조회로 하는 편이 자연스럽습니다.

쿼리 비용 — "이 토큰의 모든 소셜"

1. token_links.find({ token_address: ABC })                    → 1회
2. object 별로 묶어 각 컬렉션에 _id $in 조회                   → 최대 4회 (병렬)
                                                               ─────────
                                                               순차 5회 · 병렬 2 라운드트립

토큰 100개를 한 번에 뿌려도 쿼리 수는 그대로다 — $in 으로 묶이므로 N+1 이 생기지 않는다.
플랫폼·종류만 필요하면 token_links 1회로 끝난다 (비정규화 복사).
종합

15개 경로 중 구조 때문에 못 하는 것은 0개입니다. 불편한 것이 2개(C-1 텍스트 분산, E-3 멘션 참조), 스코프 밖이 2개(B-3·D-3)이며, 나머지 11개는 인덱스로 바로 갑니다. 그리고 C-2 는 현행에서 틀린 답이 나오던 것이 이 구조에서 고쳐집니다.

§6현행 스키마 대비 변경 폭

대상변경내용
contents필드 추가·교체subtype, source_urls[] 추가. content_key(문자열 키) 제거 후 platform_key 로 대체 — 정체성은 _id 가 맡는다
accounts필드 추가·교체동일. account_keyplatform_key
venues필드 추가·교체동일. venue_keyplatform_key
contents subtype어휘 확장search·intent·shortlink·unknown 을 흡수한다 (판단 기록 0). 별도 비객체 컬렉션을 두지 않는다
sites미결웹 처리 방식이 정해지면 결정. §7 결정 1
content_token_links구조 변경token_links 로. content_keyobject + object_id + source_url. platform·subtype 비정규화 추가
…links.source_field제거판단 기록 2
tokens.unclassified_links[]제거모든 URL 이 객체가 되므로 역할이 사라진다
tokens.web{}미결웹을 페이지/도메인 2층으로 두면 지문이 그쪽으로 옮겨간다. §7 결정 1
contents.mentions[]유지_id 참조로 바꾸지 않는다 (전제 6). {platform_key, screen_name} 값 저장 유지
lineage[]타입 변경문자열 배열 → ObjectId 배열. 조상 객체가 먼저 존재해야 하는데, 전제 1·2 가 그것을 보장한다
content_metric_snapshots참조 변경content_keyobject_id
인덱스미정source_urls.url multikey · {object_id, linked_at} · {token_address} 까지만 확정. 나머지는 별도 설계

컬렉션 추가가 하나뿐이고 기존 마스터는 필드 두 개를 더하는 선에서 끝납니다. 아직 실데이터가 없어 마이그레이션 자체가 없습니다.

§7남은 결정 항목

  1. 결정 1 · 웹사이트 — 별도 작업으로 이관. 조사 기간이 길고, 잠정 규칙을 두면 나중 결정이 전부 덧붙이기가 되어 구현을 막지 않는다. 잠정 규칙과 근거는 아래에.
  2. subtype 어휘를 무엇으로 확정할 것인가. 특히 Telegram 의 portal·shell 은 URL 만으로는 판정할 수 없고 프리뷰를 받아야 갈립니다 (실측 24건: 포털 54% · 빈껍데기 25% · 활성 4%). 이것을 subtype 으로 볼지, 관측 결과 필드로 내릴지 정해야 합니다.
  3. source_urls[] 항목에 구간을 넣을 것인가. TikTok 은 핸들을 한 달 주기로 회전시키고 옛 핸들을 재할당합니다. 같은 URL 이 시기별로 다른 계정을 가리킬 수 있으므로, 항목에 first_seen/last_seen 을 넣으면 as-of 로 풀 수 있습니다. accounts.handles[] 가 이미 같은 방식이라 새 개념은 아닙니다.
  4. status 를 어디에 둘 것인가. 지금 source_urls[].status 와 링크 쪽 상태가 둘 다 존재할 수 있습니다. 링크는 append-only 라 상태 이력을 담고 객체 쪽은 최신값이 되는데, 둘의 역할 분담을 명시해야 합니다.
  5. 텍스트 검색 대상 범위. contents.text 만 볼지, venues.description· accounts.bio 까지 $unionWith 로 묶을지 (§5 약점 1).
  6. 재분류 배치의 트리거. classifier 를 추가할 때마다 돌릴지 주기적으로 돌릴지에 따라, 범위 1군데짜리 재할당이 언제 정리되는지가 달라집니다.

결정 1 상세 — 웹사이트 (별도 작업으로 이관)

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

2번이 핵심입니다. 잠정 기간에도 도메인 공유 카운트가 기존 경로로 계속 작동합니다 — tokens.count({ fingerprints: "domain:foo.com", createdAt: {$lt: T} }). 검증된 주력 신호(knowyourmeme.com 22건 rug 100%)가 끊기지 않습니다.

왜 이 순서가 안전한가. "귀속"을 기본값으로 두면 나중의 "분리"가 전부 덧붙이기입니다 — sites 컬렉션을 만들고 contents(web) 행에 site_id 를 채우는 백필이면 끝이고, 링크는 손대지 않습니다. 반대 순서였다면 파괴적입니다. 도메인 단위로 먼저 만들면 나중에 페이지로 쪼갤 때 과거 관측을 되살릴 수 없습니다.

공짜는 아닙니다. 최종 결정이 "링크가 페이지가 아니라 도메인을 가리켜야 한다" 로 나오면 재연결이 필요합니다. token_links.source_url 이 관측 URL 을 보존하므로 복구는 가능합니다. 다만 토큰이 실제로 건 것은 페이지이고 도메인은 그 상위 개념이라, 링크의 도착점은 페이지가 맞다고 봅니다.

웹은 지문의 출처부터 두 층으로 갈려 있습니다. 필드 인벤토리(sources/website-image.html)를 소스별로 정리하면 이렇습니다.

소스필드채움%무엇의 속성인가
RDAPdomainCreatedDate · domainExpiryDate · registrar81.7 · 81.6 · 82.2%apex 도메인rdap.org/domain/{apex} 로 조회
HTML/OGisNews · ogTitle · ogSiteName76.0 · 46.0 · 25.8%개별 페이지 — 그 URL 을 GET 해서 파싱. ogTitle 실측 예시가 "Grand Theft Auto VI - Rockstar"
urlscanserver · ip · asn · techCount · certValidFrom49~57%개별 URL — 그 URL 의 스캔 기록

그리고 검증된 신호가 두 층에 갈려 있습니다. 도메인 층에는 도메인 나이 731일+(rug 85/84/91%)와 도메인 공유 카운트(knowyourmeme.com 22건 rug 100% · axiom.trade 21건 90% · orynth.dev 18건 0%)가 있고, 페이지 층에는 isNews = true(rug 80/89/100%)와 technologies 4개+(rug 83/87/98%)가 있습니다. 도메인으로만 접으면 페이지 층이 뭉개지고, 페이지로만 두면 도메인 지문이 페이지마다 복사됩니다.

논의한 방향 (미확정)
contents  { platform:web, subtype:page|article, url_key:"정규화 URL",
             og_title · is_news · server · tech_count,   // 페이지 층
             site_id → sites._id }
sites     { url_key:"apex 도메인",
             domain_created_at · domain_expiry_at · registrar }   // 도메인 층

도메인 공유 카운트 = site_id 가 같은 contents 들의 링크 수
페이지 카운트     = url_key 별

이 방향이면 token_links.objectcontents · accounts · venues 세 값을 유지하고, sites 는 링크가 직접 가리키지 않는 도메인 층 앵커가 됩니다.

확정하지 못한 이유

website URL 중 apex 만인 비율을 모릅니다. 대부분이 apex 라면 contents(web)sites 가 거의 1:1 이 되어 2층이 과설계입니다. audit 샘플에 website 파일이 없어 (samples/ 에 소셜 13종만 있음) 지금은 측정할 수 없습니다.

접는 것(페이지 행을 도메인으로 병합)은 나중에도 가능하지만 펴는 것은 관측 이력이 없어 불가능하므로, 측정 전이라면 2층으로 시작하는 쪽이 안전합니다.

선행 과제 — 웹을 키로 쓰기 전에 고쳐야 하는 코드 2건

1. normalize() 가 실제로는 정규화를 하지 않습니다. social-fetcher.router.ts:110 의 주석은 "소문자 host · www 제거 · trailing 정리" 라고 하지만, host 변수만 www. 를 떼고 반환값 urlu.toString() 원본입니다. 그래서 www.foo.com/promo · foo.com/promo · foo.com/promo?utm_source=x · foo.com/promo/ 가 전부 다른 문자열입니다. 소셜은 키를 segments[] 에서 뽑아 문제가 없었지만, 웹 페이지는 URL 자체가 키라 바로 갈라집니다.

2. apexDomain() 이 마지막 두 라벨만 봅니다. 같은 파일의 labels.slice(-2).join('.')shop.foo.co.ukco.uk 가 됩니다. 즉 모든 .co.uk 사이트가 한 도메인으로 접힙니다. 지금은 sourceKey 로만 쓰여 티가 안 났지만, sites.url_key 가 되는 순간 검증된 주력 신호인 도메인 공유 카운트가 오염됩니다. Public Suffix List 기반으로 바꿔야 합니다. 영향 규모는 website 표본이 없어 아직 모릅니다.

웹 URL 정규화 규칙 초안 — 소셜과 달리 URL 자체가 키라서 명시가 필요합니다.

항목규칙이유
스킴제거http/https 가 같은 페이지를 가름
www.제거같은 페이지
호스트 대소문자소문자DNS 가 대소문자를 구분하지 않음
서브도메인유지app.foo.comfoo.com 은 다른 페이지. 단 site_id 는 같음
경로 대소문자유지서버가 구분함
trailing /제거같은 페이지
fragment #제거서버에 전송되지 않음
추적 파라미터제거utm_* · fbclid · ref · gclid
그 외 파라미터유지 + 키 순 정렬?id=123 이 페이지를 가르는 사이트가 있음

이 문서에 없는 것 — 텍스트·지표·프로필 등 상세 필드, 인덱스 설계, 그리고 draft 의 나머지 항목 (공통 필드 최소화, Creator 재정의, 최적화용/공용/개별 필드의 문서 표기 구분). 전부 위 결정들이 확정된 뒤에 붙습니다.