인덱스 전수 감사 — 지금 걸린 26개 중 무엇을 남기는가

모델 8개에 선언된 인덱스를 전부 세고, 각각을 실제로 타는 쿼리와 그 쿼리를 부르는 프로덕션 코드까지 역추적한 결과다.

결정은 적용됐다(2026-08-12). 아래 카드의 판정과 사용자의 선택에 따라 모델에서 인덱스 18개가 제거되고 8개가 남았다. idx_links_object_linked 는 제거 대신 {object_id:1}축소됐고, notes·users 는 인덱스가 아니라 모듈째 들어냈다. 딸려서 사라진 리포지토리 메서드는 6개(findByFingerprint·findChildren·findByVenue·findByHandle·findByWallet·VenueRepository.findByCreator)다. 운영 DB 에서 실제로 지우는 명령은 §9 에 있다. 이 문서 본문은 감사 시점의 기록이라 그대로 둔다 — "지금 걸린 26개" 는 2026-08-12 이전의 사실이다.

2026-08-12 · 대상 브랜치 chore/add-sol-alpha-finder-tracker-mcp · 판정 기준 두 개: ① 토큰 데이터를 쌓는 쓰기 경로에 필요한가 · ② 중복을 막기 위해 unique 가 필요한가. 둘 다 아니면 «지금 쓰는 쿼리가 없다» 는 뜻이고, 그런 인덱스는 실제 쿼리를 만들 때 그 작업과 함께 넣는 것이 맞다.

  1. 결론 한 장 — 26개가 어떻게 갈리는가
  2. 판정 기준을 인덱스에 적용하는 법 — 어떤 순서로 물었는가
  3. 인덱스 전수표 — 26개의 키·옵션·호출부·판정
  4. 인덱스별 판정 카드 — 근거와 의견 입력
  5. 쓰기 경로 → 인덱스 지도 — 토큰 1건이 어떤 인덱스를 밟는가
  6. unique 공백 — DB 가 중복을 안 막아 주는 자리
  7. 장기 구조 리스크 — 쌓이면 아픈 곳
  8. 스켈레톤 잔재 — notes · users
  9. 결정 적용 절차 — 복사 출력과 같이 고쳐야 할 것

§1 결론 한 장

26개가 몇 개씩 어디로 갈리며, 갈림의 근거가 무엇인가.

쓰기 경로나 unique 로 정당화되는 인덱스는 8개다. 나머지 18개 중 8개는 그 인덱스를 타는 리포지토리 메서드를 부르는 프로덕션 코드가 한 줄도 없고, 5개는 호출부가 있긴 하지만 전부 디버그용 TokenInspectorController 하나이며, 5개는 프로젝트 스켈레톤에서 딸려 온 notes·users 의 것이다.

분류개수무엇이 근거인가
유지8토큰 수집·재시도 쓰기 경로가 매 건 밟거나, 정체성 중복을 DB 가 막는 자리다.
제거 1군8이 인덱스를 타는 메서드는 있으나 부르는 코드가 없다. 그 중 둘은 필드를 채우는 코드조차 없다.
제거 2군5호출부는 있지만 전부 디버그 인스펙터다. 「쌓는 데 필요」가 아니라 「보는 데 편함」이다.
스켈레톤5notes·users — 이 프로젝트가 쌓는 데이터가 아니다.
가장 큰 발견은 인덱스가 아니라 그 옆에 있다. contents 의 정체성 축 (platform, platform_key)unique 가 아니다. 즉 두 번째 기준(중복 금지)을 통과해야 할 자리가 지금 통과하지 못하고 있고, 중복 방어가 애플리케이션 락 하나에 걸려 있는데 그 락은 토큰 주소 단위라 서로 다른 두 토큰이 같은 콘텐츠를 동시에 가리키면 막지 못한다. 자세한 것은 §6.

§2 판정 기준을 인덱스에 적용하는 법

두 기준이 구체적으로 어떤 질문으로 번역되며, 왜 순서가 있는가.

기준 두 개를 인덱스 하나에 그대로 들이대면 답이 안 나온다. "쓰기에 필요한가" 는 인덱스의 성질이 아니라 그 인덱스를 타는 쿼리의 성질이기 때문이다. 그래서 판정은 인덱스 → 쿼리 → 호출부 순서로 내려가며 물었다.

