# Lock Before Execute — 작업 계획

- 작성 2026-08-12 · 상태 **🟢 구현 완료** (시그니처 `signatures/task-001-url-lock.html`)
- draft: `docs/drafts/completed/lock-before-execute-recording-draft.md`
- 검토용 시각화: `design.html`

- 결정 ID 는 `L-*`. `D-*`(schema-v5) · `G-*`(generator) · `U-*`(url-dedupe) · `W-*`(website) ·
  `E-*`(url-entry) 는 이미 쓰인다.

> **⚠️ 계획을 세운 뒤 코드가 움직였다.** 이 문서의 §1 진단은 2026-08-12 오전 기준이었고,
> 그 사이 `social-url-entry`(E-1 · E-6)와 `token-image` 가 머지됐다. 아래 §1.4 에 **무엇이
> 달라졌고 그래서 계획의 무엇이 바뀌었는지**를 적었다. 결정(L-1 ~ L-14)은 하나도 바뀌지 않았다.

---

## 0. 한 줄 목표

**같은 URL 을 두 번 부르지 않는다 — 먼저 잡은 쪽이 끝내고 «기록까지» 하면, 기다린 쪽은
그 기록을 그대로 쓴다.**

지금은 서로 다른 두 토큰이 같은 URL 을 동시에 만나면 둘 다 미스로 보고 둘 다 부른다. 유료
플랫폼에서는 그대로 돈이다. dedupe 설계가 이 구멍을 **알려진 한계로 이미 적어 두었다**
(`docs/features/social-url-dedupe/design.md:466`) — 이번 작업이 그것을 닫는다.

닫는 방법은 URL 락 하나가 아니다. **락을 넣는 것만으로는 목표가 달성되지 않는다**(§1.2 H-2).
기록 시점을 URL 단위로 앞당기는 변경이 함께 가야 한다.

---

## 1. 진단 (2026-08-12)

### 1.1 지금의 흐름

**실시간·backfill** — `token.flow` 이벤트 → `record()`

```
tryLock(social-record:<token>)          ← 잡혀 있으면 즉시 return
  findByAddress → 있으면 종료            ← 문서 존재 = 처리 완료 마커
  solTrackerApi.getTokenByAddress
  routeSocialUrls  (단축 해제 → route → canonical 중복 제거)
  dedupeGate.resolve(routed)            ← 배치 1회, 루프 «밖»
  for url of routed:                    ← 순차
      hit ? reuseOne : collectOne       ← generate + writer.write
  upsertByAddress(Token.of(...))        ← tokens 쓰기는 «여기 한 번»
releaseQuietly
```

**retry 배치** — `POST /retry` → `retryUnconverged()`

```
tryLock(social-reconcile:retry)
  후보 선별: retryCandidates(statuses) → staleForMs >= 60분 → limit 절단
  for target of targets:
      recordOneUrl(target)              ← 토큰 락을 «여기서» 다시 잡는다
      resolveFinalStatus(attempts, ...) ← attempts 상한 → exhausted
      recordSocialUrlResult(...)        ← 원소 «하나만» 갱신
```

두 경로의 쓰기 방식이 다르다. 이 비대칭이 §1.2 H-2 의 원인이다.

| | 실시간 (`collect`) | retry (`recordOneUrl`) |
|---|---|---|
| tokens 쓰기 | 모든 URL 처리 후 **한 번** (`upsertByAddress`, 문서 통째) | URL 하나마다 (`recordSocialUrlResult`, 원소 하나) |
| dedupe 게이트 | 탄다 (배치 1회) | **안 탄다** (url-entry 갭2) |
| 업스트림 조회 | 한다 | 안 한다 |
| 재시도 정책(백오프·상한) | 없다 | 있다 (Service 소유) |

### 1.2 구멍

