소셜 스키마 v5 — 확정 명세

컬렉션 6개의 최종 필드 구성과 그 구성을 정한 판정 규칙. (2026-08-07 object_raw 제거로 7 → 6)

v4 를 전수조사해 메모 22건과 추가 발견 7건을 판정한 결과다. "왜 그렇게 됐나" 는 docs/features/social-recording-refactor/schema-refactor-plan.md 가, "무엇이 됐나" 는 이 문서가 담는다.

⚠️ 2026-08-07 정정 — 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 인덱스가 남아 있으면 이름 충돌이 나므로 스키마 미배포 상태에서 인덱스를 드롭하고 실행한다.

§1v4 에서 무엇이 바뀌었나

이 절이 답하는 것 — 한 문장으로 요약하면 무엇이 달라졌는가.

한 문장

파생 가능한 것과 채우는 주체가 없는 것을 걷어냈고, 검증을 코드에서 스키마로 되돌렸다.

삭제된 필드·클래스
21
파생값 · 죽은 캐시 · 채울 주체가 없는 구조
남은 Mixed
0
v4 는 6곳. 마지막 한 곳이던 object_raw.payload 는 컬렉션과 함께 사라졌다(2026-08-07)
인덱스
21 → 19
삭제 2 · 축 변경 6
정체성 축
1개
세 객체가 전부 (platform, platform_key) 로 통일됐다

큰 변화 다섯

변화내용
① 정체성 축 통일 contentssource_urls 를 없애고 platform_key 를 필수로 올렸다. venues·contents 의 조회 키에서 subtype 을 뺐다. 정규화 URL 은 경로 접미사(/photo/1)와 모르는 쿼리 파라미터(?s=20)를 남겨 같은 대상이 두 문자열이 된다. 그리고 subtype 은 상태라서, 텔레그램 빈 채널이 차면(shellchannel) 같은 채널이 두 행이 됐다.
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 의 순수 파생이었다. 그리고 박제는 재시도에 손해다 — 분류 버그를 고쳐도 옛 판정으로 재시도하면 수정이 반영되지 않는다.

§2ERD

이 절이 답하는 것 — 컬렉션들이 서로를 어떻게 가리키는가. (그림의 object_raw 상자는 2026-08-07 에 제거됐음을 표시한 채로 남겨둔다 — 지웠던 자리를 알아야 옛 코드·문서를 읽을 때 헷갈리지 않는다.)

객체 3종 — 정체성 축이 전부 (platform, platform_key) 로 통일됐다 accounts _id ● platform ● platform_key ● handles[]{value, first_seen} outbound_urls[] known_wallets[] bio · display_name · unavailable metrics{followers} · data subtype 없음 — platform 이 종류를 결정한다 (gh→owner, rd→user) v5: source_urls·moderation·observed_at 제거 contents _id ● platform ● subtype ● platform_key ● creator_id ○ ─→ accounts venue_id ○ ─→ venues parent_content_id ○ (자기 참조) text · outbound_urls[] · tags[] mentions[] · published_at ○ metrics_latest{ContentMetrics} · data subtype 은 정체성이 아니라 속성 — 조회축으로만 쓴다 v5: source_urls·author·link_stats·observed_at 제거 venues _id ● platform ● subtype ● platform_key ● creator_id ○ ─→ accounts name · description venue_created_at ○ metrics{members} · data subtype 유지 — tg 가 channel·portal· shell 로 갈린다. 단 정체성에선 뺐다 v5: source_urls·observed_at 제거 venues.creator_id object + object_id 3종 중 하나를 가리킨다 object_raw 제거됨 (2026-08-07) 컬렉션 · SocialObjectGraph.raw · 배선 전부 무손실이라는 명분을 스스로 어기고 있었다 대체 방향은 decisions.md G-12 token_links object ● object_id ● token_address ● entry_url ● platform ● subtype ○ link_depth ● append-only · URL : link = 1 : N link_depth 0=직접 1=인용 경유 v5: linked_at·observed_at·first_transfer_at 제거 metric_series content_id ● (unique) points[]{ at, metrics: ContentMetrics } v5: 다형을 버리고 콘텐츠 전용이 됐다 — 대상당 1행 v5: object·platform 제거, object_id → content_id 1 : 1 직접 참조 tokens address ● (unique) · symbol ○ social_urls[]{url, status, attempted_at, attempts} fingerprints[] · first_transfer_at ○ · web ○ v5: 분류 결과 4필드 · discovered_at 제거 token_address
그림에서 읽어야 할 것 하나

