모델 8개에 선언된 인덱스를 전부 세고, 각각을 실제로 타는 쿼리와 그 쿼리를 부르는 프로덕션 코드까지 역추적한 결과다.
idx_links_object_linked 는 제거 대신 {object_id:1} 로 축소됐고, notes·users 는 인덱스가 아니라 모듈째 들어냈다. 딸려서 사라진 리포지토리 메서드는 6개(findByFingerprint·findChildren·findByVenue·findByHandle·findByWallet·VenueRepository.findByCreator)다. 운영 DB 에서 실제로 지우는 명령은 §9 에 있다. 이 문서 본문은 감사 시점의 기록이라 그대로 둔다 — "지금 걸린 26개" 는 2026-08-12 이전의 사실이다.26개가 몇 개씩 어디로 갈리며, 갈림의 근거가 무엇인가.
쓰기 경로나 unique 로 정당화되는 인덱스는 8개다. 나머지 18개 중 8개는 그 인덱스를 타는 리포지토리 메서드를 부르는 프로덕션 코드가 한 줄도 없고, 5개는 호출부가 있긴 하지만 전부 디버그용 TokenInspectorController 하나이며, 5개는 프로젝트 스켈레톤에서 딸려 온 notes·users 의 것이다.
| 분류 | 개수 | 무엇이 근거인가 |
|---|---|---|
| 유지 | 8 | 토큰 수집·재시도 쓰기 경로가 매 건 밟거나, 정체성 중복을 DB 가 막는 자리다. |
| 제거 1군 | 8 | 이 인덱스를 타는 메서드는 있으나 부르는 코드가 없다. 그 중 둘은 필드를 채우는 코드조차 없다. |
| 제거 2군 | 5 | 호출부는 있지만 전부 디버그 인스펙터다. 「쌓는 데 필요」가 아니라 「보는 데 편함」이다. |
| 스켈레톤 | 5 | notes·users — 이 프로젝트가 쌓는 데이터가 아니다. |
contents 의 정체성 축 (platform, platform_key) 이 unique 가 아니다. 즉 두 번째 기준(중복 금지)을 통과해야 할 자리가 지금 통과하지 못하고 있고, 중복 방어가 애플리케이션 락 하나에 걸려 있는데 그 락은 토큰 주소 단위라 서로 다른 두 토큰이 같은 콘텐츠를 동시에 가리키면 막지 못한다. 자세한 것은 §6.두 기준이 구체적으로 어떤 질문으로 번역되며, 왜 순서가 있는가.
기준 두 개를 인덱스 하나에 그대로 들이대면 답이 안 나온다. "쓰기에 필요한가" 는 인덱스의 성질이 아니라 그 인덱스를 타는 쿼리의 성질이기 때문이다. 그래서 판정은 인덱스 → 쿼리 → 호출부 순서로 내려가며 물었다.
그림 1 — 판정 트리. 순서가 있는 이유는 앞 질문이 통과하면 뒤 질문을 묻지 않기 때문이다. 예컨대 uniq_tokens_address 는 unique 로 이미 정당화되므로 "쓰기 경로인가" 를 따로 따질 필요가 없다(실제로는 둘 다 통과한다).
"미래를 생각해서 넣었다" 는 즉시 탈락이다. 이 감사에서 그 판정은 추측이 아니라 관찰이다 — 인덱스가 가리키는 필드를 grep 했을 때 그 필드로 질의하는 코드가 0건이면, 그것이 곧 "아직 쿼리가 없다" 는 증거다. 두 인덱스(idx_tokens_fingerprints·idx_tokens_image_host)는 한 단계 더 나간다 — 그 필드를 채우는 쓰기 코드조차 없다.
explain() 실측은 하지 않았다. 근거는 코드에 이미 남아 있는 실측 주석(예: idx_tokens_social_url 의 keysExamined=14 docsExamined=7 returned=7)과 쿼리 형태 분석까지다. 그래서 이 문서는 "인덱스가 얼마나 빠른가" 를 말하지 않고 "이 인덱스를 쓰는 쿼리가 존재하는가" 만 말한다.26개가 각각 어느 컬렉션의 어떤 키이고, 이 인덱스를 타는 메서드를 부르는 코드가 어디에 있는가.
| 컬렉션 | 인덱스 | 키 · 옵션 | 타는 메서드 | 프로덕션 호출부 | 판정 |
|---|
호출부 열의 쓰기 는 토큰 수집·재시도 경로, 디버그 는 TokenInspectorController, 없음 은 프로덕션 코드에서 부르는 곳이 0건이라는 뜻이다.
이 인덱스가 없으면 정확히 어떤 쿼리가 무엇을 훑게 되고, 그 쿼리는 누가 부르는가. 그리고 내 결정은 무엇인가.
카드마다 유지 / 제거 / 보류 를 고르고 메모를 남길 수 있다. 선택은 브라우저에 저장되므로 문서를 닫았다 열어도 남는다. 다 고른 뒤 §9 의 버튼으로 전부 복사하면, 그 텍스트를 그대로 붙여넣어 적용을 요청할 수 있다.
토큰 1건이 들어와 저장될 때까지 어떤 조회가 돌고, 그 중 무엇이 어느 인덱스에 의존하는가.
「쓰기 경로」라고 부른 것은 두 갈래다 — 새 토큰을 처음 수집하는 SocialRecordProcessor.collect(), 그리고 수렴하지 않은 URL 을 다시 여는 SocialReconcileService.retryUnconverged(). 아래 그림에서 파란 칸이 DB 조회이고, 그 아래 붙은 인덱스 이름이 그 조회가 의존하는 인덱스다.
그림 2 — 쓰기 경로 두 갈래가 밟는 인덱스. 여기 이름이 나온 8개가 §1 의 「유지」 8개와 정확히 일치한다. 바꿔 말하면 이 그림에 없는 인덱스는 토큰을 쌓는 데 한 번도 쓰이지 않는다.
두 번째 기준(중복이 있어선 안 된다)을 지금 통과하지 못하는 곳이 어디이고, 거기서 중복이 실제로 어떻게 생기는가.
정체성 축에 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 이 같은 값을 만들지 않는다. 즉 강등의 원래 근거는 지금 축에 적용되지 않는다.
중복이 생기는 경로는 이렇다.
그림 3 — 락이 토큰 주소 단위라 서로 다른 두 토큰이 같은 콘텐츠를 가리키면 직렬화되지 않는다. 순서가 핵심이라 순서도가 아니라 시퀀스로 그렸다.
중복이 남기는 피해는 행 하나가 아니다. token_links.object_id 가 그 중 한쪽을 가리키므로, "이 콘텐츠를 건 토큰 수" 집계가 두 그룹으로 갈린다. 그리고 metric_series 는 content_id 에 unique 가 걸려 있어 계열도 두 개가 된다. 즉 이 한 자리의 공백이 아래 두 컬렉션의 집계를 조용히 틀리게 만든다.
idx_contents_platform_key 에 그 선택지를 뒀다.같은 성격의 두 번째 공백은 token_links 다. 이쪽은 의도된 공백이다 — 같은 (object, token) 쌍이 시차를 두고 여러 번 들어오는 것이 정상이고 그 간격이 재관측 이력이라, unique 를 걸면 그 이력이 구조적으로 사라진다. 대신 중복 적재는 deleteByTokenAndEntryUrl 이 진입 URL 단위로 지우고 다시 넣는 방식으로 막는다. 이 결정은 유지하는 것이 맞고, 대가는 §7 의 R2 다.
데이터가 쌓였을 때 조회 효율이 무너질 구조가 어디인가. 인덱스를 지우고 남기는 것과 별개로 봐야 할 것들이다.
| # | 무엇이 쌓이나 | 언제 아픈가 | 지금 할 일 |
|---|---|---|---|
| R1 | contents 의 같은 (platform, platform_key) 중복 행 | 쌓이는 즉시. 건수가 아니라 정확도가 무너진다 — 집계가 두 그룹으로 갈린다. | 중복 실측 → unique 승격 판단(§6). |
| R2 | token_links 행. append-only 이고 토큰마다 URL 최대 4개 × fan-out 객체 수만큼 생긴다. | 가장 먼저 아플 자리다. countObjectsByTokens 가 {object, platform:$in} 으로 컬렉션 대부분을 훑어 그룹핑한 뒤 정렬한다. 인덱스로 좁혀지지 않고, 행이 늘면 선형으로 느려진다. | 인스펙터 전용이라 당장은 감수. 이 화면이 상시 트래픽을 받게 되면 사전 집계 컬렉션이 답이지 인덱스가 아니다. |
| R3 | 수렴하지 않는 social_urls[] 원소. unsupported·skipped_paid 는 설계상 영원히 재시도 대상이다. | findByUrlStatuses 에 limit 이 없다. 그런 URL 을 가진 토큰이 누적되면 재시도 배치가 매 주기마다 토큰 문서 전량을 메모리로 읽는다. 인덱스가 있어도 결과셋 자체가 커지는 문제라 인덱스로 해결되지 않는다. | 배치에 limit + 커서(마지막 처리 지점) 도입 검토. 이번 감사 범위 밖이지만 인덱스보다 급하다. |
| R4 | tokens.social_urls[] 에 걸린 multikey 인덱스 2개(url·status) | 원소 하나를 갱신할 때마다 인덱스 2개가 갱신된다. 다만 배열 길이가 최대 4라 지금은 무시할 수준이다. | 없음. 배열 상한이 커지면 다시 본다. |
| R5 | metric_series.points[] (상한 200, 약 30 KB) | 지금은 위험이 아니다 — recordFirstPoint 밖에 없어 원소가 영원히 1개다. 주기적 갱신 프로세스가 생기는 날 문서 성장이 시작된다. | 없음. 갱신 프로세스 설계 시점에 $slice 강제를 확인한다. |
| R6 | 인스펙터 목록의 인메모리 정렬 + $skip — venues 는 metrics.members, contents 는 published_at 으로 정렬하는데 그 축의 인덱스가 없다. | 컬렉션이 커지면 매 페이지 요청이 정렬 대상 전량을 읽는다. $skip 이 깊어질수록 더 나빠진다. | 지금은 감수(코드 주석도 그렇게 적고 있다). 인덱스를 지금 붙이는 것은 이 감사의 기준에 어긋난다 — 붙일 거면 그 화면을 상시 기능으로 승격하는 작업과 함께 붙인다. |
| R7 | users 의 중복 인덱스 선언 — @prop({unique:true}) 와 @index({...},{unique:true}) 가 email·username 에 둘 다 걸려 있다. | 같은 키에 이름만 다른 인덱스가 둘 생길 수 있다. 쓰기마다 두 번 갱신된다. | 스켈레톤 잔재라 컬렉션째 제거 대상(§8). 남긴다면 선언 하나를 지운다. |
이 5개 인덱스가 이 프로젝트의 데이터와 무슨 관계인가.
관계가 없다. Note·User 는 NestJS 스켈레톤에서 딸려 온 예제 모델이고, 소셜 그래프의 어떤 쓰기 경로도 이 둘을 건드리지 않는다. 다만 app.module.ts 가 두 모듈을 그대로 imports 하고 있어 앱이 뜰 때 컬렉션과 인덱스가 실제로 만들어진다. 인덱스 5개(notes.name, notes.writer, users.email, users.username, users.status)는 그 부산물이다.
판정은 인덱스 단위가 아니라 모듈 단위여야 한다 — 인덱스만 지우면 다음 배포에서 mongoose 가 다시 만든다. 카드에서는 "제거" 를 고르면 «모듈째 들어낸다» 는 뜻으로 §9 출력에 적힌다.
내가 고른 의견이 어떤 형식으로 나오며, 적용할 때 모델 말고 무엇을 같이 고쳐야 하는가.
@index 데코레이터 — 인덱스 선언 자체. 이것만 지우면 코드에서는 사라지지만 이미 만들어진 인덱스는 DB 에 남는다.dropIndex — mongoose 는 선언이 사라진 인덱스를 자동으로 지우지 않는다(autoIndex 는 만들기만 한다). 운영 DB 에 대해 별도 스크립트나 수동 실행이 필요하다.findByFingerprint·findChildren·findByVenue·findByHandle·findByWallet·findByCreator). 인덱스를 지우면서 메서드를 남기면 인덱스 없이 도는 풀스캔 메서드가 되어, 나중에 누가 부르는 순간 조용히 느려진다. 같이 지우는 것이 맞다.test/unit/social-graph.repository.spec.ts 가 리포지토리의 메서드 집합 자체를 단언하고 있다. 메서드를 지우면 여기가 같이 바뀐다.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 처럼 모델에서 사라진 이름과 키가 아직 남아 있다. 이번 감사의 결과를 반영할 때 이 표를 현재 모델 기준으로 통째로 다시 쓰는 것이 가장 자연스러운 처리다.모델에서 @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 로 실패한다.contents unique 승격은 중복 실측이 먼저다.