| # | 구멍 | 근거 | 닫는 결정 |
|---|---|---|---|
| **H-1** | **URL 축 상호배제가 없다.** 락이 토큰 주소 단위라 서로 다른 두 토큰이 같은 URL 을 동시에 처리하면 둘 다 부른다 | `social-recording.consts.ts` `RECORD_LOCK_PREFIX` 주석 · url-dedupe design.md:466 | L-2 |
| **H-2** | 🔴 **락을 잡아도 다음 사람이 볼 기록이 없다.** `collect()` 은 URL 이 하나 끝나도 아무 데도 안 남기고, tokens 를 루프가 끝난 뒤 한 번에 쓴다. 대기한 워커가 락을 얻어 재판정해도 **미스** → 중복 호출이 그대로 나간다 | `social-record.processor.ts:206,222` | **L-3 · L-4** |
| **H-3** | **대기할 수단이 없다.** `tryLock` 은 재시도 0(즉시 포기), `acquireLock` 은 `retryCount: 500 × 500ms` 고정이라 **최대 약 250초 뒤 throw**. 호출부가 상한을 정할 손잡이가 없다 | `redlock.sdk.ts:20-35,58-66` | L-2 |
| **H-4** | **토큰 락 TTL 침식.** URL 락 대기 시간이 토큰 락 TTL(120초) 예산에서 나간다. URL 여러 건이 각각 기다리면 토큰 락이 먼저 만료되고 토큰 층 보호가 조용히 사라진다 | `social-recording.consts.ts` `RECORD_LOCK_TTL_MS` | L-1 |
| **H-5** | **TTL 이 풀려도 코드는 계속 돈다(fencing 없음).** `using` 은 자동연장을 하지만 진입 시 `signal.aborted` 를 **한 번만** 보고, 연장 실패를 routine 에 전파하지 않는다 | `redlock.sdk.ts:44-56` | L-1 · L-2 |
| **H-6** | **retry 배치가 직렬화를 우회한다.** URL 축 진입점인데 URL 락이 없으면 배치와 스트림이 같은 URL 을 동시에 처리한다. 게다가 게이트를 안 타서 남이 이미 푼 URL 을 다시 부른다 | url-entry design.md §1.1 · §1.3 | L-9 · L-13 |
| **H-7** | **락 키 정규화.** `recordOneUrl` 은 단축 해제를 안 타므로, 원리적으로 같은 대상에 두 개의 락이 잡힐 수 있다 | url-entry design.md §1.2 | §7 (실측상 무해) |
| **H-8** | **재사용 불가 상태로 끝난 경쟁.** 앞 워커가 `error`·`blocked` 로 끝나면 기다린 쪽은 재판정도 미스라 결국 자기도 부른다 — 대기 비용만 쓰고 이득 0 | `social-recording.consts.ts` `REUSABLE_FETCH_STATUSES` | §7 (한계로 수용) |
| **H-9** | draft 의 **"ACID 를 보장"** 은 과한 표현. 다중 문서 트랜잭션이 없고 `contents`·`accounts` 의 유니크도 강등된 상태(C-3)라, 얻는 것은 **상호배제와 중복 호출 제거**이지 원자성·내구성이 아니다 | `social-graph.writer.ts` 클래스 주석 | §7 |

### 1.3 구멍이 아닌 것 (확인하고 지나감)

- **같은 이벤트 안의 중복 URL** — `routeSocialUrls` 의 `seen` 이 canonical 기준으로 이미 제거한다.
  자기 자신과의 데드락은 발생할 수 없다 (`social-record.processor.ts:243,266-271`)
- **같은 토큰 안 두 URL 의 fan-out 충돌** — 이건 URL 축이 아니라 **객체 축** 경쟁이라 URL 락이
  막아 주지 못한다. 그래서 URL 루프는 이번에도 **순차**를 유지한다(§6.1 F-1)
- **Redis 장애·failover** — 범위 밖(§7)

### 1.4 진단 이후 움직인 것 (2026-08-12 오후)

계획을 세운 뒤 세 커밋이 들어왔고, 그중 둘이 이 문서의 진단을 바꿨다.

| 무엇 | 진단 시점 | 지금 | 계획에 미친 영향 |
|---|---|---|---|
| **URL 유입 창구**(E-1, `6b1225f`) | Processor 가 `TokenInfo` 를 받아 필드 4종을 순회하고 단축 해제·정규화·중복 제거를 했다 | `TokenProfileReader` 가 그것을 전부 끝내고 **canonical 문자열 배열**만 넘긴다 | 선생성(L-3)이 더 쉬워졌다 — 심을 URL 목록이 이미 완성된 채로 온다. **H-7(락 키 정규화)이 사실상 닫혔다** |
| **재시도의 재사용 판정**(E-6, `f1bc6f6`) | `recordOneUrl` 은 게이트를 **안 탔다** | 탄다 | **H-6 의 절반이 이미 닫혀 있었다.** 남은 절반(URL 락)만 이번에 넣었다 |
| **토큰 이미지**(`37c0907`) | `Token.of` 에 이미지가 없었다 | `image` 가 **required** 다 | 선생성 시점에 이미지도 함께 심어야 한다. 창구가 `imageUrl` 없는 토큰을 아예 `null` 로 돌려주므로 갈래가 늘지는 않았다 |

**분류 실패의 모양도 바뀌었다.** 예전 `route()` 는 분류 못 하는 URL 에 `null` 을 줬지만, 지금
`platformOf()` 는 **던진다**(담당 없는 URL 은 `web` 으로 떨어지므로, 던지는 경우는 canonical 이
아닌 저장값뿐 = 우리 버그). 그래서 재시도 목록을 만들 때 그 예외를 **URL 단위로 격리**한다 —
그대로 올리면 URL 하나가 그 토큰의 나머지(선생성된 `pending` 포함)를 통째로 막는다.

---

## 2. 결정

### L-1 · 토큰 락은 «즉시 포기» 를 유지하고, TTL 추정을 자동연장으로 바꾼다

계약은 그대로다 — 다른 워커가 그 토큰을 잡고 있으면 **기다리지 않고 즉시 return** 한다.
같은 일을 할 것이므로 기다릴 이유가 없고, 기다리면 컨슈머 슬롯만 묶인다.

바뀌는 것은 만료 방식이다. URL 락 대기가 토큰 락 예산 안에서 일어나므로 최악 소요가
`URL 수 × (대기 상한 + 처리 상한)` 까지 늘어난다(§6). 그 값을 상수로 박으면 **크래시 후
그 토큰이 그만큼 오래 막힌다.** 자동연장이면 살아 있는 동안 계속 연장되고, 죽으면 TTL 후
풀린다 — 추정할 값 자체가 없어진다.