metric_series 가 다형 참조선(점선)에서 내려왔다. v4 에서는 object_raw·token_links 와 같은 버스를 탔지만, v5 에서는 contents 를 직접 1:1 로 가리킨다. (object_raw 는 그 뒤 2026-08-07 에 제거됐다 — 지금 이 버스를 타는 것은 token_links 하나뿐이다.)

그 결과 points[].metrics콘텐츠 지표만 담으면 되므로 타입을 붙일 수 있게 됐다. 다형을 유지했다면 계정·베뉴 지표까지 담는 단일 뭉치 타입이 필요했고, 그러면 Mixed 로 되돌아갔을 것이다.

§3판정 규칙

이 절이 답하는 것 — 새 필드를 만들 때 어디에 두고, 무엇을 두지 않는가.

규칙 ① 시각 필드는 created_at/updated_at 이 답할 수 없을 때만 둔다

두 단계로 판정한다.

  1. 이 필드가 created_at구조적으로 갈리는가. 갈리지 않으면 두지 않는다.
  2. 갈린다면 그 차이가 우리 인프라의 성질인가 세상의 성질인가. 인프라의 성질(큐 지연·재시도 간격·처리 소요)이면 두지 않는다. 세상의 성질(체인 최초 전송 시각·소셜 게시 시각·소셜의 크롤 시각)이면 둔다.
필드판정이유
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 은 예외 없이 정규화된 값이다

url 로 끝나는 필드에 값이 있다는 것 자체가 "정규화에 성공했다" 를 뜻한다. 예외 필드도, raw_ 류 접두어도 두지 않는다.

정규화에 실패한 URL 은 버린다. 실패하는 실제 경우는 우리 코드의 버그가 아니라 쓰레기 입력이다 — tracker 의 소셜 필드는 토큰 발행자가 채우는 자유 텍스트라 "none"·"TBA" 같은 값이 온다. 예외를 던지지 않고 그 URL 만 건너뛴 뒤 경고 로그를 남긴다.

경우처리이유
정규화 실패"none" · "TBA"버린다회수될 가능성이 없다
플랫폼 미상https://randomsite.com기록한다소셜을 하나 더 지원하면 회수 대상이 된다. platform = nullunsupported

URL 필드는 넷뿐이다. tokens.social_urls[].url · token_links.entry_url · contents.outbound_urls[] · accounts.outbound_urls[].

개명 둘

token_links.source_urlentry_urlsource_ 접두어를 쓰던 필드가 이번에 전부 사라지므로(source_field·source_key·source_type), 하나만 남으면 없어진 가족의 생존자가 되어 더 헷갈린다. entry 는 fan-out 된 객체들이 왜 같은 값을 갖는지도 이름으로 설명한다.

linksoutbound_urlstoken_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% 인데, 빈 채널이 나중에 차는 것은 토큰 프로젝트의 정상 진행이다.

shellchannel 은 다른 베뉴가 된 것이 아니라 같은 베뉴가 채워진 것이다. 상태를 정체성에 넣으면 상태가 바뀔 때마다 새 객체가 생긴다.

§4컬렉션 명세

이 절이 답하는 것 — 각 컬렉션의 최종 필드 구성.

⚠️ 2026-08-14 정정 — 이 문서가 "현행"이라 자칭하지만 아래 4곳은 그 뒤 코드가 더 나갔다

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필드. 아래 상자 참조
discovered_at삭제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
linked_at · observed_at삭제created_at 이 시각축이 된다
first_transfer_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빈 클래스로 시작. 설계 문서에 목록이 없다
source_urls[]삭제정규화 URL 은 정체성 축이 못 된다. 아래 상자 참조
author삭제구조적으로 채울 수 없었다. 아래 상자 참조
link_stats삭제채우는 집계 잡이 없다. §6 에 요구사항만 남긴다
observed_at삭제재관측이 행을 덮지 않으므로 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_idcreator_id, handleaccounts.handles[](더 강하다), account_created_ataccounts, followersmetric_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타입화AccountDataaccount_type·source_ref·badges·content_count·declared_handles
declared_handles이동GitHub 전용이고 조회축이 아니라 data 로 내렸다
observed_at삭제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타입화VenueDatamoderators·admin_handle
observed_at삭제규칙 ①

