# Social URL Dedupe — Design

- 작성 2026-08-11 · 상태 **✅ 구현 완료 (2026-08-11)** — [`tasks/overview.md`](../../docs/features/social-url-dedupe/tasks/overview.md) 참고
- 결정 ID 는 `U-*` 를 쓴다. `D-*` 는 schema-v5 가, `G-*` 는 social-generator 가 이미 쓰고 있다.

---

## 0. 한 줄 요약

**리다이렉트·정규화까지 끝난 진입 URL 에 이미 처리 이력이 있으면, Generator 를 부르지 않고
이전에 만들어진 객체를 가리키는 링크만 만든다.**

아끼는 것은 유료 소셜의 외부 호출이다. 새로 만드는 것은 `token_links` 행뿐이고,
`contents`·`accounts`·`venues` 는 손대지 않는다.

## 0.5 근거 문서

| 문서 | 무엇을 정했나 |
|---|---|
| [`social-recording-process/be-system-design.md`](../../docs/features/social-recording-process/be-system-design.md) | §3.3 수집 순서 · §6.2 재시도 · §1.4 상태 인덱스 |
| [`social-generator/decisions.md`](./decisions.md) | G-4 호출 0회 성공 · G-18 단축 해제 |
| [`social-recording-refactor/refactoring-direction.md`](../../docs/features/social-recording-refactor/refactoring-direction.md) | R-003 경계 · R-007 링크에 상태 없음 · R-009 재관측 시 안 덮음 |
| `social-graph.consts.ts` | `RETRYABLE_FETCH_STATUSES` — 흡수 상태의 정의 |
| `telegram.generator.ts` T-1 | Telegram `platformKey` 가 가변 `username` 인 이유와 그 한계 |

**이 작업은 `social-generator/backlog.md` 의 B-항목이 아니다.** 그 대장은 8소셜 Generator 의
잔여 작업을 세는 것이고, 이 작업은 Processor·Writer·인덱스의 문제라 축이 다르다.

---

## 1. 배경 — 실측 (2026-08-11 · dev DB · 토큰 58건)

```
고유 소셜 URL                     97
2개 이상 토큰이 공유하는 URL        5   (약 5%)
  ├ ok          3   ← 링크가 있어 재사용 가능
  ├ not_found   1   ← 3개 토큰이 각각 유료 X 호출 = 3회 낭비
  └ unsupported 1   ← 애초에 호출 0회
```

**돈이 새는 자리가 `not_found` 다.** 없는 트윗 하나를 세 토큰이 각각 유료로 확인했고,
`token_links` 는 성공했을 때만 생기므로 링크 기반 조회로는 이 세 번을 하나도 못 막는다.
U-1 이 게이트와 페이로드를 가르는 이유가 이것이다.

> ⚠️ **적중률 5% 를 그대로 믿지 않는다.** 토큰 58건은 표본이 작다. 밈코인 카피캣이 같은
> 바이럴 트윗을 거는 패턴이면 프로덕션에서는 훨씬 높다. 아래 결정들은 적중률과 무관하게
> 성립하도록 잡았고, 적중률이 낮아도 손해가 아닌 근거는 §7 의 비대칭성이다.

---

## 2. 전제와 그 한계

전제는 **"같은 URL 이면 같은 결과"** 다. 지표 변동·원본 갱신은 고려하지 않는다.

이 전제가 어긋나는 방향이 둘인데, **둘의 위험도가 완전히 다르다.**

| | 무슨 일 | 결과 |
|---|---|---|
| **miss** — 같은 대상인데 URL 문자열이 달라 못 찾음 | `?s=20` vs `?s=46` vs 무파라미터 | Generator 를 부른다 → Writer 의 `findByPlatformKey` 가 기존 행을 찾아 재사용 → **행은 안 늘어난다.** 오늘 dedupe 없이 도는 경로 그대로다 |
| **false hit** — URL 은 같은데 대상이 바뀜 | 핸들 반납 후 타인이 가져감 | **조용히 틀린 데이터** — 남의 계정에 링크가 박힌다 |

**miss 는 비용일 뿐 정합성 문제가 아니다.** 캐시는 canonical 키가 필요 없다 —
정체성을 URL 로 판정하면 안 된다는 v5 C-M1 은 *"한 객체가 두 행이 되는 것"* 을 막는 규칙이고,
여기서는 Writer 가 `platform_key` 로 이미 받아낸다. 실측에서 97개 중 18개가 쿼리스트링을
갖고 있어 miss 는 실제로 발생하지만, 그것이 만드는 것은 **낭비된 호출 하나**뿐이다.

**false hit 만 막으면 된다.** U-2(TTL)와 U-4(Telegram 제외)가 그 장치다.