> ⚠️ `RedlockSDK` 에 **"즉시 포기 + 자동연장"** 조합이 없다. `using` 은 `retryCount: 500` 이라
> 즉시 포기 계약이 깨진다. 추가가 필요하다(§4.1).

**버린 쪽의 대가** — TTL 을 720~900초로 늘리는 안은 변경이 가장 작지만, 크래시 시 그 토큰이
최대 15분 막힌다. 120초 유지 + 만료 수용도 가능하다(URL 락이 실질 보호를 하므로 대가는
헛조회 몇 번뿐이다). 다만 *"토큰 락이 상시 만료되는 것이 정상"* 인 상태를 남기지 않는다.

### L-2 · URL 락은 «대기» 한다 — 상한 180초, 자동연장

토큰 락과 계약이 정반대인 것이 의도다.

- 토큰 락 = *같은 일*을 막는다 → 기다릴 이유가 없다
- URL 락 = **먼저 잡은 쪽의 결과를 쓰기 위해** 막는다 → 기다려야 이득이 생긴다

토큰 처리자는 **자기 토큰에 걸린 URL 을 전부 기록할 책임**이 있다. 그 책임 때문에 "포기하고
넘어간다" 가 기본이 될 수 없다.

자동연장을 같이 쓰는 이유는 H-5 다. TTL 이 먼저 풀린 채로 fetch·쓰기가 계속되면 두 워커가
동시에 쓰는 상태가 되고, 그건 락을 넣기 전과 같다.

### L-3 · `tokens` 문서를 «업스트림 조회 직후» `pending` 으로 선생성한다

이것이 H-2 를 닫는 핵심이다. 지금은 URL 하나가 끝나도 그 사실이 **어디에도 없어서**,
기다린 워커가 락을 얻어도 볼 것이 없다.

```
solTrackerApi.getTokenByAddress
routeSocialUrls
upsertByAddress( Token.of({ address, symbol, socialUrls: routed.map(pending) }) )
   ↑ status: pending · attempted_at: null · attempts: 0
```

> ⚠️ **문서가 이미 있으면 절대 부르지 않는다.** `upsertByAddress` 는 `$set: {...token}` 으로
> 문서를 통째로 덮어 다른 URL 의 상태와 `fingerprints` 를 날린다(H-015,
> `token.repository.ts:35-50`). 이 호출은 **신규 생성 경로 전용**이다.

**같이 딸려오는 의미 변경** — "문서 존재 = 처리 완료" 가 **"문서 존재 = 봤다"** 로 바뀐다.
완료 여부는 이제 URL 상태가 답한다. 그래서 L-8 이 필수로 따라온다.

**얻는 것 하나 더** — 부분 진행이 보존된다. 지금은 3번째 URL 에서 죽으면 마커가 없어 다음
처리가 **1·2번을 다시 부른다**(유료면 그대로 돈이다). pending 모델은 끝난 URL 을 다시 안 부른다.

### L-4 · URL 하나가 끝날 때마다 `recordSocialUrlResult` 로 원소를 갱신한다

`collect()` 도 retry 와 같은 쓰기 방식을 쓴다. 루프 끝의 `upsertByAddress` 는 사라진다.

이로써 **두 경로의 쓰기가 한 가지가 된다.** 부수 효과로 `collect()` 경로에서 H-015(통째
덮어쓰기) 위험이 사라진다.

**대가** — tokens 쓰기가 토큰당 1회에서 `1(생성) + URL 수(≤4)` 회로 는다. 인덱스 걸린 단일
문서 갱신이라 외부 호출 한 번의 소요에 비하면 무시할 수 있다.

### L-5 · URL 락은 «한 번에 하나만» 보유한다

루프 한 바퀴 안에서 잡고 놓는다. **두 개 이상을 동시에 들지 않으므로 hold-and-wait 가
성립하지 않고, 데드락이 원리적으로 불가능하다.** 정렬 획득 같은 장치가 필요 없다.

이것이 가능한 이유가 L-4 다. 결과가 락 안에서 이미 기록되므로, 락을 계속 들고 있을 이유가
없다. (L-4 가 없으면 tokens 를 쓸 때까지 락 전부를 들고 있어야 하고, 그때는 데드락 회피를
위해 canonical 정렬 획득이 강제된다.)

### L-6 · 판정·호출·쓰기가 «전부 락 안에서» 일어난다

불변식 한 줄: **"락 안에서 판정하고, 락 안에서 부르고, 락 안에서 쓴다."**

```
for (url of routed)                 ← 순차 (§1.3)
    lock(url)                       ← 대기, 자동연장
      ├ dedupe 판정 (이 URL 하나)
      ├ 히트 → writer.linkExisting
      ├ 미스 → generator.generate → writer.write
      └ recordSocialUrlResult       ← 여기까지가 락 안
    unlock(url)
```

루프 «밖»의 배치 판정(`dedupeGate.resolve(routed)`)은 **제거한다.** 락을 얻은 시점에는 그
판정이 이미 낡았고, 낡은 판정을 근거로 부르면 락을 넣은 의미가 없다.

