컬렉션 6개의 최종 필드 구성과 그 구성을 정한 판정 규칙. (2026-08-07 object_raw 제거로 7 → 6)
v4 를 전수조사해 메모 22건과 추가 발견 7건을 판정한 결과다. "왜 그렇게 됐나" 는 docs/features/social-recording-refactor/schema-refactor-plan.md 가, "무엇이 됐나" 는 이 문서가 담는다.
object_raw 가 사라졌다컬렉션 7개가 아니라 6개다. object_raw 컬렉션과 그것을 채우던 SocialObjectGraph.raw 필드, 그리고 관련 배선이 전부 제거됐다(06e85b0).
이유는 이 문서가 그 컬렉션을 정당화한 근거 그 자체였다. 여기서는 payload 를 "정의상 무손실 덤프" 라고 적었는데, 실제로 저장되던 값은 SDK 의 transform 을 이미 거친 결과였다. 원문은 그보다 위로 올라온 적이 없다 — twitter-api.sdk.ts 에서 응답 본문이 변환 함수로 들어가고 함수 안에서 사라진다. 무손실이라는 명분을 스스로 어기고 있었다.
게다가 그 값의 필드가 거의 전부 ContentInput·AccountInput 으로도 가고 있어 같은 값을 두 번 저장하는 셈이었다.
원문 보존을 포기한 것은 아니다. 다만 그 일이 일어날 자리가 SDK 층이라는 것이 확인됐고, 되살릴 때는 정체성 축이 달라진다 — 옛 인덱스는 (object, object_id) 였는데 fetch 시점에는 object_id 가 아직 없다. 반쯤 맞는 스키마를 남기지 않으려고 데이터가 0건인 지금 지웠다.
근거: docs/features/social-generator/decisions.md G-8(폐기) · G-12(대체 방향).
이 문서는 레포의 현재 상태다. 판정 26건이 스키마·리포지토리·Writer·프로세서·분류 레이어에 반영됐고, 단위 테스트 510개가 이 계약을 고정한다.
미결로 남긴 것 셋은 §7 에 있다 — 동시 생성 중복 · TokenWeb · 재관측 업데이트. 이 셋은 의도적으로 손대지 않았다. 이 중 «재관측 업데이트» 는 2026-08-13 에 결정됐다 — 재관측은 아무것도 안 덮고 변하는 값은 전부 주기성 갱신 프로세스가 맡는다(§7).
⚠️ 통합 테스트는 아직 돌리지 않았다. .env.test 와 이 레포의 test 컨테이너가 필요하다(npm run test:integration:full). v4 인덱스가 남아 있으면 이름 충돌이 나므로 스키마 미배포 상태에서 인덱스를 드롭하고 실행한다.
이 절이 답하는 것 — 한 문장으로 요약하면 무엇이 달라졌는가.
파생 가능한 것과 채우는 주체가 없는 것을 걷어냈고, 검증을 코드에서 스키마로 되돌렸다.
Mixedobject_raw.payload 는 컬렉션과 함께 사라졌다(2026-08-07)(platform, platform_key) 로 통일됐다| 변화 | 내용 | 왜 |
|---|---|---|
| ① 정체성 축 통일 | contents 의 source_urls 를 없애고 platform_key 를 필수로 올렸다. venues·contents 의 조회 키에서 subtype 을 뺐다. |
정규화 URL 은 경로 접미사(/photo/1)와 모르는 쿼리 파라미터(?s=20)를 남겨 같은 대상이 두 문자열이 된다. 그리고 subtype 은 상태라서, 텔레그램 빈 채널이 차면(shell→channel) 같은 채널이 두 행이 됐다. |
② Mixed 제거 |
지표와 data 를 타입 클래스로 바꿨다. METRIC_SPEC 화이트리스트와 pickKnownMetrics 를 삭제한다. |
Mixed 는 mongoose 검증이 0 이라, 그 역할을 하는 레지스트리와 필터 함수를 따로 만들어야 했다. 재구현이라 버그가 났다 — 계정 지표가 전량 버려지고 빈 점이 저장되고 있었다. |
| ③ 시각 필드 정리 | observed_at 4곳 · linked_at · discovered_at 을 지우고 created_at/updated_at 로 대체했다. |
전부 created_at 과 큐 지연·fetch 소요만큼만 달랐다. 그 차이는 세상의 성질이 아니라 우리 인프라의 성질이다. |
| ④ 시계열 축소 | metric_series 가 다형 참조를 버리고 콘텐츠 전용이 됐다. object·platform 필드가 사라진다. |
계정·베뉴는 주기적 폴링 대상이 아니라 점이 하나뿐이었다. 그리고 다형을 유지하면 points[].metrics 가 세 모양을 다 담는 단일 뭉치 타입이어야 해서 ②와 충돌한다. |
| ⑤ 분류 결과 비저장 | tokens.social_urls[] 에서 source_field·source_type·source_key·platform·subtype 을 뺐다. 라우터는 platform 만 반환하고 게이트는 사라진다. |
다섯 필드가 전부 url 의 순수 파생이었다. 그리고 박제는 재시도에 손해다 — 분류 버그를 고쳐도 옛 판정으로 재시도하면 수정이 반영되지 않는다. |
이 절이 답하는 것 — 컬렉션들이 서로를 어떻게 가리키는가. (그림의 object_raw 상자는 2026-08-07 에 제거됐음을 표시한 채로 남겨둔다 — 지웠던 자리를 알아야 옛 코드·문서를 읽을 때 헷갈리지 않는다.)
metric_series 가 다형 참조선(점선)에서 내려왔다. v4 에서는 object_raw·token_links 와 같은 버스를 탔지만, v5 에서는 contents 를 직접 1:1 로 가리킨다. (object_raw 는 그 뒤 2026-08-07 에 제거됐다 — 지금 이 버스를 타는 것은 token_links 하나뿐이다.)
그 결과 points[].metrics 가 콘텐츠 지표만 담으면 되므로 타입을 붙일 수 있게 됐다. 다형을 유지했다면 계정·베뉴 지표까지 담는 단일 뭉치 타입이 필요했고, 그러면 Mixed 로 되돌아갔을 것이다.
이 절이 답하는 것 — 새 필드를 만들 때 어디에 두고, 무엇을 두지 않는가.
created_at/updated_at 이 답할 수 없을 때만 둔다두 단계로 판정한다.
created_at 과 구조적으로 갈리는가. 갈리지 않으면 두지 않는다.| 필드 | 판정 | 이유 |
|---|---|---|
| contents.published_at | 유지 | 소셜에 게시된 시각 — 세상의 성질 |
| tokens.first_transfer_at | 유지 | 체인의 사건 시각 — 세상의 성질 |
| metric_series.points[].at | 유지 | 배열 요소라 부모의 created_at 이 대신할 수 없고, 값의 출처가 소셜의 크롤 시각이다 |
| accounts.handles[].first_seen | 유지 | 배열 요소이며 개명 시점을 나타낸다 |
| tokens.social_urls[].attempted_at | 유지 | 배열 요소이며 "호출을 실제로 했는가" 를 나타낸다 |
| contents · accounts · venues 의 observed_at | 삭제 | fetch 소요 시간만큼만 다르다 — 인프라의 성질. object_raw 도 같은 판정이었으나 컬렉션 자체가 사라졌다(2026-08-07) |
| token_links.linked_at · observed_at | 삭제 | 큐 지연만큼만 다르다. 백필이 이벤트 재생이 아니라 실행 시점 기준이므로 갈릴 여지가 없다 |
| tokens.discovered_at | 삭제 | $setOnInsert 라 문서 생성 순간에만 찍히는데, created_at 도 정확히 그 순간이다 |
큐가 크게 밀린 구간(배포·장애·크레딧 소진)에서는 created_at 이 실제 사건보다 늦게 찍힌다. 분석 해상도가 분 단위면 문제가 되지만, 이 데이터의 소비처는 토큰 간 상대 평가라 시간·일 단위로 본다. 그 전제가 바뀌면 이 규칙부터 다시 연다.
url 로 끝나는 필드에 값이 있다는 것 자체가 "정규화에 성공했다" 를 뜻한다. 예외 필드도, raw_ 류 접두어도 두지 않는다.
정규화에 실패한 URL 은 버린다. 실패하는 실제 경우는 우리 코드의 버그가 아니라 쓰레기 입력이다 — tracker 의 소셜 필드는 토큰 발행자가 채우는 자유 텍스트라 "none"·"TBA" 같은 값이 온다. 예외를 던지지 않고 그 URL 만 건너뛴 뒤 경고 로그를 남긴다.
| 경우 | 예 | 처리 | 이유 |
|---|---|---|---|
| 정규화 실패 | "none" · "TBA" | 버린다 | 회수될 가능성이 없다 |
| 플랫폼 미상 | https://randomsite.com | 기록한다 | 소셜을 하나 더 지원하면 회수 대상이 된다. platform = null → unsupported |
URL 필드는 넷뿐이다. tokens.social_urls[].url · token_links.entry_url · contents.outbound_urls[] · accounts.outbound_urls[].
token_links.source_url → entry_url — source_ 접두어를 쓰던 필드가 이번에 전부 사라지므로(source_field·source_key·source_type), 하나만 남으면 없어진 가족의 생존자가 되어 더 헷갈린다. entry 는 fan-out 된 객체들이 왜 같은 값을 갖는지도 이름으로 설명한다.
links → outbound_urls — token_links 컬렉션과 겹치는 모호함이 사라지고 방향이 이름에 드러난다.
data · 버림| 값의 성격 | 자리 |
|---|---|
| 쿼리·정렬·비교의 축이거나, 소셜 공통 의미를 갖는다 | 최상위 필드 |
| 플랫폼별 부가 정보이고, 문서를 쥔 뒤 읽는다 | data 안의 타입 필드 |
| 구조가 복잡하고(중첩 객체·객체 배열) 쓸 계획이 없다 | 버린다. ⚠️ object_raw 가 제거되면서(2026-08-07) 버린 값은 정말로 사라진다 — 되찾으려면 다시 fetch 해야 한다 |
세 번째가 경계를 지킨다. 타입화의 비용은 필드 개수가 아니라 중첩에서 온다 — 어떤 소셜이 객체 배열을 돌려주면 서브클래스를 또 만들어야 하고, 그러면 부가 정보 하나에 클래스 트리가 생긴다.
승격 규칙: data 안의 값이 쿼리·정렬의 축이 되는 순간 최상위 필드로 올린다. data 는 "아직 축이 아닌 것" 의 대기실이다.
(platform, platform_key) 하나다| 컬렉션 | 정체성 축 | subtype 필드 |
|---|---|---|
| accounts | (platform, platform_key) | 없음 — platform 을 알면 종류가 결정된다 (gh→owner, rd→user) |
| venues | (platform, platform_key) | 있음 — portal/shell/channel 은 상태다 |
| contents | (platform, platform_key) | 있음 — tweet/video/post 종류 |
subtype 을 정체성에서 뺀 이유 — 막으라고 건 인덱스가 갈라짐을 허용했다v4 의 uniq_venues_platform_key 는 (platform, subtype, platform_key) 였다. 그런데 텔레그램은 같은 채널이 channel·portal·shell 로 갈리고 그 판정이 fetch 후에 나온다.
1월 토큰 A 가 t.me/foo 를 검. 메시지 0 → subtype = shell → venues 행 생성 (tg, shell, foo) 3월 토큰 B 가 t.me/foo 를 검. 커뮤니티 활성화 → subtype = channel → findByPlatformKey(tg, channel, foo) 가 못 찾음 → venues 행 하나 더 생성 (tg, channel, foo)
같은 채널이 두 행이 되고 unique 인덱스는 키가 다르므로 막지 못한다. 드문 경우도 아니다 — 실측이 빈껍데기 25% 인데, 빈 채널이 나중에 차는 것은 토큰 프로젝트의 정상 진행이다.
shell → channel 은 다른 베뉴가 된 것이 아니라 같은 베뉴가 채워진 것이다. 상태를 정체성에 넣으면 상태가 바뀔 때마다 새 객체가 생긴다.
이 절이 답하는 것 — 각 컬렉션의 최종 필드 구성.
① tokens.fingerprints[]·tokens.web 둘 다 삭제됐다(2026-08-13). 아래 tokens 표는 아직 fingerprints[] 를 필수·유지로, web 을 "보류"로 적고 있다. 실제로는 TokenWeb 이 통째로 폐기되고 신설 TokenImage(token-image-fields)와 Content.data 의 웹 지문 필드군(domainCreatedDate·registrar·certIssuer 등, W-9)으로 대체됐다.
② 인덱스 21개 표기가 index-audit(2026-08-12) 결과를 반영하지 않는다. contents·accounts·venues·token_links 에서 다수 인덱스가 삭제됐고(호출부 없음), contents 의 (platform, platform_key) unique 인덱스는 오히려 2026-08-13에 다시 걸렸다. §5 표는 이 변경 이전 상태다 — 최신 목록은 index-audit/index-audit.html 과 코드(*.model.ts 의 @index)를 본다.
③ contents.media_urls[]·accounts.avatar_url·venues.data.icon_url 이 이 문서 어디에도 없다. 전부 image-asset-fields(2026-08-08)에서 top-level 필드로 신설됐다 — 아래 contents/accounts/venues 표에 반영되지 않았다.
④ venues.data 의 Telegram 전용 필드군(guard_chat_ids[]·guard_bot·guard_setup_lag_sec·subscriber_count 등)도 이 문서 이후에 추가됐다.
즉 판정 규칙(§3, D-1~D-8)과 구조 패턴은 여전히 유효하지만, 구체 필드 목록은 코드(src/modules/social-graph/{objects,token,observations}/*.model.ts)가 최종 근거다.
모든 컬렉션이 BaseModel 을 상속해 _id·created_at·updated_at 을 갖는다. 아래 표에는 적지 않는다. ● 필수 · ○ 선택.
tokens| 필드 | 필수 | v4→v5 | 비고 |
|---|---|---|---|
| address | ● | 유지 | mint 주소. unique. token_links.token_address 의 조인 대상 |
| symbol | ○ | 유지 | 표시용 사본. SoT 는 sol-alpha-finder-tracker |
| first_transfer_at | ○ | 유지 | 나이 앵커. 세상의 성질이라 남는다. 현재 채우는 주체가 없다 |
| fingerprints[] | ● | 유지 | "kind:value" 평면 문자열. multikey 인덱스 하나로 군집 조회 |
| web | ○ | 보류 | TokenWeb 17필드. 채우는 주체가 없다 — 웹 수집 작업에서 재검토 |
| social_urls[] | ● | 축소 | { url, status, attempted_at, attempts } — 4필드. 아래 상자 참조 |
| — | 삭제 | created_at 이 대신한다 |
social_urls[] 에서 5필드를 뺀 이유source_field·source_type·source_key·platform·subtype 이 전부 url 의 순수 파생이었다. 라우터는 I/O 없는 순수 함수이고, fetch 이후 이 값들이 갱신되는 경로가 없었다.
비정규화 근거였던 "플랫폼별 질의가 인덱스로 끝난다" 는 성립하지 않았다 — tokens 의 인덱스에 social_urls.platform 이 없다.
박제 근거였던 "재시도 배치가 다시 분류하지 않게" 도 재시도에는 손해다 — 분류 버그를 고쳐도 옛 판정으로 재시도하면 수정이 반영되지 않는다. 재시도는 현재 라우터의 답을 써야 맞다.
대가: "유료를 켰으니 tiktok 만" 같은 플랫폼 한정 조회가 인덱스로 끝나지 않는다. status ≠ ok 인 토큰을 전부 읽고 앱에서 걸러야 한다.
token_links| 필드 | 필수 | v4→v5 | 비고 |
|---|---|---|---|
| token_address | ● | 유지 | ObjectId 를 쓰지 않는다 — 토큰은 재분류·병합이 없다 (D-2 예외) |
| object | ● | 유지 | 다형 참조 대상 컬렉션. $lookup.from 이 상수여야 해 집계가 이 값으로 먼저 갈린다 |
| object_id | ● | 유지 | "이 객체를 건 토큰 수" 의 유일한 경로 |
| entry_url | ● | 개명 | 구 source_url. fan-out 된 객체들이 같은 값을 공유한다 |
| platform | ● | 유지 | 대상에서 복사. idx_links_platform_linked 가 실제로 쓴다 |
| subtype | ○ | 유지 | 대상에서 복사. accounts 링크에는 값이 없다 |
| link_depth | ● | 유지 | 0=직접 · 1=인용 경유. 상한 LINK_MAX_DEPTH - 1 |
| — | 삭제 | created_at 이 시각축이 된다 | |
| — | 삭제 | 읽는 코드가 없고, 원본조차 채워지지 않는다 |
contents| 필드 | 필수 | v4→v5 | 비고 |
|---|---|---|---|
| platform · subtype | ● | 유지 | subtype 은 조회축으로만 쓴다 |
| platform_key | ● | 필수화 | 이 컬렉션의 유일한 정체성 축 |
| creator_id · venue_id · parent_content_id | ○ | 유지 | 자기 참조 인덱스가 별도 엣지 컬렉션을 대체한다 |
| parent_relation | ○ | 유지 | quote · retweet · fork. 참조하는 쪽의 속성이다 |
| published_at | ○ | 유지 | 세상의 성질 |
| text | ● | 유지 | { primary, body, from_media } |
| outbound_urls[] | ● | 개명 | 구 links. 본문의 외부 링크. 제너레이터가 직접 정규화한다 |
| tags[] · mentions[] | ● | 유지 | 멘션은 _id 참조가 아니다 — 관측하지 않은 대상이라 값으로 남긴다 (D-6) |
| metrics_latest | ○ | 타입화 | ContentMetrics. 시계열의 최신 한 점 캐시. SoT 는 metric_series |
| data | ● | 타입화 | ContentData — 빈 클래스로 시작. 설계 문서에 목록이 없다 |
| — | 삭제 | 정규화 URL 은 정체성 축이 못 된다. 아래 상자 참조 | |
| — | 삭제 | 구조적으로 채울 수 없었다. 아래 상자 참조 | |
| — | 삭제 | 채우는 집계 잡이 없다. §6 에 요구사항만 남긴다 | |
| — | 삭제 | 재관측이 행을 덮지 않으므로 created_at 과 같다 |
source_urls 를 뺀 이유 — 놓치는 축이 먼저 돌고 있었다v4 의 중복 방지는 2축이었다 — source_urls.url 먼저, 없으면 platform_key.
같은 트윗을 가리키는 두 URL: https://x.com/elonmusk/status/123456/photo/1?s=20 ← 공유 버튼 링크 https://x.com/elonmusk/status/123456 ← 주소창 복사 정규화 후에도 다른 문자열이다. 경로 접미사와 모르는 파라미터가 남는다. 그런데 분류는 둘 다 sourceKey = "123456" 을 준다 — segments.indexOf('status') 로 위치를 찾아 그 다음 값만 읽기 때문이다.
즉 URL 문자열로 중복을 판정하면 놓치고, 키로 판정하면 잡는다. 더 약하고 더 비싼 축(URL 개수만큼 쿼리)이 먼저 돌고 있었다.
그리고 ContentSourceUrl 이 객체일 이유도 없어졌다 — status 는 R-002 이후 구조적으로 ok 외의 값이 들어올 수 없고, observed_at 은 규칙 ①로 제거 대상이며, 둘을 읽는 isAlive() 는 프로덕션 호출부가 0 이었다.
author 를 뺀 이유 — 유일한 생산자가 값을 만들 수 없다ContentAuthorSnapshotInput.accountId 가 필수인데, ContentInput 을 만드는 주체는 제너레이터이고 제너레이터는 _id 를 볼 수 없다. _id 는 Writer 가 계정 행을 만들거나 찾을 때 생긴다. 그리고 Writer 는 author 를 건드리지 않는다.
담으려던 값 5개 중 4개가 이미 다른 곳에 있었다 — account_id는 creator_id, handle은 accounts.handles[](더 강하다), account_created_at은 accounts, followers는 metric_series. 존재 근거였던 "마스터는 덮인다" 는 R-009 로 사라졌다.
accounts| 필드 | 필수 | v4→v5 | 비고 |
|---|---|---|---|
| platform · platform_key | ● | 유지 | 정체성 축. unique |
| handles[] | ● | 축소 | { value, first_seen } — 배열 순서가 개명 순서다 |
| account_created_at | ○ | 유지 | rug 1순위 신호. 6개 중 5개가 준다 — Instagram 만 구조적 부재 |
| display_name · bio | ○ | 유지 | 지갑 주소가 실제로 bio 에 노출된다 |
| outbound_urls[] | ● | 개명 | 구 links. 프로필 소개란의 링크 |
| known_wallets[] | ● | 유지 | 온체인 ↔ 소셜 조인 축. bio 에서 추출한 파생 키라 bio 로 대체 불가 |
| unavailable | ○ | 펼침 | 구 moderation.unavailable. 필드 하나짜리 네임스페이스를 없앴다 |
| metrics | ○ | 유지 | AccountMetrics { followers? }. 시계열 대상이 아니라 _latest 가 아니다 |
| data | ● | 타입화 | AccountData — account_type·source_ref·badges·content_count·declared_handles |
| — | 이동 | GitHub 전용이고 조회축이 아니라 data 로 내렸다 | |
| — | 삭제 | isObservedLaterThan() 과 함께 삭제 |
handles[].last_seen 을 뺀 이유 — 소유 구간이 아니라 관측 구간이다우리는 토큰이 그 계정을 걸 때만 관측한다. 표본이 희소하고 임의적이라 "마지막으로 본 시각" 이 소유의 끝을 조금도 알려주지 않는다. 정밀해 보이지만 정밀하지 않으면서, 읽는 사람에게 "이건 소유 구간이다" 라고 오해시킨다.
first_seen 배열이 필요한 것을 이미 다 준다 — 현재 핸들은 배열 마지막, 개명 이력은 배열 순서, 개명 시점은 다음 엔트리의 first_seen.
핸들이 가변인 소셜: X · TikTok · Instagram · YouTube · GitHub. TikTok 과 GitHub 은 재할당까지 된다. Reddit 만 불변이라 platform_key 와 같은 값이지만, 일관성을 위해 handles[] 에도 넣는다.
venues| 필드 | 필수 | v4→v5 | 비고 |
|---|---|---|---|
| platform · platform_key | ● | 축 변경 | 정체성 축에서 subtype 을 뺐다 |
| subtype | ● | 유지 | portal/shell/channel — 정체성이 아니라 상태다 |
| name · description | ○ | 유지 | 토큰 CA 가 실제로 description 에 노출된다 |
| venue_created_at | ○ | 유지 | "런치 직전 급조" 신호 |
| creator_id | ○ | 유지 | → accounts. 핸들 fallback 금지 — 응답 키가 갈린다 |
| metrics | ○ | 유지 | VenueMetrics { members? } |
| data | ● | 타입화 | VenueData — moderators·admin_handle |
| — | 삭제 | 규칙 ① |
metric_series| 필드 | 필수 | v4→v5 | 비고 |
|---|---|---|---|
| content_id | ● | 단순화 | 구 (object, object_id) 다형. unique. 대상당 1행 |
| points[] | ● | 타입화 | { at, metrics: ContentMetrics } |
| — | 삭제 | 콘텐츠 전용이 되어 다형이 필요 없고, platform 은 조회 소비자가 없다 |
object_raw이 자리에는 원래 필드 3개짜리 표와, payload 만 Mixed 로 남기는 이유를 적은 상자가 있었다. 그 이유가 "이 필드는 정의상 무손실 덤프다" 였다.
그 전제가 틀렸다. 저장되던 값은 SDK 의 변환을 이미 거친 결과다. 응답 본문은 twitter-api.sdk.ts 의 변환 함수로 들어간 뒤 그 함수 안에서 사라지고, fetcher 도 생성기도 원문을 본 적이 없다. 그러니 Mixed 를 스키마 전체에서 유일하게 허용한 근거도 함께 무너졌다.
여기 "알려진 한계" 로 적혀 있던 것 — 원문이 루트 객체 1건만 저장되고 fan-out 객체는 원문이 없다 — 도 같은 방향의 신호였다. 무손실 보관소라면 그런 한계가 있을 이유가 없다.
그리고 그 값의 필드가 거의 전부 ContentInput·AccountInput 으로도 가고 있어 같은 값을 두 번 저장하고 있었다.
되살릴 자리는 SDK 층이다(decisions.md G-12). 그때는 정체성 축이 달라진다 — 옛 인덱스는 (object, object_id) 였는데 fetch 시점에는 object_id 가 아직 없다. 그래서 반쯤 맞는 스키마를 남기는 대신, 데이터가 0건인 지금 지웠다.
export class ContentMetrics { // 23필드, 전부 optional
// X — viewCount·likeCount 는 YouTube 공용
viewCount? likeCount? replyCount? retweetCount? quoteCount? bookmarkCount?
// TikTok — commentCount 는 YouTube 공용
playCount? diggCount? commentCount? shareCount? collectCount? repostCount?
// Instagram — commentsCount 는 Reddit 공용
likesCount? commentsCount? videoViewCount? videoPlayCount?
// Reddit
upVotes? score? upvoteRatio? numCrossposts?
// GitHub — 러그 검증 채널이라 도달·호응 축에 매핑하지 않는다
stars? forks? openIssues?
}
export class AccountMetrics { followers?: number }
export class VenueMetrics { members?: number }
키 이름은 지어내지 않고 각 fetcher 반환 타입의 실제 필드명을 그대로 쓴다. 통일하는 순간 TikTok 의 자동재생과 YouTube 의 30초 시청이 같은 칸에 들어간다.
v4 의 METRIC_SPEC 은 8개 집합이 전부 콘텐츠 응답 모양이었다(// XTweet·// TiktokVideo). followers 가 어느 집합에도 없어 계정 지표가 전량 버려지고 빈 점 { at, metrics: {} } 이 저장됐다.
그리고 recordFirstPoint 가 $setOnInsert 라 나중에 화이트리스트를 고쳐도 그 빈 점은 채워지지 않는다.
판정축이 "플랫폼 × 키" 였고 객체 종류 축이 빠져 있었는데, 그 사실을 아무것도 알려주지 않았다. 클래스를 종류별로 나누면 이 버그가 생길 수 없다.
mongoose strict 모드가 스키마에 없는 경로를 조용히 버린다. 그리고 타이핑이 100% 막지 못한다 — 제너레이터가 metrics: { ...response.stats } 처럼 스프레드로 넘기면 TS 의 초과 속성 검사가 적용되지 않아 컴파일을 통과한다.
대응은 계약으로 한다: 제너레이터는 필드 단위로 명시 매핑한다. 스프레드는 숫자가 아닌 필드까지 딸려 들어와 v4 구조에서도 잘못된 코드다.
strict: 'throw' 로 시끄럽게 만드는 안은 기각했다 — 소셜이 지표 하나 추가했다고 수집 파이프라인이 멈추는 것이 더 나쁘다.
이 절이 답하는 것 — 어떤 조회가 인덱스로 끝나는가.
| 컬렉션 | 이름 | 키 | v4→v5 |
|---|---|---|---|
| tokens | uniq_tokens_address | address (unique) | 유지 |
| idx_tokens_social_url_status | social_urls.status | 유지 재수집 배치의 조회축 | |
| idx_tokens_fingerprints | fingerprints | 유지 multikey | |
| idx_tokens_discovered | created_at ↓ | 축 변경 discovered_at 삭제에 따라. 이름도 정합성 확인 필요 | |
| token_links | idx_links_object_linked | object_id, link_depth, created_at | 축 변경 정렬축 linked_at→created_at |
| idx_links_token_linked | token_address, created_at | 축 변경 | |
| idx_links_platform_linked | platform, subtype, created_at | 축 변경 | |
| contents | idx_contents_platform_key | platform, platform_key | 축 변경 subtype 제거 · partial 제거 |
| idx_contents_platform_subtype | platform, subtype | 유지 아래 상자 참조 | |
| idx_contents_creator_published | creator_id, published_at ↓ | 유지 partial | |
| idx_contents_parent | parent_content_id | 유지 partial | |
| idx_contents_venue | venue_id | 유지 partial | |
| — | — | 삭제 | |
| accounts | uniq_accounts_platform_key | platform, platform_key (unique) | 유지 |
| idx_accounts_handle | platform, handles.value | 축 변경 platform 추가 | |
| idx_accounts_wallet | known_wallets | 유지 multikey | |
| venues | uniq_venues_platform_key | platform, platform_key (unique) | 축 변경 subtype 제거 |
| idx_venues_creator | creator_id | 유지 partial | |
| metric_series | uniq_series_content | content_id (unique) | 개명·단순화 구 uniq_series_object. |
컬렉션 제거 2026-08-07. 되살릴 때는 이 축을 못 쓴다 — fetch 시점에 object_id 가 없다 |
v4 에서 idx_contents_platform_key 는 (platform, subtype, platform_key) 였고, 이것이 idx_contents_platform_subtype (platform, subtype) 을 접두어로 포함했다. 즉 뒤의 것은 불필요한 중복이었다.
앞의 것에서 subtype 을 빼면 접두어 관계가 끊어져 둘 다 제 역할을 하게 된다. 의도한 효과는 아니었지만 결과가 맞다.
idx_tokens_discovered 와 idx_links_*_linked 는 이름이 discovered_at·linked_at 을 가리키는데 그 필드들이 사라진다. 정렬축이 created_at 이 되므로 이름이 실제와 어긋난다. 적용 시 개명 여부를 결정한다.
이 절이 답하는 것 — 소셜별 제너레이터가 지켜야 할 것.
여러 판정이 제너레이터에 조건을 걸었다. 흩어진 것을 하나로 모으면 이렇다.
generate(url, opts, observedAt): Promise<{
status: FetchStatus; // 유료·미지원 판정을 스스로 한다
attempted: boolean; // 외부를 실제로 불렀는가
graph: SocialObjectGraph | null;
}>
v4 의 SocialObjectGraph | null 로는 세 가지를 구분할 수 없다 — "유료라 안 불렀다" · "지원 안 해서 안 불렀다" · "불렀는데 대상이 없다". 이 셋이 tokens.social_urls[].status 에서 skipped_paid·unsupported·not_found 로 갈려야 한다.
attempts 집계도 같은 문제다. 게이트가 사라지면 외부를 실제로 불렀는지 아는 주체가 제너레이터뿐이다. v4 는 프로세서의 헬퍼가 "안 불렀을 것이다" 를 추정했는데, 새 구조에서는 부른 쪽이 직접 보고한다.
platform_key 가 있어야 한다. 계정·베뉴는 기존 계약이었고, 이제 콘텐츠까지 확대된다. 키를 만들 수 없으면 그 객체를 넣지 않는다.data 는 필드 단위로 명시 매핑한다. 스프레드로 넘기지 않는다.outbound_urls 는 본문에서 추출한 값이라 제너레이터가 직접 정규화해야 한다 — 라우터를 거치지 않는 유일한 URL 경로다.subtype 은 fetch 후 판정이며 정체성이 아니다. 같은 대상을 다시 만나면 subtype 이 달라질 수 있다.normalizeUrl() 을 라우터에서 url-normalizer.ts 로 뽑았다. 소비자가 셋이 됐기 때문이다 — 라우터(host 로 담당 플랫폼을 찾으려고), 소셜 fetcher(자기 classify 에 넘길 ParsedUrl 을 얻으려고), 그리고 Generator(outbound_urls 를 저장 전에 정규화해야 한다).
SocialPlatform 의 정의가 이사했다enum 정의를 common/social-fetcher 로 옮기고 social-graph.consts 가 재수출한다. 소셜 fetcher 가 UrlClassifier.platform 으로 자기 플랫폼을 선언하는데, 정의가 modules/ 에 있으면 common → modules 역방향 의존이 생긴다.
| 계층 | 책임 |
|---|---|
| router | 정규화 + host → platform 조회. 그 이상 모른다. 반환은 { url, platform } | null 이며, null 은 정규화 실패다 |
| generator | platform 으로 선택된다. 내부에서 fetcher 를 호출하고, fetcher 가 subtype·키를 판정하고 자기 정책(유료·미지원)을 스스로 적용한다 |
| processor | 결과를 받아 tokens 에 기록하고 Writer 를 호출한다. 소셜 지식 없음 |
"모르겠다" 는 route() 가 null 을 반환하는 것으로 표현한다. SocialPlatform 에 UNKNOWN 을 추가하지 않는다 — platform 은 token_links 에 저장돼 집계 축으로 쓰이는데 거기 unknown 이 섞이면 정체불명 버킷이 생긴다. 판정 실패는 값이 아니라 값의 부재다.
담당 fetcher 를 못 찾은 정상 URL 은 web 으로 떨어진다. 웹은 "아직 분류되지 않은 URL 을 담는 잠정 버킷"(§1.11)이고, 담당 Generator 가 없어 unsupported 로 끝난다.
수집 경로에서만 빠진다. SocialFetchGate 와 정책 집합(PAID_SOURCE_TYPES 등)은 social-fetcher 패키지에 남고, 어댑터(SocialFetcherService)가 계속 쓴다.
정책을 각 소셜 fetcher 로 흩는 안도 검토했으나 채택하지 않았다. 얻는 것은 "TikTok 은 Apify 라 유료" 가 TikTok fetcher 옆에 있다는 것뿐인데, 흩어 놓으면 "지금 뭐가 유료지" 를 보려고 소셜 일곱 곳을 봐야 한다. 한곳에 모여 있는 편이 낫다.
이 절의 목적은 정책의 위치가 아니라 sourceType 이 프로세서와 스키마로 새지 않게 하는 것이고, 게이트가 수집 경로에서 빠지면서 그것은 달성된다.
NONE_OBJECT_SOURCE_TYPES 와 UNRESOLVED_SOURCE_TYPES 는 이미 소비자가 없는 죽은 상수라 삭제한다.
SocialSourceType 자체는 남는다각 소셜 fetcher 가 내부에서 자기 URL 종류를 가르는 데 계속 쓴다. 사라지는 것은 그 값이 소셜 밖으로 새어 나가 라우터·게이트·프로세서·스키마를 지나던 경로다.
이 절이 답하는 것 — 이번에 정하지 않은 것과, 그것을 언제 정하는가.
| 항목 | 내용 | 언제 정하는가 |
|---|---|---|
| 동시 생성 중복 | 락이 토큰 주소 단위라 서로 다른 두 토큰이 같은 트윗·계정을 동시에 가리키면 막지 못한다. 지금 accounts·venues 는 예외가 터져 그 토큰이 통째로 죽고, contents 는 조용히 두 행이 생긴다. E11000 처리 코드가 레포 어디에도 없다. |
별도의 순차 처리 방식을 도입할 예정. 그것이 정해진 뒤 contents unique 여부와 E11000 재조회 경로를 함께 판단한다 |
TokenWeb |
17필드가 있으나 채우는 호출자가 0. 필드 구성·updateWeb() 의 통째 교체 정책·tokens 에 붙는 것이 맞는지 전부 미결 |
웹 수집 작업 시작 시 |
2026-08-13 에 결정됐다 — 미결이 아니다. resolveAccount·resolveVenue 는 기존 행을 찾으면 그대로 반환하고 아무것도 갱신하지 않는다. 그 동작이 최종 정답이고, 변하는 값의 갱신은 전부 주기성 프로세스가 맡는다 |
R-009 의 갱신 프로세스와 같은 작업이다 |
여기 쌓여 있던 3건은 "재관측 시 무엇을 덮을 것인가" 를 정해 달라는 요구였다. 답은 "아무것도 안 덮는다" 이고, 셋 다 지표와 같은 처리로 간다.
addHandleObservation 이 생성 시 1회만 불려서 handles[] 엔트리가 항상 1개다subtype 갱신 — shell → channel 진행을 반영해야 한다. 정체성 축에서 subtype 을 뺀 것이 노리는 동작이 이것이다outbound_urls·bio 등 갱신 — Writer 의 기존 MEMO 가 가리키는 자리R-009 를 뒤집지 않는다 — 전 필드로 확장한다. 예전 계획은 "지표는 계속 덮지 않되 subtype·handles·URL 은 갱신" 이었다. 그렇게 하면 같은 파이프라인 안에서 필드마다 규칙이 갈린다 — 어떤 값은 첫 관측이고 어떤 값은 마지막 관측이라, 한 행을 읽는 사람이 필드별로 시점을 따져야 한다.
그리고 R-009 의 근거가 지표에만 해당하는 것이 아니었다. 근거는 "발견될 때마다 찍으면 «누군가 이 대상을 건 시점» 에만 찍힌 불규칙 표본이 된다" 인데, 핸들 이력도 똑같다 — 재관측 경로로 쌓은 handles[] 는 "언제 바뀌었나" 가 아니라 "언제 누가 이 URL 을 걸었나" 를 기록하게 되고, 그것으로 핸들 재할당 시점을 추정하면 틀린 답이 조용히 나온다.
⚠️ 대가 — 주기성 프로세스가 생길 때까지 전 필드가 첫 관측에서 얼어붙는다. 특히 첫 관측이 fan-out 경로(인용 원본의 축약된 author)면 빈약한 행이 그대로 고착되고, 나중에 그 계정을 직접 fetch 해도 안 채워진다(H-008). 그것을 아는 상태로 고른 것이다 — 통제하지 않는 시점의 갱신은 정보성이 떨어진다는 판단이 그보다 앞선다.
⚠️ metric_series 를 접는 선택지가 약해졌다. R-009 는 "갱신 프로세스가 안 생기면 metric_series 를 접고 metrics_latest 만 남기는 것이 맞다" 는 탈출구를 남겨 뒀는데, 이제 그 프로세스에 걸린 것이 지표만이 아니다.
link_stats 를 되살릴 집계 잡을 만들 때link_count 는 distinct token_address 다. token_links 는 append-only 이고 유니크 제약이 없어 행 수 ≠ 토큰 수 다. 행을 세면 재처리 횟수가 인기도로 둔갑한다.computed_at 과 input_cutoff 를 반드시 함께 세팅한다. 소비 측이 값을 신뢰할 근거가 이 둘뿐이다.나래비 쿼리는 매번 계산이 성립하지 않는다 — 정렬 대상이 저장된 값이 아니라 계산 결과라 인덱스가 돕지 못하고, 상위 50개만 필요해도 전체를 다 세야 한다. 하루 토큰 1,000건 × URL 4개 × 객체 3개면 1년에 약 440만 행이다. 캐시는 선택이 아니라 필연이다 — 다만 어디에 둘지는 잡을 설계할 때 정한다.
지금은 팔로워·멤버 수의 시간 변화를 담을 곳이 없다. "큰 계정이 이 토큰을 밀었다" 에서 증가율을 볼 수 없고 현재값만 본다.
다만 지금은 실질 손실이 0 이다 — R-009 로 지표가 생성 시 1회만 기록되므로 계정 시계열이 있어도 점이 하나뿐이고, 그 값은 accounts.metrics.followers 와 같다. 손실은 계정 폴링 주체가 생기는 시점에 비로소 발생한다.
R-002 로 실패가 행을 만들지 않고, 미지원 타입에는 제너레이터가 없다. 담당 제너레이터가 없으면 그 subtype 은 영영 생기지 않는다.
| ContentSubtype | 플랫폼 | 생성 가능? |
|---|---|---|
| tweet | X | 가능 |
| video | TikTok · YouTube | 가능 |
| post | Instagram · Reddit | 가능 |
| repo | GitHub | 가능 |
| photo · reel · tv | TikTok · Instagram | 가능 fetch 후 세분화 |
| search | X · TikTok · Reddit | 불가 UNSUPPORTED — T-012 재검토 대기 |
| intent | X | 불가 UNSUPPORTED |
| shortlink | TikTok · Reddit | 불가 UNSUPPORTED |
| story | 불가 24h 소멸 | |
| gist | GitHub | 불가 엔드포인트 미구현 |
| unknown | YouTube playlist · web | 불가 UNSUPPORTED |
| site | web | 보류 웹 작업 |
삭제하지 않고 주석으로 표시만 한다. search 는 T-012 가 열려 있다 — 실측에서 tiktok_search 1회 호출($0.038)이 주제 적합 10/10 · 서로 다른 작성자 10명 · 총 조회 194만을 돌려줘 "검색 URL 은 객체가 없다" 는 전제가 반증됐다.
VenueSubtype 은 전량 살아 있다 — community(X) · subreddit(Reddit) · channel·portal·shell·guard_group(Telegram).
token_links.subtype 의 mongoose enum 검증이 ContentSubtype 과 VenueSubtype 의 합집합이라, 베뉴 링크에 tweet 이 들어가도 DB 는 통과시킨다. 다형 참조의 구조적 한계라 스키마로는 막을 수 없고 object 값과 함께 봐야 판정된다.
두 enum 을 합치지 않는 이유는 반대다 — 합치면 Content.subtype 에 channel 을 넣어도 컴파일이 통과한다. 객체 종류가 다르면 어휘도 다르다는 것이 지표 클래스를 셋으로 나눈 것과 같은 판단이다.
| 문서 | 담는 것 |
|---|---|
| schema-v5.html (이 문서) | 무엇이 됐나 — 최종 필드 구성과 판정 규칙 |
| social-recording-refactor/ schema-refactor-plan.md | 왜 그렇게 됐나 — 항목별 판정 근거, 기각한 대안, 영향 범위. 전수조사의 원본 기록 |
| social-recording-refactor/ url-pipeline.html | URL 이 어떻게 가공되나 — 정규화·분류 단계별 계약. 규칙 ②의 근거 |
| schema-v4.html | 이전 판. 이 문서가 대체한다 |
| social-url-structure-audit/ | 필드·valueNature·조인키의 실측 원천 |
src/modules/social-graph/ · src/modules/social-recording/ · src/common/social-fetcher/.