---

## 3. U-1 — 게이트와 페이로드를 가른다

| | 성공 기록 | 실패 기록 | 이 작업에서의 역할 |
|---|---|---|---|
| `tokens.social_urls[]` | ✅ | ✅ | **게이트** — "이 URL 을 처리한 적 있나 · 언제 불렀나" |
| `token_links` | ✅ | ❌ 없음 | **페이로드** — "그래서 어떤 객체가 나왔나" |

`token_links` 는 `result.graph` 가 있을 때만(=`status: ok`) 쓰인다. 실패는 Writer 에
도달조차 하지 않는다(R-003). 그래서 링크만 보면 **실패 이력이 통째로 안 보이고**,
§1 의 `not_found` 3회 호출이 그대로 반복된다.

> 📌 `social-record.processor.ts` 헤더에 이 기능의 초안 주석이 이미 있고 거기엔
> *"url 있는 지 체크 (token link 에서 체크하면 됨)"* 라고 적혀 있다. **이 문서가 그것을
> 갱신한다** — 링크만으로는 부족하다는 것이 U-1 의 결론이다.

---

## 4. U-2 — TTL 14일 · 모든 URL 에 균일

URL 종류(핸들 기반 / 불변 id 기반)를 **분류하지 않는다.** 전부 같은 14일을 적용한다.

분류표를 만들지 않는 이유는 단순함이 아니라 **오분류의 대가가 비대칭**이기 때문이다.

| 오분류 방향 | 결과 |
|---|---|
| 핸들 URL 을 "불변" 으로 판정 | **영구 false hit** — 남의 계정에 링크가 박히고 영영 안 풀린다 |
| 불변 URL 을 "핸들" 로 판정 | 14일마다 헛 호출 1회 |

분류 로직을 두는 순간 위쪽 실패가 가능해진다. 균일 TTL 은 위쪽이 **구조적으로 불가능**하다.
불변 id URL 이 14일 뒤 한 번 더 호출되는 것이 대가인데, 카피캣 토큰은 시간적으로 몰려
들어오므로 14일을 넘겨 재등장하는 경우 자체가 드물다.

**TTL 값은 코드 상수다**(U-8). `social-recording.consts.ts` 가 이미
*"전부 코드 상수다 — env 로 내리지 않는다. 환경별로 달라질 값이 아니고, 상수는 `undefined`
가 될 수 없어서 «설정 키가 없으면 조용히 틀린 동작» 이라는 실패 모드 자체가 생기지 않는다"*
라고 선언해 두었고, 이 값이 정확히 그 성격이다.

---

## 5. U-3 — 앵커는 `attempted_at` 의 **최댓값**

14일을 어느 시점부터 재는가.

### 5.1 "가장 과거" 가 아니다

| | day 0 (A) | day 5 (B) | day 20 (C) | day 25 (D) | day 27 (E) |
|---|---|---|---|---|---|
| 최초 관측 고정 | fetch | 재사용 | fetch | **fetch** | **fetch** |
| **마지막 실제 호출** | fetch | 재사용 | fetch | 재사용 | 재사용 |

최초 시각은 영원히 안 움직이므로, 14일이 지난 뒤로는 **모든 토큰이 영구히 호출한다.**
캐시가 그 시점에 죽는다.

"가장 과거" 에도 논리는 있다 — 재사용하는 *데이터* 는 첫 관측 것이니(행이 안 덮이므로)
데이터의 나이는 최초 기준이 맞다. **그런데 그 목적은 TTL 로 달성할 수 없다.**
day 20 에 재호출해도 `existing ?? create` 라 행은 day-0 값 그대로다(§7). 즉 최초 앵커는
목적을 이루지 못한 채 비용만 영구히 낸다.

TTL 이 실제로 사주는 것은 **"아직 같은 대상인가"** 하나뿐이고, 그것은 *마지막으로 확인한
시점* 부터 재는 것이 맞다.

### 5.2 `attempted_at` 이어야 하는 이유 — 시계의 자기전진

`token_links.created_at` 의 최댓값을 앵커로 쓰면 **안 된다.** dedupe 로 만든 링크도
`created_at` 이 now 로 찍히므로, 실제로는 아무도 호출하지 않았는데 시계만 계속 앞으로
밀린다. 토큰이 꾸준히 들어오는 한 **TTL 이 영원히 안 터진다.**

`tokens.social_urls[].attempted_at` 은 정의가 정확히 *"외부 호출을 실제로 시도한 마지막
시각. 게이트가 막았거나 담당 Generator 가 없어 부르지도 않았다면 `null`"* 이다.
재사용은 `attempted: false` 로 나가 `null` 이 남으므로 **시계가 저절로 안 밀린다.**