> ⚠️ **U-7 을 뒤집는 결정이다.** 게이트에는 *"단건 시그니처로 바꾸지 마라, 호출부가 루프를
> 돌면 토큰당 최대 8회 쿼리가 된다"* 가 못박혀 있다(`url-dedupe.gate.ts:57-67`).
> 그 원칙은 **락이 없던 시절의 절약책**이었다. 이제 락 안 재확인이 정확성 요구사항이므로
> 전제가 바뀌었다. 게이트의 배열 시그니처는 그대로 두고 **호출부가 1건씩 넘긴다** —
> 판정 규칙이 한 군데(`decide`)에 남는 것이 유지된다.

쿼리 수는 URL 당 1회(히트면 링크 조회로 2회)다. 미스가 대부분인 실측 분포에서는 기존
`1 + 히트당 1` 과 크게 다르지 않다.

**판정 실패는 여전히 던지지 않는다** — 실패하면 전부 미스로 떨어뜨려 정상 경로로 보낸다
(`url-dedupe.gate.ts:105-113` 의 계약 유지).

### L-7 · `pending` 은 재시도 대상이고, 재사용 대상이 아니다

| 목록 | pending | 이유 |
|---|---|---|
| `RETRYABLE_FETCH_STATUSES` | **포함** | 아직 처리되지 않은 URL 이므로 다음 기회에 처리돼야 한다 |
| `REUSABLE_FETCH_STATUSES` | **제외** | 결과가 없다. 재사용할 대상 자체가 없다 |

`pending` 은 `attempted_at: null` 이라 `staleForMs = Infinity` 가 되어 **백오프 필터를 자동
통과한다**(`token.model.ts:193-195`). 아직 부른 적이 없으니 기다릴 이유가 없다 — 별도 분기
없이 기존 규칙 그대로 맞아떨어진다.

`attempts` 도 0 이라 `resolveFinalStatus` 의 `exhausted` 판정에 영향을 주지 않는다
(`social-reconcile.service.ts:276-289`).

### L-8 · 실시간 진입 판정은 «문서 있나» 가 아니라 «재시도 대상 URL 이 있나» 다

L-3 이 마커의 의미를 바꾸므로 조기 종료 조건도 같이 바뀐다.

```
findByAddress
├ 없음  → 업스트림 조회 → pending 선생성(L-3) → 대상 = 전체 URL
└ 있음  → 업스트림 «안» 부른다 → 대상 = retryCandidates(RETRYABLE) ∩ 백오프 통과
             └ 대상 0 건이면 종료
```

문서가 있는 경로에서는 **저장된 분류가 아니라 `route()` 로 다시 분류한다.** 분류 규칙의
버그를 고쳐도 이미 잘못 분류된 URL 에 영영 닿지 못하기 때문이다(v5 T-M1, `recordOneUrl` 과
같은 판단).

이것으로 백필 크론이 없어도 **거래가 계속되는 토큰은 이벤트마다 자가 회복한다.** 지금까지
그 역할을 하던 것이 "마커를 안 남겼으니 다음 이벤트가 사실상 재시도" 였는데(F-4), L-3 이
그 경로를 끊으므로 대체가 필요하다.

### L-9 · 재시도 정책은 한 벌만 두고 두 경로가 공유한다

지금 `SocialReconcileService` 안에만 있는 두 장치를 공용 부품으로 뺀다.

| 장치 | 현재 위치 | 없으면 |
|---|---|---|
| 60분 백오프 (`RETRY_MIN_INTERVAL_MINUTES`) | `social-reconcile.service.ts:200-202` | `error` URL 을 **이벤트마다** 재호출 → 유료면 그대로 돈 |
| `attempts` 상한 → `exhausted` (`MAX_ATTEMPTS`) | `social-reconcile.service.ts:276-289` | 영영 안 열릴 URL 을 무한히 두드림 |

`token.flow` 는 prefetch 10 의 고빈도 스트림이다(`token-flow.consts.ts:36`). 실시간 경로가
재시도를 겸하는 순간 이 두 장치 없이는 안 된다.

**규칙이 두 벌이 되면 어느 쪽이 진짜인지 알 수 없게 되므로** 값을 복사하지 않고 부품을
공유한다. 위치·이름은 시그니처 문서에서 확정한다.

### L-10 · 대기 상한을 넘기면 그 URL 은 `pending` 으로 두고 다음 URL 로 간다

포기가 아니라 **미룸**이다. 앞 워커가 그 URL 을 끝내 주고 있으므로, 못 기다린 쪽은 잃는 것이
없다 — 다음 이벤트나 배치가 가져가고 그때는 **히트로 공짜 처리**된다.

`recordSocialUrlResult` 를 부르지 않으므로 상태는 `pending` 그대로 남고 `attempts` 도 오르지
않는다. 외부를 부르지 않았으니 사실 그대로다.

### L-11 · 숫자

§6 참조. URL 락 TTL 180초 · 대기 상한 180초 · 백오프와 `attempts` 상한은 기존 값 유지.

### L-12 · 기존 적체도 그대로 켠다

L-8 은 신규 토큰만이 아니라 **이미 저장된 토큰 전부에 즉시 적용된다.** 지금까지 "문서가
있으니 스킵" 으로 방치되던 미수렴 URL 들이 그 토큰에 거래가 있는 순간부터 재처리 대상이 된다.