metric_series

필드필수v4→v5비고
content_id단순화(object, object_id) 다형. unique. 대상당 1행
points[]타입화{ at, metrics: ContentMetrics }
object · platform삭제콘텐츠 전용이 되어 다형이 필요 없고, platform 은 조회 소비자가 없다

object_raw 컬렉션 제거 (2026-08-07)

왜 지웠나 — 무손실이라는 명분이 사실이 아니었다

이 자리에는 원래 필드 3개짜리 표와, payloadMixed 로 남기는 이유를 적은 상자가 있었다. 그 이유가 "이 필드는 정의상 무손실 덤프다" 였다.

그 전제가 틀렸다. 저장되던 값은 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' 로 시끄럽게 만드는 안은 기각했다 — 소셜이 지표 하나 추가했다고 수집 파이프라인이 멈추는 것이 더 나쁘다.

§5인덱스 — 21 → 19

이 절이 답하는 것 — 어떤 조회가 인덱스로 끝나는가.

컬렉션이름v4→v5
tokensuniq_tokens_addressaddress (unique)유지
idx_tokens_social_url_statussocial_urls.status유지 재수집 배치의 조회축
idx_tokens_fingerprintsfingerprints유지 multikey
idx_tokens_discoveredcreated_at ↓축 변경 discovered_at 삭제에 따라. 이름도 정합성 확인 필요
token_linksidx_links_object_linkedobject_id, link_depth, created_at축 변경 정렬축 linked_atcreated_at
idx_links_token_linkedtoken_address, created_at축 변경
idx_links_platform_linkedplatform, subtype, created_at축 변경
contentsidx_contents_platform_keyplatform, platform_key축 변경 subtype 제거 · partial 제거
idx_contents_platform_subtypeplatform, subtype유지 아래 상자 참조
idx_contents_creator_publishedcreator_id, published_at ↓유지 partial
idx_contents_parentparent_content_id유지 partial
idx_contents_venuevenue_id유지 partial
idx_contents_source_url삭제
accountsuniq_accounts_platform_keyplatform, platform_key (unique)유지
idx_accounts_handleplatform, handles.value축 변경 platform 추가
idx_accounts_walletknown_wallets유지 multikey
venuesuniq_venues_platform_keyplatform, platform_key (unique)축 변경 subtype 제거
idx_venues_creatorcreator_id유지 partial
metric_seriesuniq_series_contentcontent_id (unique)개명·단순화uniq_series_object. idx_series_platform 삭제
object_rawuniq_raw_objectobject, object_id (unique)컬렉션 제거 2026-08-07. 되살릴 때는 이 축을 못 쓴다 — fetch 시점에 object_id 가 없다
축을 바꾸면서 중복 하나가 저절로 해소된다

v4 에서 idx_contents_platform_key(platform, subtype, platform_key) 였고, 이것이 idx_contents_platform_subtype (platform, subtype)접두어로 포함했다. 즉 뒤의 것은 불필요한 중복이었다.

앞의 것에서 subtype 을 빼면 접두어 관계가 끊어져 둘 다 제 역할을 하게 된다. 의도한 효과는 아니었지만 결과가 맞다.

이름 정합성 — 적용 시 확인할 것

idx_tokens_discoveredidx_links_*_linked 는 이름이 discovered_at·linked_at 을 가리키는데 그 필드들이 사라진다. 정렬축이 created_at 이 되므로 이름이 실제와 어긋난다. 적용 시 개명 여부를 결정한다.

§6제너레이터 계약

이 절이 답하는 것 — 소셜별 제너레이터가 지켜야 할 것.

여러 판정이 제너레이터에 조건을 걸었다. 흩어진 것을 하나로 모으면 이렇다.

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 는 프로세서의 헬퍼가 "안 불렀을 것이다" 를 추정했는데, 새 구조에서는 부른 쪽이 직접 보고한다.