실측이 이 필드가 그 뜻대로 동작함을 보여준다.

```
status        총계   attempted_at 있음   null
ok             69          64             5   ← null 5건이 G-4 호출 0회 경로
not_found      13          13             0
error           2           2             0
unsupported    17           0            17   ← 안 부른 것은 전부 null
skipped_paid    3           0             3
```

### 5.3 판정 규칙

```
후보  = 그 URL 의 social_urls 원소 중 status ∈ {ok, not_found}
상태  = 후보에 ok 가 하나라도 있으면 ok, 없으면 not_found
앵커  = 그 status 를 가진 후보들의 attempted_at 중 null 이 아닌 것의 최댓값
판정  = 앵커가 없거나(호출 0회 경로) now - 앵커 < TTL  →  재사용
        그 외                                          →  fetch
```

**`ok` 우선인 이유** — 같은 URL 에 `ok` 와 `not_found` 가 섞여 있을 수 있다(관측 후 삭제 등).
`ok` 쪽은 실제로 객체가 만들어졌고, 링크는 *"이 토큰이 이 대상을 가리켰다"* 는 관측이라
나중의 삭제가 그 사실을 무효화하지 않는다.

**앵커를 "재사용할 status 를 가진 후보" 안에서만 고르는 이유** — 예컨대 day 20 의 `error`
호출은 실제로 불렀지만 아무것도 확인해 주지 못했다. 그것으로 시계를 밀면 확인하지 않은
것을 확인했다고 기록하는 셈이다.

**앵커가 아예 없으면 영구 재사용이다.** 해당하는 것은 `ok` + `attempted_at: null` 조합,
즉 검색어·intent·trend 의 호출 0회 경로뿐이다(G-4). 값이 URL 문자열에서 파생되므로 상할
것이 없다. 별도 분기 없이 규칙 하나로 처리된다.

---

## 6. U-4 — Telegram 은 dedupe 대상에서 제외

재호출이 재할당을 잡아내는 원리는 *"새 주인은 `platform_key` 가 다르니 `findByPlatformKey`
가 miss → 새 행"* 이다. **키가 핸들이면 miss 가 안 나고 옛 주인 행이 그대로 돌아온다.**

| 소셜 | 계정·베뉴 `platform_key` | 유료 | 재할당 시 |
|---|---|---|---|
| X | `profile.id` | 유료 | 새 행 ✅ |
| TikTok | `profile.authorId` | 유료 | ✅ |
| Instagram | `profile.id` | 유료 | ✅ |
| Reddit | `username` (변경 불가 보장) | 유료 | ✅ |
| YouTube | `channel.channelId` | 무료 | ✅ |
| GitHub | `String(owner.id)` (`login` 아님) | 무료 | ✅ |
| **Telegram** | **`username` — 가변** | **무료** | **옛 행 재사용** ❌ |

Telegram 만 예외이고, 이것은 코드에 이미 경고가 붙어 있다(T-1).

> ⚠️ 대신 키가 불변이 아니다. Telegram username 은 소유자가 놓으면 타인이 가져가서,
> 같은 키가 시점에 따라 다른 채널일 수 있다(**실측 이관 2건**). 유일성 제약은 지켜지므로
> DB 가 못 막는다.

즉 `t.me/foo` 가 넘어간 뒤 재호출해도 키가 같아 옛 채널 행이 돌아오고, 새로 받은
title·guardChatId·생성일은 버려진다. **Telegram 에서는 TTL 이 아무것도 사주지 못한다.**

**돈이 나가는 소셜은 전부 키가 불변**이므로, 유일하게 위험한 Telegram 을 빼도 절감분은
그대로 남는다. 정합성은 오늘과 완전히 동일하고, `venueCreatedAt` 기반 이관 탐지는
telegram.md §8 의 숙제로 그대로 남긴다.

### ⚠️ 위 표에 `web` 이 없다 — 의도적 제외가 아니라 축이 다르기 때문이다

표는 **계정·베뉴**의 키를 센다. `web` 은 계정도 베뉴도 만들지 않고 **콘텐츠 키가 URL**
이다(W-1, `resolvePlatformKey`). 그래서 Telegram 과 같은 성질을 하나 공유한다 —
도메인이 재등록돼 주인이 바뀌어도 `platform_key` 가 같아 `findByPlatformKey` 가 옛 행을
돌려주므로, **재호출이 정체성을 고쳐 주지 못한다.**

그럼에도 Telegram 처럼 빼지 않는다. 근거가 둘이다.