단계적 플래그를 두지 않는다 — 어차피 해야 할 처리이고, 백오프(60분)와 `attempts` 상한이 이미
막고 있으며, 유료는 `SOCIAL_RECORD_ENABLE_PAID` 라는 전역 안전판이 따로 있다
(`social-record.processor.ts:476-478`). 배포 후 **유료 호출량을 관찰**하는 것으로 갈음한다.

### L-13 · 진입점 통합은 이번 작업이 아니다

L-8 · L-9 로 `record()` 와 `recordOneUrl()` 의 차이가 **"업스트림을 부르는가" 하나로** 줄어든다.
합치고 싶어지는 자리지만 이번엔 합치지 않는다 — 이번 작업의 주제는 동시성이고, 창구 일원화는
`social-url-entry`(E-*)가 이미 다루고 있다. 그쪽 backlog 에 **"두 경로 통합"** 을 추가한다.

이번엔 양쪽에 **같은 부품을 붙이는 것**까지만 한다: URL 락 · 공용 재시도 정책 · 락 안 판정.

### L-14 · 범위 밖

백필 크론 · Redis 장애 시나리오 · 무한 `pending` 해소(§7).

### L-15 · 실시간 경로가 다시 여는 상태는 «`pending` · `error`» 둘뿐이다

**L-8 의 대상 목록을 `RETRYABLE_FETCH_STATUSES` 에서 좁힌다.** 구현 후 코드 리뷰에서 나온
결정이고, 발단은 L-12 의 근거가 부분적으로 거짓이었다는 것이다.

```
RETRYABLE = [error, unsupported, skipped_paid, pending]
REALTIME  = [error, pending]                            ← 실시간이 여는 것
```

**빠진 둘은 백오프를 «원리적으로» 통과한다.** `unsupported`·`skipped_paid` 는 외부를 부르지
않아 `attempted_at` 이 `null` 로 남고, `retryCandidates` 가 그 경우 `staleForMs` 를 `Infinity`
로 계산한다. 처리해도 결과가 같은 값이라 상태가 안 바뀌고 `attempts` 도 안 올라 상한에도
안 걸린다 — **자기 자신을 재생산하는 고리**다.

고리 한 바퀴의 비용: URL 락 왕복(Redis 2) + 게이트 조회(Mongo 1~2) + 원소 갱신(Mongo 1).
외부 호출은 0회라 **돈은 안 나가지만**, `token.flow` 는 고빈도 스트림이라 그 토큰이 거래될
때마다 그대로 반복된다.

**그 둘이 열리는 조건은 «배포» 다** — 담당 Generator 가 생기거나 유료 스위치를 켜는 것.
거래가 일어난다고 바뀌지 않으므로 이벤트가 두드릴 이유가 없고, 사람이 부르는 `/retry`
(기본값이 `RETRYABLE_FETCH_STATUSES`)가 그대로 담당한다.

**L-12 정정** — *"백오프와 `attempts` 상한이 이미 막고 있다"* 는 `error` 에만 해당한다.
`attempted: false` 인 상태에는 둘 다 안 걸린다. 배포 후 관찰 대상은 유료 호출량이 맞지만,
그 근거는 백오프가 아니라 **이 상태 목록**이다.

**검증:** V-8(통합) — `skipped_paid` 로 끝난 URL 이 다음 이벤트에서 다시 불리지 않는다.

---

## 3. 흐름

### 3.1 지금 — 두 워커가 같은 URL 을 만난다

```
워커1 (토큰 A)                        워커2 (토큰 B)
 dedupeGate.resolve → 미스             dedupeGate.resolve → 미스      ← 둘 다 미스
 generate(url)  💸                     generate(url)  💸              ← 둘 다 유료 호출
 writer.write                          writer.write                   ← contents 중복 행 가능
 upsertByAddress                       upsertByAddress
```

### 3.2 바뀐 뒤

```
워커1 (토큰 A)                        워커2 (토큰 B)
 lock(url) ─ 획득                      lock(url) ─ 대기 ▒▒▒▒▒▒▒▒
   판정 → 미스
   generate(url)  💸                            ▒▒▒▒▒▒▒▒
   writer.write                                 ▒▒▒▒▒▒▒▒
   recordSocialUrlResult(A, url, ok)            ▒▒▒▒▒▒▒▒   ← 이 시점에 «보인다»
 unlock(url) ─────────────────────────▶ 획득
                                        판정 → 히트 (A 의 링크를 소스로)
                                        writer.linkExisting          ← 호출 0회
                                        recordSocialUrlResult(B, url, ok)
                                       unlock(url)
```

180초 안에 못 얻으면 워커2 는 그 URL 을 `pending` 으로 두고 다음 URL 로 간다(L-10).

---

## 4. 변경 지점

### 4.1 `src/nest-core/redis/redlock.sdk.ts`

신규 계약 둘. **시그니처는 별도 문서에서 확정한다**(§9 T-1).

| 계약 | 용도 | 없으면 |
|---|---|---|
| **대기 상한을 받는 획득** | URL 락(L-2) | `acquireLock` 의 고정 250초가 토큰 락 TTL 을 넘겨 H-4 가 그대로 남는다 |
| **즉시 포기 + 자동연장** | 토큰 락(L-1) | `using` 은 `retryCount: 500` 이라 "잡혀 있으면 즉시 return" 이 깨진다 |