반환 트리가 지켜야 할 것 넷

  1. 모든 객체에 platform_key 가 있어야 한다. 계정·베뉴는 기존 계약이었고, 이제 콘텐츠까지 확대된다. 키를 만들 수 없으면 그 객체를 넣지 않는다.
  2. 지표와 data 는 필드 단위로 명시 매핑한다. 스프레드로 넘기지 않는다.
  3. 모든 URL 은 정규화된 값이다. outbound_urls 는 본문에서 추출한 값이라 제너레이터가 직접 정규화해야 한다 — 라우터를 거치지 않는 유일한 URL 경로다.
  4. subtype 은 fetch 후 판정이며 정체성이 아니다. 같은 대상을 다시 만나면 subtype 이 달라질 수 있다.
3번 때문에 정규화가 독립 모듈이 됐다

normalizeUrl() 을 라우터에서 url-normalizer.ts 로 뽑았다. 소비자가 셋이 됐기 때문이다 — 라우터(host 로 담당 플랫폼을 찾으려고), 소셜 fetcher(자기 classify 에 넘길 ParsedUrl 을 얻으려고), 그리고 Generator(outbound_urls 를 저장 전에 정규화해야 한다).

곁가지 — SocialPlatform 의 정의가 이사했다

enum 정의를 common/social-fetcher 로 옮기고 social-graph.consts 가 재수출한다. 소셜 fetcher 가 UrlClassifier.platform 으로 자기 플랫폼을 선언하는데, 정의가 modules/ 에 있으면 commonmodules 역방향 의존이 생긴다.

라우터와 게이트의 변화

계층책임
router정규화 + host → platform 조회. 그 이상 모른다. 반환은 { url, platform } | null 이며, null 은 정규화 실패다
generatorplatform 으로 선택된다. 내부에서 fetcher 를 호출하고, fetcher 가 subtype·키를 판정하고 자기 정책(유료·미지원)을 스스로 적용한다
processor결과를 받아 tokens 에 기록하고 Writer 를 호출한다. 소셜 지식 없음

"모르겠다" 는 route()null 을 반환하는 것으로 표현한다. SocialPlatformUNKNOWN 을 추가하지 않는다 — platformtoken_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_TYPESUNRESOLVED_SOURCE_TYPES이미 소비자가 없는 죽은 상수라 삭제한다.

SocialSourceType 자체는 남는다

각 소셜 fetcher 가 내부에서 자기 URL 종류를 가르는 데 계속 쓴다. 사라지는 것은 그 값이 소셜 밖으로 새어 나가 라우터·게이트·프로세서·스키마를 지나던 경로다.

§7미결 · 후속 작업

이 절이 답하는 것 — 이번에 정하지 않은 것과, 그것을 언제 정하는가.

미결 3건

항목내용언제 정하는가
동시 생성 중복 락이 토큰 주소 단위라 서로 다른 두 토큰이 같은 트윗·계정을 동시에 가리키면 막지 못한다. 지금 accounts·venues 는 예외가 터져 그 토큰이 통째로 죽고, contents 는 조용히 두 행이 생긴다. E11000 처리 코드가 레포 어디에도 없다. 별도의 순차 처리 방식을 도입할 예정. 그것이 정해진 뒤 contents unique 여부와 E11000 재조회 경로를 함께 판단한다
TokenWeb 17필드가 있으나 채우는 호출자가 0. 필드 구성·updateWeb() 의 통째 교체 정책·tokens 에 붙는 것이 맞는지 전부 미결 웹 수집 작업 시작 시
재관측 업데이트 → 주기성 갱신 2026-08-13 에 결정됐다 — 미결이 아니다. resolveAccount·resolveVenue 는 기존 행을 찾으면 그대로 반환하고 아무것도 갱신하지 않는다. 그 동작이 최종 정답이고, 변하는 값의 갱신은 전부 주기성 프로세스가 맡는다 R-009 의 갱신 프로세스와 같은 작업이다
확정 (2026-08-13) — 변하는 값은 전부 주기성 갱신이 맡는다