1. **web 은 유료다**(urlscan + RDAP). Telegram 을 뺄 때의 *"빼도 잃는 게 없다"* 가 성립하지 않는다.
2. **dedupe 가 이 문제를 만들지 않는다.** 재호출해도 어차피 기존 행을 안 덮으므로(R-009),
   dedupe 가 있든 없든 결과가 같다 — §7 의 *"행 내용은 재호출로 안 고쳐진다"* 그대로다.
   Telegram 은 *다른 객체* 둘이 한 행으로 뭉치는 정체성 문제지만, web 은 *같은 URL* 의
   내용이 낡는 문제라 성질이 다르다.

TTL 14일이 노출 창을 그만큼으로 묶는다 — 그 안에 도메인이 손바뀜할 확률은 낮다.
높아지면 web 만 TTL 을 줄이는 것이 다음 손잡이다.

---

## 7. 재호출이 고치는 것 / 못 고치는 것

TTL 이 만료돼 다시 부른 결과가 **이전과 같으면 아무것도 안 써진다.** 쓰기 경로 전체를
확인한 결과다.

| 단계 | 재처리 시 |
|---|---|
| `resolveAccount` · `resolveVenue` · `resolveContent` | `existing ?? create(...)` — 기존 행 반환, **안 덮음**(R-009) |
| `recordFirstMetrics` → `recordFirstPoint` | `$setOnInsert` — 이미 있으면 no-op |
| `token_links` | **여기만 쓴다** — 새 토큰은 자기 링크가 필요하므로 정상 |

이것이 이 설계가 안전한 근거다. **헛 재호출이 데이터를 망가뜨릴 수 없다.**

| | 재호출로 고쳐지나 |
|---|---|
| **정체성** (핸들 재할당) | ✅ 새 주인은 키가 달라 새 행이 생기고 올바른 객체에 링크된다 |
| 행의 내용 (venue `subtype` · `unavailable` · 지표) | ❌ 안 덮으므로 그대로 |

**TTL 은 "데이터를 신선하게" 가 아니라 "아직 같은 대상인가" 를 확인하는 장치다.**
행 갱신 정책은 v5 §7 미결이며 이 작업의 스코프가 아니다(§10).

### 비대칭성 — 적중률이 낮아도 남는 이유

| | 비용 / 이득 |
|---|---|
| miss (실측 95%) | 인덱스 조회 1회, 결과 0건 — **1ms 미만** |
| hit (실측 5%) | 유료 API 호출 1회 회피 — **수백 ms + 크레딧** |

3~4자릿수 차이라 적중률이 1% 로 떨어져도 이득이다.

---

## 8. U-5 — 재사용 대상은 `ok` 와 `not_found` 뿐이며, 둘 다 흡수 상태다

```
RETRYABLE_FETCH_STATUSES = [ERROR, UNSUPPORTED, SKIPPED_PAID]
"여기 없는 값은 전부 흡수 상태다(ok·not_found·blocked·suspended·invalid·exhausted)"
```

재사용하는 두 상태가 **모두 흡수 상태**라 재시도 배치와 아예 겹치지 않는다. 이것이
세 가지를 동시에 보장한다.

1. 재사용한 URL 을 배치가 뒤에서 다시 부르는 일이 없다
2. `attempts: 0` 으로 남아도 `MAX_ATTEMPTS`·`exhausted` 카운터가 꼬이지 않는다 — 애초에
   재시도 후보가 아니므로 누적될 일이 없다
3. `not_found` 재사용은 **링크를 만들지 않고 `social_urls[]` 원소만 남긴다.**
   오늘도 `not_found` 는 링크를 만들지 않으므로 동작이 같다

멱등 모델과도 충돌하지 않는다. 설계는 *"한 번 `ok` 면 몇 번을 더 불러도 값이 안 변한다"*
로 못박아 두었는데, TTL 은 **토큰 A 의 URL 을 다시 여는 값이 아니라 새 토큰 C 가 A 의 답을
얼마나 오래 믿어도 되는지** 를 정하는 값이다. 축이 다르다.

## 9. U-6 — 재사용임을 표시하지 않는다

재사용 결과는 `status` 그대로 + `attempted: false` + `attempts: 0` + `attempted_at: null`
로 기록한다. 기존 계약의 *"부르지 않았다"* 와 같은 모양이며, 이는 **의도한 것이다** —
그 모양이 U-3 의 시계 자기전진 방지를 그대로 만들어 준다.

`token_links` 에 재사용 표시를 두지 않는 것은 R-007 이다. *"상태 필드가 없는 것이 의도다.
여기에 사본을 두면 같은 사실을 두 곳에서 관리하게 되고, 둘이 갈렸을 때 어느 쪽이 맞는지
판단할 근거가 없다."*

---

## 10. U-7 — 쿼리는 배치 + 조건부 2단계

URL 마다 조회하면 토큰당 최대 8회가 된다. `routeSocialUrls` 가 URL 4종을 **한 번에**
만들어 주므로 `$in` 으로 묶는다.