기존 `tryLock`·`acquireLock`·`releaseQuietly` 는 그대로 둔다 — 다른 호출부(cron helper,
reconcile 작업 락)가 쓰고 있다.

### 4.2 `src/modules/social-recording/social-recording.consts.ts`

- `URL_LOCK_PREFIX = 'social-url'` — 최종 키는 `social-url:<canonical url>`
- `URL_LOCK_TTL_MS = 180_000`
- `URL_LOCK_WAIT_MS = 180_000`
- `RECORD_LOCK_TTL_MS` — 값은 유지하되 의미가 "전체 소요 상한" 에서 **"자동연장의 기준 간격"**
  으로 바뀐다. 주석 갱신 필요

### 4.3 `src/modules/social-graph/social-graph.consts.ts`

- `FetchStatus.PENDING = 'pending'` 추가
- `RETRYABLE_FETCH_STATUSES` 에 추가 (L-7)
- `REUSABLE_FETCH_STATUSES` 는 **그대로** (L-7)

### 4.4 `src/modules/social-recording/social-record.processor.ts`

가장 크게 바뀐다.

- `collect()` — 조기 종료 판정을 L-8 로 교체, pending 선생성(L-3), 루프 끝
  `upsertByAddress` 제거
- URL 루프 — 각 회차를 URL 락으로 감싸고(L-2·L-5), 판정을 락 안으로(L-6), 끝에
  `recordSocialUrlResult`(L-4), 대기 초과 시 건너뛰기(L-10)
- `recordOneUrl()` — URL 락 추가. 게이트 통과 여부는 L-13 대로 이번엔 건드리지 않는다
- `resolveFinalStatus` 를 공용 부품에서 받아 쓴다(L-9)

### 4.5 `src/modules/social-recording/social-reconcile.service.ts`

- 백오프 필터와 `resolveFinalStatus` 를 공용 부품으로 이관, 그것을 호출하도록 교체
- 나머지 로직(후보 선별·limit·deadline·작업 락)은 그대로

### 4.6 `src/modules/social-recording/url-dedupe.gate.ts`

- 배열 시그니처 유지. 호출부가 1건씩 넘긴다
- U-7 주석에 **"락 안 재확인이 정확성 요구사항이 되어 배치 전제가 바뀌었다"** 는 근거 추가

### 4.7 인덱스·마이그레이션

- 인덱스 변경 **없음**. `idx_tokens_social_url_status` 가 `pending` 도 그대로 커버한다
  (`token.model.ts:65`)
- 데이터 마이그레이션 **없음**. 기존 문서에 `pending` 원소는 생기지 않는다.
  기존 토큰은 L-8 의 "문서 있음" 경로를 그대로 탄다

---

## 5. 상태 어휘 — `pending` 이 건드리는 곳

| 자리 | 처리 |
|---|---|
| `FetchStatus` | 값 추가 |
| `RETRYABLE_FETCH_STATUSES` | 포함 |
| `REUSABLE_FETCH_STATUSES` | 제외 (변경 없음) |
| `UrlDedupeGate.decide` | 자동 제외 — `REUSABLE` 로 거르므로 별도 분기 불필요 |
| `resolveFinalStatus` | `attempted: false` 라 상태를 그대로 돌려준다. 변경 불필요 |
| `Token.retryCandidates` | `attempted_at: null` → `staleForMs = Infinity` → 백오프 통과. 변경 불필요 |
| `idx_tokens_social_url_status` | 변경 없음 |
| 재시도 API 의 `statuses` 파라미터 검증 | enum 확장분 허용 여부 확인 필요 |
| schema-v5 문서 상태표 | 갱신 |

**분기를 거의 안 만드는 것이 이 상태의 장점이다** — 기존 규칙들이 `attempted_at: null` ·
`attempts: 0` 을 이미 올바르게 다루고 있어서, `pending` 은 그 규칙들 위에 그대로 얹힌다.

---

## 6. 숫자와 근거

### 6.1 외부 호출 1건의 최악 소요 (코드 실측)

| 경로 | 상한 | 근거 |
|---|---|---|
| apify (유료) | 90초 + 15초 = **105초**, 재시도 없음 | `apify.sdk.ts:26,58,154` |
| twitter-api | 30초 × 3회 + 백오프 ≈ **95초** | `twitter-api.consts.ts:10-12` |
| urlscan | 15초 + 5회 × (3초 + 15초) = **105초** | `urlscan.client.ts:68-69,154-155` |
| 일반 HTTP | **15초** | `social-fetcher.consts.ts:5` |
| 단축 해제 | 홉당 5초 × 3홉 = **15초** (URL 락 «밖», 진입 단계) | `shortlink-resolver.ts:55` |

Generator 하나가 fetcher 를 여러 번 부를 수 있어(fan-out) URL 1건은 이보다 커질 수 있다.

### 6.2 정하는 값