flowchart TD A["인덱스 하나"] --> B{"unique 인가?"} B -- "예" --> B1{"그 unique 가
정체성 중복을 막나?"} B1 -- "예" --> KEEP["유지 — 기준 ②"] B1 -- "아니오" --> C B -- "아니오" --> C{"이 인덱스를 타는
리포지토리 메서드가 있나?"} C -- "없다" --> DROP1["제거 1군 —
쿼리 자체가 없다"] C -- "있다" --> D{"그 메서드를 부르는
프로덕션 코드가 있나?"} D -- "없다" --> DROP1 D -- "있다" --> E{"호출부가 수집·재시도
쓰기 경로인가?"} E -- "예" --> KEEP2["유지 — 기준 ①"] E -- "아니오 (인스펙터뿐)" --> DROP2["제거 2군 —
디버그 전용"]

그림 1 — 판정 트리. 순서가 있는 이유는 앞 질문이 통과하면 뒤 질문을 묻지 않기 때문이다. 예컨대 uniq_tokens_address 는 unique 로 이미 정당화되므로 "쓰기 경로인가" 를 따로 따질 필요가 없다(실제로는 둘 다 통과한다).

"미래를 생각해서 넣었다" 는 즉시 탈락이다. 이 감사에서 그 판정은 추측이 아니라 관찰이다 — 인덱스가 가리키는 필드를 grep 했을 때 그 필드로 질의하는 코드가 0건이면, 그것이 곧 "아직 쿼리가 없다" 는 증거다. 두 인덱스(idx_tokens_fingerprints·idx_tokens_image_host)는 한 단계 더 나간다 — 그 필드를 채우는 쓰기 코드조차 없다.

판정에 쓰지 않은 것. explain() 실측은 하지 않았다. 근거는 코드에 이미 남아 있는 실측 주석(예: idx_tokens_social_urlkeysExamined=14 docsExamined=7 returned=7)과 쿼리 형태 분석까지다. 그래서 이 문서는 "인덱스가 얼마나 빠른가" 를 말하지 않고 "이 인덱스를 쓰는 쿼리가 존재하는가" 만 말한다.

§3 인덱스 전수표

26개가 각각 어느 컬렉션의 어떤 키이고, 이 인덱스를 타는 메서드를 부르는 코드가 어디에 있는가.

컬렉션인덱스키 · 옵션타는 메서드프로덕션 호출부판정

호출부 열의 쓰기 는 토큰 수집·재시도 경로, 디버그TokenInspectorController, 없음 은 프로덕션 코드에서 부르는 곳이 0건이라는 뜻이다.

§4 인덱스별 판정 카드

이 인덱스가 없으면 정확히 어떤 쿼리가 무엇을 훑게 되고, 그 쿼리는 누가 부르는가. 그리고 내 결정은 무엇인가.

카드마다 유지 / 제거 / 보류 를 고르고 메모를 남길 수 있다. 선택은 브라우저에 저장되므로 문서를 닫았다 열어도 남는다. 다 고른 뒤 §9 의 버튼으로 전부 복사하면, 그 텍스트를 그대로 붙여넣어 적용을 요청할 수 있다.

§5 쓰기 경로 → 인덱스 지도

토큰 1건이 들어와 저장될 때까지 어떤 조회가 돌고, 그 중 무엇이 어느 인덱스에 의존하는가.

「쓰기 경로」라고 부른 것은 두 갈래다 — 새 토큰을 처음 수집하는 SocialRecordProcessor.collect(), 그리고 수렴하지 않은 URL 을 다시 여는 SocialReconcileService.retryUnconverged(). 아래 그림에서 파란 칸이 DB 조회이고, 그 아래 붙은 인덱스 이름이 그 조회가 의존하는 인덱스다.

flowchart TD subgraph NEW ["신규 수집 — SocialRecordProcessor.collect()"] N1["findByAddress(address)
uniq_tokens_address"] --> N2{"이미 있나?"} N2 -- "있다" --> NEND["조기 반환 — 아무것도 안 쓴다"] N2 -- "없다" --> N3["업스트림에서 프로필 읽기
(DB 아님)"] N3 --> N4["UrlDedupeGate.resolve()
findBySocialUrls(urls)
idx_tokens_social_url"] N4 --> N5{"재사용 히트?"} N5 -- "예" --> N6["findByTokenAndEntryUrl()
idx_links_token_linked"] N6 --> N7["링크 복사 append
token_links"] N5 -- "아니오" --> N8["소셜 fetch → SocialGraphWriter"] N8 --> N9["findByPlatformKey()
idx_contents_platform_key
uniq_accounts_platform_key
uniq_venues_platform_key"] N9 --> N10["recordFirstPoint()
uniq_series_content"] N7 --> N11["upsertByAddress()
uniq_tokens_address"] N10 --> N11 end subgraph RETRY ["재시도 배치 — retryUnconverged()"] R1["findByUrlStatuses(statuses)
idx_tokens_social_url_status"] --> R2["URL 단위 선별
(문서 안에서 계산)"] R2 --> R3["UrlDedupeGate.resolve()
idx_tokens_social_url"] R3 --> R4["deleteByTokenAndEntryUrl()
idx_links_token_linked"] R4 --> R5["재수집 후 링크 재삽입"] R5 --> R6["recordSocialUrlResult()
uniq_tokens_address"] end

그림 2 — 쓰기 경로 두 갈래가 밟는 인덱스. 여기 이름이 나온 8개가 §1 의 「유지」 8개와 정확히 일치한다. 바꿔 말하면 이 그림에 없는 인덱스는 토큰을 쌓는 데 한 번도 쓰이지 않는다.

이 그림이 판정의 뼈대다. 「제거 1군」과 「제거 2군」의 차이는 그림 밖에 있다는 점에서 같고, 호출부가 존재하느냐에서 갈린다. 1군은 지워도 어떤 코드 경로도 느려지지 않는다(부르는 코드가 없으므로). 2군은 지우면 인스펙터 화면 몇 개가 느려진다.

§6 unique 공백 — DB 가 중복을 안 막아 주는 자리

두 번째 기준(중복이 있어선 안 된다)을 지금 통과하지 못하는 곳이 어디이고, 거기서 중복이 실제로 어떻게 생기는가.

정체성 축에 unique 가 걸린 컬렉션은 넷이다 — tokens.address, accounts.(platform, platform_key), venues.(platform, platform_key), metric_series.content_id. contents 만 빠져 있다. 같은 축(platform, platform_key)에 인덱스는 있는데 unique 가 아니다.

모델 주석은 그 이유를 «fan-out 콘텐츠가 부모 URL 을 공유해 충돌했기 때문에 강등됐다» 로 적고 있는데, 그 문장이 가리키는 것은 지금은 사라진 옛 축(source_urls.url)이다. 현재 축인 platform_key 는 분류 단계가 만든 키라 fan-out 이 같은 값을 만들지 않는다. 즉 강등의 원래 근거는 지금 축에 적용되지 않는다.

중복이 생기는 경로는 이렇다.

sequenceDiagram participant A as 워커 A — 토큰 X participant B as 워커 B — 토큰 Y participant L as Redlock — 키는 토큰 주소 participant D as contents 컬렉션 A->>L: lock(token:X) L-->>A: 획득 B->>L: lock(token:Y) L-->>B: 획득 — 키가 다르므로 막히지 않는다 Note over A,B: 두 토큰이 같은 트윗을 인용한다 A->>D: findByPlatformKey(x, 175...) D-->>A: null B->>D: findByPlatformKey(x, 175...) D-->>B: null A->>D: create(...) B->>D: create(...) Note over D: 같은 (platform, platform_key) 두 행이 남는다.
unique 가 아니라 E11000 도 안 난다.

그림 3 — 락이 토큰 주소 단위라 서로 다른 두 토큰이 같은 콘텐츠를 가리키면 직렬화되지 않는다. 순서가 핵심이라 순서도가 아니라 시퀀스로 그렸다.

중복이 남기는 피해는 행 하나가 아니다. token_links.object_id 가 그 중 한쪽을 가리키므로, "이 콘텐츠를 건 토큰 수" 집계가 두 그룹으로 갈린다. 그리고 metric_seriescontent_id 에 unique 가 걸려 있어 계열도 두 개가 된다. 즉 이 한 자리의 공백이 아래 두 컬렉션의 집계를 조용히 틀리게 만든다.

다만 이번 감사의 결정 대상은 아니다. unique 승격은 인덱스를 지우는 작업이 아니라 제약을 새로 거는 작업이라, 기존 데이터에 이미 중복이 있으면 인덱스 생성 자체가 실패한다. 그래서 «승격한다/안 한다» 를 여기서 고르되, 실행은 중복 실측 → 정리 → 승격 순서의 별도 작업이어야 한다. 카드 idx_contents_platform_key 에 그 선택지를 뒀다.

같은 성격의 두 번째 공백은 token_links 다. 이쪽은 의도된 공백이다 — 같은 (object, token) 쌍이 시차를 두고 여러 번 들어오는 것이 정상이고 그 간격이 재관측 이력이라, unique 를 걸면 그 이력이 구조적으로 사라진다. 대신 중복 적재는 deleteByTokenAndEntryUrl 이 진입 URL 단위로 지우고 다시 넣는 방식으로 막는다. 이 결정은 유지하는 것이 맞고, 대가는 §7 의 R2 다.

§7 장기 구조 리스크

데이터가 쌓였을 때 조회 효율이 무너질 구조가 어디인가. 인덱스를 지우고 남기는 것과 별개로 봐야 할 것들이다.

#무엇이 쌓이나언제 아픈가지금 할 일
R1contents 의 같은 (platform, platform_key) 중복 행쌓이는 즉시. 건수가 아니라 정확도가 무너진다 — 집계가 두 그룹으로 갈린다.중복 실측 → unique 승격 판단(§6).
R2token_links 행. append-only 이고 토큰마다 URL 최대 4개 × fan-out 객체 수만큼 생긴다.가장 먼저 아플 자리다. countObjectsByTokens{object, platform:$in} 으로 컬렉션 대부분을 훑어 그룹핑한 뒤 정렬한다. 인덱스로 좁혀지지 않고, 행이 늘면 선형으로 느려진다.인스펙터 전용이라 당장은 감수. 이 화면이 상시 트래픽을 받게 되면 사전 집계 컬렉션이 답이지 인덱스가 아니다.
R3수렴하지 않는 social_urls[] 원소. unsupported·skipped_paid설계상 영원히 재시도 대상이다.findByUrlStatuseslimit 이 없다. 그런 URL 을 가진 토큰이 누적되면 재시도 배치가 매 주기마다 토큰 문서 전량을 메모리로 읽는다. 인덱스가 있어도 결과셋 자체가 커지는 문제라 인덱스로 해결되지 않는다.배치에 limit + 커서(마지막 처리 지점) 도입 검토. 이번 감사 범위 밖이지만 인덱스보다 급하다.
R4tokens.social_urls[] 에 걸린 multikey 인덱스 2개(url·status)원소 하나를 갱신할 때마다 인덱스 2개가 갱신된다. 다만 배열 길이가 최대 4라 지금은 무시할 수준이다.없음. 배열 상한이 커지면 다시 본다.
R5metric_series.points[] (상한 200, 약 30 KB)지금은 위험이 아니다 — recordFirstPoint 밖에 없어 원소가 영원히 1개다. 주기적 갱신 프로세스가 생기는 날 문서 성장이 시작된다.없음. 갱신 프로세스 설계 시점에 $slice 강제를 확인한다.
R6인스펙터 목록의 인메모리 정렬 + $skipvenuesmetrics.members, contentspublished_at 으로 정렬하는데 그 축의 인덱스가 없다.컬렉션이 커지면 매 페이지 요청이 정렬 대상 전량을 읽는다. $skip 이 깊어질수록 더 나빠진다.지금은 감수(코드 주석도 그렇게 적고 있다). 인덱스를 지금 붙이는 것은 이 감사의 기준에 어긋난다 — 붙일 거면 그 화면을 상시 기능으로 승격하는 작업과 함께 붙인다.
R7users중복 인덱스 선언@prop({unique:true})@index({...},{unique:true})email·username둘 다 걸려 있다.같은 키에 이름만 다른 인덱스가 둘 생길 수 있다. 쓰기마다 두 번 갱신된다.스켈레톤 잔재라 컬렉션째 제거 대상(§8). 남긴다면 선언 하나를 지운다.
R2 와 R3 는 인덱스 감사의 결론을 뒤집지 않는다 — 인덱스로 못 고치는 문제라는 것이 요점이다. R2 는 사전 집계, R3 는 배치 페이징이 답이다. 인덱스를 더 붙이면 «고쳤다» 는 착각만 생기고 쓰기 비용은 실제로 는다.

§8 스켈레톤 잔재 — notes · users

이 5개 인덱스가 이 프로젝트의 데이터와 무슨 관계인가.

관계가 없다. Note·User 는 NestJS 스켈레톤에서 딸려 온 예제 모델이고, 소셜 그래프의 어떤 쓰기 경로도 이 둘을 건드리지 않는다. 다만 app.module.ts 가 두 모듈을 그대로 imports 하고 있어 앱이 뜰 때 컬렉션과 인덱스가 실제로 만들어진다. 인덱스 5개(notes.name, notes.writer, users.email, users.username, users.status)는 그 부산물이다.

판정은 인덱스 단위가 아니라 모듈 단위여야 한다 — 인덱스만 지우면 다음 배포에서 mongoose 가 다시 만든다. 카드에서는 "제거" 를 고르면 «모듈째 들어낸다» 는 뜻으로 §9 출력에 적힌다.

§9 결정 적용 절차

내가 고른 의견이 어떤 형식으로 나오며, 적용할 때 모델 말고 무엇을 같이 고쳐야 하는가.

같이 고쳐야 하는 것

  1. 모델의 @index 데코레이터 — 인덱스 선언 자체. 이것만 지우면 코드에서는 사라지지만 이미 만들어진 인덱스는 DB 에 남는다.
  2. 실제 DB 의 dropIndex — mongoose 는 선언이 사라진 인덱스를 자동으로 지우지 않는다(autoIndex 는 만들기만 한다). 운영 DB 에 대해 별도 스크립트나 수동 실행이 필요하다.
  3. 쓰이지 않게 된 리포지토리 메서드 — 「제거 1군」의 인덱스는 대부분 자기만의 메서드를 갖고 있다(findByFingerprint·findChildren·findByVenue·findByHandle·findByWallet·findByCreator). 인덱스를 지우면서 메서드를 남기면 인덱스 없이 도는 풀스캔 메서드가 되어, 나중에 누가 부르는 순간 조용히 느려진다. 같이 지우는 것이 맞다.
  4. 메서드 목록을 못박은 단위 테스트test/unit/social-graph.repository.spec.ts 가 리포지토리의 메서드 집합 자체를 단언하고 있다. 메서드를 지우면 여기가 같이 바뀐다.
  5. 스키마 통합 테스트의 기대 인덱스 표test/integration/social-graph.schema.integration.spec.ts. 다만 이 표는 이미 현재 모델과 어긋나 있다. 스펙 자신의 헤더 주석이 그 사실을 적고 있다(2026-08-12 확인 · contents·accounts·venues·token_links·metric_series 의 13개 단언이 실패하며, 이 기능 이전부터 그랬다). 표에는 idx_contents_source_url·linked_at·uniq_series_object 처럼 모델에서 사라진 이름과 키가 아직 남아 있다. 이번 감사의 결과를 반영할 때 이 표를 현재 모델 기준으로 통째로 다시 쓰는 것이 가장 자연스러운 처리다.

운영 DB 에서 실제로 지우는 명령 (2026-08-12 적용분)

모델에서 @index 선언을 지워도 이미 만들어진 인덱스는 DB 에 그대로 남는다 — mongoose 의 autoIndex 는 만들기만 하고 지우지 않는다. 아래를 mongosh 로 한 번 실행해야 실제로 사라진다. 없는 인덱스에 dropIndex 를 부르면 IndexNotFound 로 던지므로, 이미 지웠거나 애초에 만들어진 적 없는 환경을 위해 감싸 뒀다.

const drops = {
  tokens: ['idx_tokens_fingerprints', 'idx_tokens_image_host', 'idx_tokens_discovered'],
  token_links: ['idx_links_platform_linked', 'idx_links_object_linked'], // object_id 단독으로 재생성된다
  contents: ['idx_contents_platform_subtype', 'idx_contents_creator_published',
             'idx_contents_parent', 'idx_contents_venue'],
  accounts: ['idx_accounts_handle', 'idx_accounts_wallet'],
  venues:   ['idx_venues_creator'],
};

for (const [coll, names] of Object.entries(drops)) {
  for (const name of names) {
    try { db.getCollection(coll).dropIndex(name); print(`dropped ${coll}.${name}`); }
    catch (e) { print(`skip ${coll}.${name} — ${e.codeName}`); }
  }
}

// notes · users 는 인덱스가 아니라 컬렉션째 사라진다(모듈을 들어냈다).
db.notes.drop();
db.users.drop();
idx_links_object_linked 는 이름이 같은 채로 키가 바뀌었다({object_id, link_depth, created_at}{object_id}). MongoDB 는 같은 이름에 다른 키로 인덱스를 만들지 못한다 — 위에서 먼저 지워야 애플리케이션이 뜰 때 새 키로 다시 만든다. 지우지 않으면 인덱스 생성이 IndexOptionsConflict 로 실패한다.
순서 제안. ① 제거 1군 8개를 먼저 처리한다 — 부르는 코드가 없어 회귀 위험이 사실상 없다. ② 제거 2군은 인스펙터 화면을 실제로 눌러 보고 견딜 만한지 확인한 뒤 처리한다. ③ 스켈레톤은 모듈 제거라 별개 커밋. ④ contents unique 승격은 중복 실측이 먼저다.