```
① tokens.find({ 'social_urls.url': { $in: 최대 4개 } })
     → 앵커·상태 판정 + **소스 토큰 선정**                                        ← 항상

② token_links.find({ token_address: <소스 토큰>, entry_url: <그 URL> })
     → 링크 재료                                                                  ← 히트가 있을 때만
```

### ①에 `status` 를 넣지 않는다 — 넣고 싶어지는 자리다

초안은 `$elemMatch: { url: {$in}, status: {$in: [ok, not_found]} }` 였다. 뺀 이유가 셋이다.

**① 정확할 수가 없다.** URL 을 배치로 넘기므로(`$in`) `$elemMatch` 를 써도 조건은
*"넘긴 URL 중 **아무거나** 하나가 그 상태인 원소가 있는 토큰"* 이 된다. `A=ok, B=error` 인
토큰은 B 를 물어도 매칭된다. **URL 별 정밀도가 원리적으로 안 나오므로 호출부가 어차피 다시
걸러야 한다** — 실제로 `decide` 가 `socialUrlOf` 로 그렇게 한다.

**② 훑는 양이 1건도 안 준다.** 인덱스에 `url` 밖에 없어 `status` 는 FETCH **이후**의 필터다.
실측(2026-08-11) 두 형태가 완전히 동일했다.

```
$elemMatch(url + status)   keysExamined=14  docsExamined=7  returned=7
url 만                      keysExamined=14  docsExamined=7  returned=7   ← 동일
(참고) idx_tokens_social_url_status 로 강제  keysExamined=116 docsExamined=96 returned=7
```

**③ 함정이 하나 사라진다.** `status` 를 넣으면 조건이 둘이 되어 **서로 다른 원소**에 걸릴 수
있고(실측 재현: `not_found` 인 X URL 이 같은 토큰의 `ok` 인 TikTok URL 때문에 매칭),
그것을 막으려 `$elemMatch` 가 필요해진다. 조건이 하나면 그 실패 모드 자체가 존재하지 않는다.

결과적으로 **재사용 가능 상태의 정의가 `REUSABLE_FETCH_STATUSES` 한 곳에만 산다.**

miss 면 ① 만. **② 는 히트 1건마다 한 번**이다 — URL 마다 소스 토큰이 다를 수 있어 하나로
묶지 못한다. 즉 쿼리 수는 `1 + 히트 수`이고, 실측 적중률(약 5%)에서 URL 4개짜리 토큰이면
평균 **약 1.2회**다. 히트가 많아질수록 늘지만, 늘어난 만큼 유료 호출을 아낀 것이라
바꿔치기가 이득인 방향이다.

### 소스 토큰은 **하나만** 고른다 — 앵커를 가진 토큰

②가 `entry_url` 단독이 아니라 `token_address` 와 함께인 것이 핵심이다. 앵커를 정하는 순간
*"어느 토큰의 관측을 믿을 것인가"* 가 이미 정해지므로, **그 토큰의 링크를 그대로 복사하면
Generator 가 만들었을 것과 같은 모양이 나온다.** 여러 토큰의 행을 합쳐 접는 처리 자체가 없다.

이것이 두 가지를 동시에 없앤다.

**① 링크 폭주.** `entry_url` 단독으로 긁으면 **모든 토큰의 행**이 온다. 그대로 복사하면
N번째 토큰이 `2^(N-1)` 건을 쓴다 — 선형 낭비가 아니라 폭주다.

```
        게이트가 읽는 행   그 토큰이 쓰는 행   DB 누적
A(생성)        —                 2              2
B              2                 2              4
C              4                 4              8
D              8                 8             16
```

`deleteByTokenAndEntryUrl` 도 못 막는다 — 그것은 *자기 토큰의* 이전 행만 지우는데 신규
토큰은 지울 것이 없다.

**② 인덱스 1개.** `{ token_address, entry_url }` 술어는 **기존
`idx_links_token_linked (token_address, created_at)` 의 접두를 그대로 탄다.**
`deleteByTokenAndEntryUrl` 이 이미 같은 모양이며, 그래서 `token-link.repository.ts` 의
*"`entry_url` 인덱스를 추가하지 않는다"* 주석은 **고칠 필요 없이 그대로 유효하다.**

### 인덱스 효과 (2026-08-11 실측)

| 조회 | 결과 |
|---|---|
| `token_links` — 소스 토큰 1개로 좁힘 | **`idx_links_token_linked` · 2건 훑어 2건** (기존 인덱스) |
| `token_links` — `entry_url` 단독(전 토큰) | COLLSCAN · 189건 훑어 6건 |
| `tokens` — URL 4개 `$in`, 인덱스 없음 | COLLSCAN · 58건 훑어 7건 |
| `tokens` — URL 4개 `$in`, 인덱스 있음 | IXSCAN keys=14 · **7건 훑어 7건** |