| 값 | 제안 | 근거 |
|---|---|---|
| **URL 락 TTL** | 180초 | 최악 단일 호출 105초 + fan-out·DB 쓰기 여유. 정상 종료 시 즉시 해제되므로 이 값은 **크래시 대비용**이다 |
| **URL 락 대기 상한** | 180초 | **TTL 과 같게 잡는 것이 근거가 깔끔하다** — 앞 워커가 살아 있으면 이 안에 끝내고, 죽었으면 TTL 만료로 풀린다. 어느 쪽이든 내 차례가 온다. 넘기면 회수 가치가 없다고 보고 미룬다(L-10) |
| **토큰 락** | 자동연장 (TTL 은 연장 간격 기준) | 최악이 `URL 수 × (대기 + 처리)` = 최대 720초까지 늘어나 상수로 박을 수 없다(L-1) |
| **백오프** | 60분 (`RETRY_MIN_INTERVAL_MINUTES`) | 기존 값 유지. 실시간 경로에도 같은 값 적용(L-9) |
| **`attempts` 상한** | 5 (`MAX_ATTEMPTS`) | 기존 값 유지 |

---

## 7. 한계와 범위 밖

### 7.1 남기는 한계

| 한계 | 왜 이번에 안 고치나 |
|---|---|
| **무한 `pending` 가능성** — 대기 상한 초과가 반복되면 `attempts` 가 안 올라 `exhausted` 에 영영 도달하지 못한다 | `unsupported` 가 같은 구조로 이미 존재한다(url-entry §1.2). 반복 초과는 "경쟁이 계속된다" 는 뜻이고 언젠가는 잡힌다. 비용이 0(외부 호출 없음)이라 급하지 않다 |
| **Generator 가 외부 호출 «전»에 던지면 백오프가 안 걸린다** — `attempted` 가 `false` 라 `attempted_at` 이 없고, 그러면 `staleForMs` 가 `Infinity` 라 모든 백오프를 통과한다. 실시간이 재시도를 겸하면서 **이벤트마다 `generate()` 가 다시 불린다** | 구현 중 통합 테스트 V-6 이 이 경계를 드러냈다 — 평범한 `Error` 로는 백오프가 안 걸리고 `ExternalFetchError` 로는 걸린다. **외부 호출이 0회라 돈은 안 나간다.** 고치려면 «부르기 전에 우리 코드가 터진 것» 을 별도 상태로 가르거나 `attempted` 와 무관한 시계를 하나 더 두어야 하는데, 둘 다 이번 주제 밖이다 |
| **H-8 · 재사용 불가 상태 경쟁** — 앞 워커가 `error`·`blocked` 로 끝나면 기다린 쪽은 미스라 결국 자기도 부른다 | 대기 비용만 쓰고 이득이 0인 경우다. 이걸 없애려면 "실패도 재사용" 이 필요한데, 재시도로 결과가 바뀔 수 있는 상태를 얼리는 것이라 dedupe 설계가 이미 거절했다 |
| **H-7 · 락 키 정규화** — `recordOneUrl` 은 단축 해제를 안 탄다 | 저장된 URL 은 실측 100% canonical 이라(url-entry §1.4) 실질 무해하고, 근본 수정은 `social-url-entry`(E-*)의 일이다 |
| **새로 추가된 소셜 URL 을 못 본다** — 문서가 있으면 업스트림을 다시 안 부른다(L-8) | 현행과 동일한 한계다(D-1). 이번 작업이 만들지도 넓히지도 않는다 |
| **같은 토큰 안 두 URL 의 fan-out 충돌** | 객체 축 경쟁이라 URL 락의 사정권 밖이다. 순차 처리 유지로 현행과 같다(§6.1 F-1) |

### 7.2 범위 밖

- **백필 크론** — 이 레포에는 `@nestjs/schedule` 도 `@Cron` 도 없고 retry 는 수동 API 뿐이다
  (`social-reconcile.controller.ts:84`). L-8 이 실시간 자가 회복을 대신하므로 이번엔 넣지 않는다
  - ⚠️ **이 설계 시점의 전제다(덮지 않는다).** 2026-08-13 `9cdf1c2` 가 `@nestjs/schedule` 과
    인프로세스 `SocialRetryCron`(1시간 주기, prod 전용)을 들였다. «이번엔 넣지 않는다» 는
    당시 범위 판단으로 유효했고, 지금은 그 크론이 존재한다
- **Redis 장애·failover** — 인프라가 죽는 시나리오는 애플리케이션 설계가 답할 문제가 아니다
- **진입점 통합** — `social-url-entry` 로 넘긴다(L-13)

### 7.3 draft 문구 정정

draft 마지막 줄의 *"token 단위 락 + url 단위 락으로 ACID 를 보장"* 은 이 작업이 실제로 주는
것보다 크다. **다중 문서 트랜잭션이 없고**(레플리카셋 없이는 불가) `contents`·`accounts` 의
유니크도 강등된 상태(C-3)다.

정확히 얻는 것은 이것이다:

- **상호배제** — 같은 URL 에 대한 처리가 한 번에 하나
- **중복 외부 호출 제거** — 같은 대상을 두 번 사지 않는다
- **부분 진행 보존** — 중간에 죽어도 끝난 URL 을 다시 부르지 않는다(L-3 의 부수 효과)

---

## 8. 검증 계획

동시성은 단위 테스트로 증명되지 않는다. **통합 테스트(실 Redis + 실 Mongo)로 세운다** —
`docker-compose.test.yml` 과 `test/integration/` 이 이미 있다.