여기 쌓여 있던 3건은 "재관측 시 무엇을 덮을 것인가" 를 정해 달라는 요구였다. 답은 "아무것도 안 덮는다" 이고, 셋 다 지표와 같은 처리로 간다.

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 를 되살릴 집계 잡을 만들 때
  1. link_countdistinct token_address 다. token_links 는 append-only 이고 유니크 제약이 없어 행 수 ≠ 토큰 수 다. 행을 세면 재처리 횟수가 인기도로 둔갑한다.
  2. 파생값에는 computed_atinput_cutoff 를 반드시 함께 세팅한다. 소비 측이 값을 신뢰할 근거가 이 둘뿐이다.
  3. 라이브 캐시와 백테스트 재계산은 다른 경로다. 캐시된 숫자는 "지금 기준" 한 벌이라 as-of 질의에는 쓸 수 없다.

나래비 쿼리는 매번 계산이 성립하지 않는다 — 정렬 대상이 저장된 값이 아니라 계산 결과라 인덱스가 돕지 못하고, 상위 50개만 필요해도 전체를 다 세야 한다. 하루 토큰 1,000건 × URL 4개 × 객체 3개면 1년에 약 440만 행이다. 캐시는 선택이 아니라 필연이다 — 다만 어디에 둘지는 잡을 설계할 때 정한다.

계정·베뉴 지표의 시계열이 필요해질 때

지금은 팔로워·멤버 수의 시간 변화를 담을 곳이 없다. "큰 계정이 이 토큰을 밀었다" 에서 증가율을 볼 수 없고 현재값만 본다.

다만 지금은 실질 손실이 0 이다 — R-009 로 지표가 생성 시 1회만 기록되므로 계정 시계열이 있어도 점이 하나뿐이고, 그 값은 accounts.metrics.followers 와 같다. 손실은 계정 폴링 주체가 생기는 시점에 비로소 발생한다.

subtype 어휘 — 절반 이상이 지금 생성될 수 없다

R-002 로 실패가 행을 만들지 않고, 미지원 타입에는 제너레이터가 없다. 담당 제너레이터가 없으면 그 subtype 은 영영 생기지 않는다.

ContentSubtype플랫폼생성 가능?
tweetX가능
videoTikTok · YouTube가능
postInstagram · Reddit가능
repoGitHub가능
photo · reel · tvTikTok · Instagram가능 fetch 후 세분화
searchX · TikTok · Reddit불가 UNSUPPORTED — T-012 재검토 대기
intentX불가 UNSUPPORTED
shortlinkTikTok · Reddit불가 UNSUPPORTED
storyInstagram불가 24h 소멸
gistGitHub불가 엔드포인트 미구현
unknownYouTube playlist · web불가 UNSUPPORTED
siteweb보류 웹 작업

삭제하지 않고 주석으로 표시만 한다. search 는 T-012 가 열려 있다 — 실측에서 tiktok_search 1회 호출($0.038)이 주제 적합 10/10 · 서로 다른 작성자 10명 · 총 조회 194만을 돌려줘 "검색 URL 은 객체가 없다" 는 전제가 반증됐다.

VenueSubtype 은 전량 살아 있다 — community(X) · subreddit(Reddit) · channel·portal·shell·guard_group(Telegram).

제약으로 기록 — 다형 참조가 subtype 어휘를 섞는다

token_links.subtype 의 mongoose enum 검증이 ContentSubtypeVenueSubtype합집합이라, 베뉴 링크에 tweet 이 들어가도 DB 는 통과시킨다. 다형 참조의 구조적 한계라 스키마로는 막을 수 없고 object 값과 함께 봐야 판정된다.

두 enum 을 합치지 않는 이유는 반대다 — 합치면 Content.subtypechannel 을 넣어도 컴파일이 통과한다. 객체 종류가 다르면 어휘도 다르다는 것이 지표 클래스를 셋으로 나눈 것과 같은 판단이다.

§8문서 지도

문서담는 것
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·조인키의 실측 원천
전수조사 범위 — 모델 메모 22건 + 추가 발견 7건. 판정 항목 24개, 미결 3건. 근거 코드는 src/modules/social-graph/ · src/modules/social-recording/ · src/common/social-fetcher/.