**새로 필요한 인덱스는 `tokens.social_urls.url` 하나뿐이다.** 멀티키지만 문서당 키 2~4개이고
평균 문서 크기가 393B 라 쓰기 비용은 무시할 수준이다.

### ⚠️ 멀티키 인덱스는 배열 원소를 걸러 주지 않는다

`{ 'social_urls.url': 1 }` 은 *그 URL 을 포함한 문서* 를 찾아 줄 뿐,
`social_urls[]` 안에서 해당 원소만 뽑아 주지 않는다. 앵커를 읽으려면 **앱에서 배열을
훑어 URL 이 일치하는 원소를 골라야 한다.** "DB 가 걸러 주겠지" 로 짜면 같은 토큰의
**엉뚱한 URL 의 `attempted_at`** 을 읽는다 — 컴파일도 통과하고 테스트도 우연히 지나갈 수 있다.

### 프로젝션을 건다

지금은 `tokens` 평균 393B 라 티가 안 나지만, `tokens.web`(urlscan 지문 17필드)이 **아직
비어 있다.** 별도 세션의 Website 작업이 들어오면 문서가 커지므로 `address` +
`social_urls` 만 뽑도록 미리 건다.

### Redis 는 쓰지 않는다 (검토함)

"url → 마지막 호출 시각" 을 Redis 에 두면 TTL 이 네이티브로 해결되고 조회가 1회다. 그러나
진실의 원천이 둘로 갈리고, 어차피 링크 재료는 Mongo 를 봐야 해서 ② 를 없애지 못한다.
Mongo 조회가 인덱스로 1ms 미만이라 얻는 것이 없다. (축출·재시작 시 소실은 fetch 쪽으로
실패하므로 안전한 방향이긴 하다 — 되살릴 이유가 생기면 이 문단을 근거로 재검토한다.)

---

## 11. 지켜야 할 것은 원칙 하나다

> **재사용 경로의 산출물 = Generator 경로가 만들었을 산출물.**
> 토큰 주소만 다르고 나머지는 같다.

소스 토큰 하나의 링크를 그대로 복사하므로(§10) 이 원칙이 거의 저절로 지켜진다.
따라 나오는 것이 하나 있고, 걸고 싶어지지만 걸면 안 되는 것이 하나 있다.

### 따라 나오는 것 — `deleteByTokenAndEntryUrl` (H-013)

dedupe 경로는 `write()` 를 타지 않으므로 delete-then-append 보상이 없다. `write()` 가
하는 것을 똑같이 하면 된다.

> ⚠️ **두 번 불리는 경로가 무엇인지 틀리게 적으면 이 삭제가 죽은 코드로 보인다.**
> 초안은 *"재시도(`recordOneUrl`)가 같은 URL 을 dedupe 로 두 번 탄다"* 고 적었는데,
> 이 작업 시점에는 `recordOneUrl` 이 게이트를 타지 않아 그 서술이 틀렸다. 그 근거를
> 믿고 감사하면 "재진입 경로가 없으니 삭제도 필요 없다" 는 결론이 나온다.
>
> 당시의 진짜 경로는 **`collect()` 의 중간 실패 후 전체 재처리**였다. 마커
> (`upsertByAddress`)가 일부러 가장 마지막이라(§3.3), 링크를 만든 뒤 마커 전에 죽으면
> `tokens` 행이 없어 다음 이벤트가 같은 토큰을 처음부터 다시 돈다. 그때 `linkExisting`
> 이 같은 `(token, entryUrl)` 로 재진입한다.
>
> 🔄 **2026-08-12 갱신** — `recordOneUrl` 도 게이트를 탄다(social-url-entry E-6).
> 그래서 재진입 경로가 **둘**이 됐고, 초안이 적었던 그 경로가 뒤늦게 실재하게 됐다.
> 삭제가 필요하다는 결론은 그대로지만 **근거가 하나 더 늘었다.**

> **Writer 안에서** 처리한다. *"하나의 그래프가 만드는 모든 연결을 기록하는 유일한 지점"*
> 이라는 경계(R-003)를 지키기 위해, 재사용 경로도 Processor 가 아니라 Writer 를 통과한다.

### 검사도 원칙 그대로 한다 — 동등성 테스트

항목을 쪼개 테스트하지 않는다. 원칙을 직접 검사하는 것이 더 강하고 오래 간다.

```
같은 URL 을
  토큰 A → Generator 경로
  토큰 B → dedupe 경로
로 처리한 뒤, 두 토큰의 token_links 가 token_address 만 빼고 동일한지 비교한다
```