| # | 시나리오 | 통과 기준 |
|---|---|---|
| V-1 | 서로 다른 두 토큰이 **같은 URL** 로 동시에 진입 | Generator 호출이 **정확히 1회**. 양쪽 토큰 모두 그 URL 이 `ok` 로 남고 링크가 각각 생긴다 |
| V-2 | 같은 토큰으로 이벤트 2건 동시 진입 | 두 번째는 토큰 락에서 **즉시 return**(대기하지 않는다) |
| V-3 | 앞 워커가 URL 처리 중 프로세스 종료 | 그 URL 은 `pending` 으로 남고, 다음 진입이 **이미 끝난 URL 은 다시 부르지 않는다** |
| V-4 | 락 대기 상한 초과 | 그 URL 은 `pending` 유지 · `attempts` 불변, 나머지 URL 은 정상 처리된다 |
| V-5 | 문서가 이미 있는 토큰에 이벤트 도착 | 업스트림을 **부르지 않고**, 재시도 대상 URL 만 처리한다. 대상이 없으면 아무것도 안 한다 |
| V-6 | 백오프 안에 이벤트가 연달아 도착 | 60분 안에는 같은 URL 에 외부 호출이 **다시 나가지 않는다** |
| V-7 | 앞 워커가 `error` 로 끝난 뒤 대기자 진입 | 대기자는 정상적으로 자기 호출을 한다(H-8 이 의도된 동작임을 고정) |

---

## 9. 작업 분할

| ID | 작업 | 선행 | 비고 |
|---|---|---|---|
| ✅ **T-1** | **시그니처 문서** — `RedlockSDK` 신규 계약 2종 · `SocialRecordProcessor` 변경 시그니처 · 공용 재시도 정책 부품 · `FetchStatus.PENDING` | — | **승인 전 코드 0줄** |
| ✅ T-2 | `RedlockSDK` — 대기 상한 획득 · 즉시 포기 + 자동연장 | T-1 | 단위 테스트 포함 |
| ✅ T-3 | `FetchStatus.PENDING` 추가 + 상태 목록 갱신(§5) | T-1 | schema-v5 문서 동시 갱신 |
| ✅ T-4 | 재시도 정책 공용 부품 추출(백오프 · `resolveFinalStatus`)과 `SocialReconcileService` 교체 | T-1 | 동작 변화 없음이 통과 기준 |
| ✅ T-5 | `collect()` 재구성 — pending 선생성(L-3) · URL 단위 기록(L-4) · 진입 판정 교체(L-8) | T-3, T-4 | 이 단계까지는 락이 없어도 동작해야 한다 |
| ✅ T-6 | URL 락 적용 — `collect()` 루프(L-2·L-5·L-6·L-10) | T-2, T-5 | |
| ✅ T-7 | `recordOneUrl()` 에 URL 락 적용 | T-6 | H-6 을 닫는다 |
| ✅ T-8 | 통합 테스트 V-1 ~ V-7 | T-7 | |
| ✅ T-9 | 문서 갱신 — url-dedupe design.md:466 의 "알려진 한계" 해소 기록 · url-entry backlog 에 진입점 통합 추가 | T-8 | |

**T-5 와 T-6 을 가른 것이 의도다.** T-5 만으로도(락 없이) 부분 진행 보존과 자가 회복이 먼저
들어오고, 그 상태에서 회귀를 확인한 뒤 락을 얹는다. 한 번에 넣으면 실패했을 때 **기록 모델이
문제인지 락이 문제인지 가를 수 없다.**

### 9.1 구현 결과 (2026-08-12)

T-1 ~ T-9 전부 완료. 계획과 달라진 것은 셋이다.

| 무엇 | 계획 | 실제 | 왜 |
|---|---|---|---|
| `RedlockSDK` 계약 이름 | «대기 상한 획득» · «즉시 포기 + 자동연장» | `usingWithin` · `tryUsingExtended` | 둘 다 `redlock.using` 기반이어야 자동연장이 된다. 그래서 «획득» 이 아니라 «실행» 형태로 나왔다 |
| `SocialReconcileService` 의 쓰기 | 유지 | **제거** | 락 «안»에서 써야 하므로 Processor 로 옮겼다. Service 는 반환된 최종 상태로 `converged` 만 센다 |
| V-4 검증 위치 | 통합 | **단위 + 통합 나눔** | URL 락 대기 상한이 180초라 통합에서 실제로 기다릴 수 없다. 대기 초과 자체는 단위(`urlLockAcquired: false`)가, «못 잡으면 상태를 안 건드린다» 는 통합이 본다 |

**검증 결과** — 단위 1,180건 전부 통과. 통합은 새 파일 `test/integration/url-lock.integration.spec.ts`
7건 전부 통과했고, 나머지 20건 실패는 **이번 작업 이전부터 있던 것**이다(브랜치 stash 로 기준선을
직접 측정해 확인: 변경 전 20 실패 / 변경 후에도 같은 20 실패, 파일도 동일 —
`social-graph.schema` 인덱스 감사 13 · `reddit-recording` 5 · `social-recording` 2).

---

*근거가 된 draft: `docs/drafts/completed/lock-before-execute-recording-draft.md`*