이 하나가 depth·`object` 종류(accounts/contents/venues)·건수·`subtype` 을 전부 덮고,
앞으로 Generator 가 만드는 모양이 바뀌어도 자동으로 따라온다. 항목별 테스트는 그때 같이
안 고치면 조용히 낡는다.

### ⚠️ `link_depth` 에 min 을 걸지 않는다 — 걸고 싶어지는 자리다

이 문서 초안에는 *"`object_id` 별 최솟값을 취한다(H-001 재발 방지)"* 가 필수 항목으로
있었다. **틀린 판단이었고 근거를 잘못 읽은 것이다.** 다음 사람이 같은 곳에 빠지지 않도록
남긴다.

`depth` 는 `ordered.length - 1 - index`, 즉 **root 로부터의 거리**다. 같은 진입 URL 이면
인용 사슬이 결정적이라 객체별 거리가 안 변하고, 사슬이 중간에 끊겨도(인용 원본 삭제)
살아남은 객체의 거리는 그대로다. 게다가 `write()` 가 `linkByObjectId` 로 이미 접으므로
**(토큰, entry_url, object_id) 당 행이 하나이고 그 depth 는 이미 최솟값**이다.

실측(2026-08-11)이 그대로 확인해 준다.

```
같은 (entry_url, object_id) 인데 depth 가 다른 경우      →  0건
같은 (entry_url, object_id) 를 여러 토큰이 가진 경우     →  6건, depths 전부 단일값
```

초안이 근거로 삼았던 것은 **서로 다른 entry_url** 사이의 depth 차이였다
(`같은 object_id, entry_url 2개 → depths=[1, 0]`). 같은 계정이 자기 트윗에서는 0,
그 트윗을 인용한 트윗에서는 1인 **정상 동작**이다. 소스 토큰 하나만 복사하는 지금 설계에서는
애초에 여러 행을 비교할 일 자체가 없다.

남는 위험은 *"관측 사이에 Generator 의 사슬 로직이 바뀌는 것"* 하나인데, 그때 필요한 것은
방어적 min 이 아니라 마이그레이션이다. **min 을 걸면 불일치를 조용히 덮어 그 사실을 못 보게 된다.**

---

## 12. 스코프 밖

| 항목 | 이유 |
|---|---|
| 기존 행의 내용 갱신 | `existing ?? create` 정책 자체가 v5 §7 미결. TTL 로는 못 고친다(§7) |
| ~~동시성~~ **✅ 2026-08-12 해소** | 락이 토큰 주소 단위라 서로 다른 두 토큰이 같은 URL 을 동시에 처리하면 둘 다 miss 로 보고 둘 다 불렀다. **`lock-before-execute-recording`(L-2 · L-6)이 URL 단위 락을 넣어 닫았다** — 판정·호출·쓰기가 전부 락 안에서 일어나므로, 기다린 쪽은 앞 워커의 결과를 보고 재사용한다. 그 락이 값을 하려면 결과가 URL 단위로 즉시 기록돼야 해서 `pending` 모델(L-3 · L-4)이 함께 들어갔다 |
| Telegram 이관 탐지 | telegram.md §8 의 한계. U-4 로 dedupe 밖에 두는 것까지가 이번 범위 |
| URL miss 를 줄이는 정규화 강화 | `?s=20` 류 제거는 정규화 규칙 변경이라 저장된 URL 전체에 영향이 간다. 별도 작업 |
| 실패 상태 중 `error`·`blocked`·`suspended` 재사용 | 재시도로 결과가 바뀔 수 있거나(전자) 흡수지만 신호 자체가 목적인 것(후자)이라 이번엔 안 건드린다 |
| ~~**재시도 배치(`recordOneUrl`)의 재사용**~~ | ✅ **2026-08-12 완료** (social-url-entry E-6). 당시 미룬 이유는 *"`recordOneUrl` 이 URL 하나만 받아 배치(U-7)가 성립하지 않는다"* 였는데, **배치 시그니처를 그대로 두고 원소 1개짜리 배열을 넘기면 끝이었다** — `resolve([routed], now)`. U-7 이 막으려던 것은 «호출부가 루프를 도는 것» 이지 «원소가 하나인 것» 이 아니었다. 미룰 이유가 실제로는 없었다 |
| 게이트 조회 실패 시 **부분 히트 보존** | 지금은 URL 하나에서 던지면 그 토큰의 히트를 통째로 버리고 전부 정상 경로로 보낸다. 안전한 방향이라 그대로 두되, 낭비인 것은 맞다 |
| `web` 의 재사용 수명 | 아래 참고 |

---

## 13. 변경 지점

계약은 [`signatures/task-001-dedupe-gate.html`](../../docs/features/social-url-dedupe/signatures/task-001-dedupe-gate.html) 이,
작업 순서는 [`tasks/overview.md`](../../docs/features/social-url-dedupe/tasks/overview.md) 가 소유한다.

**새로 생기는 타입은 `DedupeHit` 하나다.** 초안에는 `ReusableLink`·`UrlAttemptRow` 가 더
있었으나 둘 다 제거했다 — `Token`·`TokenLink` 가 이미 자기 문서에 대해 답하는 자리라
(`retryCandidates`·`capDepth`) 거기 흡수하는 것이 이 레포의 구조에 맞다. 새 책임이 생기지 않는다.

### 신규

| 파일 | 역할 |
|---|---|
| `src/modules/social-recording/url-dedupe.gate.ts` | 배치 조회 + TTL 판정. `RoutedUrl[]` → `Map<string, DedupeHit>` |
| `test/unit/social-recording/url-dedupe.gate.spec.ts` | TTL 경계 · 시계 자기전진 방지 · `ok` 우선 · 상태 격리 · Telegram 제외 |

### 수정

| 파일 | 무엇 |
|---|---|
| `social-recording.types.ts` | `DedupeHit` 추가 — **이번 작업의 유일한 새 타입** |
| `social-recording.consts.ts` | `URL_REUSE_TTL_MS` 추가 (14일) |
| `social-record.processor.ts` | `// MEMO: 여기서 gate 로 url 처리한 적 있는 지…` 주석 자리에 게이트 삽입 · **헤더 초안 주석 갱신**(§3) · 생성자 주입 |
| `social-graph.writer.ts` | `linkExisting()` 추가 — delete-then-append 복제 |
| `token.model.ts` | `@index({ 'social_urls.url': 1 })` · `socialUrlOf()` 추가 |
| `token-link.model.ts` | `rebind()` 추가 (**인덱스 추가 없음** — 기존 것으로 충분, §10) |
| `token.repository.ts` | `findBySocialUrls()` 추가 (url 만 · 프로젝션 — `status` 를 넣지 않는 근거는 §10) |
| `token-link.repository.ts` | `findByTokenAndEntryUrl()` 추가 — 기존 주석은 **그대로 유효** |
| `social-recording.module.ts` | provider 등록 |
| `test/unit/social-graph.model.spec.ts` | 모델 메서드 2건 테스트 |
| `test/unit/social-graph.repository.spec.ts` | 리포지토리 메서드 목록을 못박는 테스트 — 메서드 추가 시 반드시 깨진다 |
| `test/unit/social-recording.spec.ts` | Processor 생성자 인자 추가 |

> ⚠️ `token-link.repository.ts` 의 주석 갱신은 선택이 아니다. 지금 거기엔 *"`entry_url`
> 인덱스를 추가하지 않는다 — 이 술어는 `idx_links_token_linked` 의 접두를 탄다"* 라고
> 근거까지 적혀 있는데, 그 근거는 **`token_address` 로 좁혀진 조회에만 성립한다.**
> 이번에 추가하는 것은 cross-token 조회다. 주석을 안 고치면 다음 사람이 주석을 믿고 인덱스를 지운다.

---

## 14. 실행 순서

상세는 [`tasks/overview.md`](../../docs/features/social-url-dedupe/tasks/overview.md).

```
T-001 시그니처 승인 ──── 여기까지 코드 0줄
  ▼
T-002 인덱스 1건 ─────── 먼저 넣지 않으면 게이트가 매 토큰마다 풀스캔한다
  ▼
T-003 모델 메서드 ──┐
                    ├─→ T-005 게이트 ──┐
T-004 리포지토리 ───┘                  ├─→ T-007 배선 · 실측
                    T-006 Writer ──────┘
```

---

## 15. 확정 요약

| ID | 결정 |
|---|---|
| U-1 | 게이트 `social_urls[]` · 페이로드 `token_links` |
| U-2 | TTL 14일 · URL 종류 분류 없이 균일 |
| U-3 | 앵커 = 재사용 status 후보의 `attempted_at` 최댓값(null 제외) · 앵커 없으면 영구 재사용 |
| U-4 | Telegram 제외 (무료 + `platformKey` 가 가변) |
| U-5 | 재사용 대상 `ok`·`not_found` — 둘 다 흡수 상태 · `ok` 우선 |
| U-6 | 재사용 표시 안 함 (`attempted: false`, `attempts: 0`) |
| U-7 | 배치 `$in` + 조건부 2단계 · **링크는 소스 토큰 하나에서만 복사** · 프로젝션 · Redis 안 씀 |
| U-8 | TTL 은 코드 상수 (env 아님) |
