tokenInfo 가 준 URL 한 건이 세 파이프라인을 지나며 어디서 바뀌고 어디서 읽히기만 하는가.
URL 관련 판단을 다시 할 때 근거로 쓰는 정본이다. 코드가 바뀌면 이 문서도 같이 바꾼다. 최종 갱신 2026-08-12.
이전 판의 §1~§4 는 v4 코드 기준이라 «낡았다» 는 경고를 달고 있었다. 그 절들을 현행 구조로 다시 썼다. 그 사이 바뀐 것은 단축 링크 해제(G-18) · 정규화 함수 분리(toCanonical / parseCanonical) · URL 재사용 게이트 · 유입 창구 넷이다.
2026-08-12 에 창구 도입 · route() 축소 · 재시도의 재사용 판정 셋이 더 들어갔다. 무엇이 어디로 옮겨갔는지는 §9 에 남겼다.
이 문서가 계속 쓰는 두 단어가 각각 무엇을 뜻하는가
URL 을 다루는 코드는 두 가지 일 중 하나를 한다. 이 문서는 그 둘을 끝까지 구분해서 부른다.
www. 를 떼거나, 추적 파라미터를 지우거나, 단축 링크를 실제 주소로 바꾼다.toCanonical · toCanonicalAll · toCanonicalOne · ShortlinkResolver.resolve.classify 가 platform_key 를 뽑는 데만 쓴다.parseCanonical. 입력이 canonical 이 아니면 고치지 않고 던진다.예전에는 normalizeUrl() 하나가 둘을 겸했다. 그래서 비정규 URL 이 파이프라인 안쪽까지 들어와도 조용히 고쳐졌고, 어느 경로가 규칙을 어겼는지 아무도 알 수 없었다.
실제로 그 일이 있었다. 제너레이터 테스트 100곳이 https://www.youtube.com/... 처럼 사람이 브라우저에서 복사한 형태를 넣고 있었는데, 프로덕션 경로는 그런 값을 절대 넘기지 않는다. 함수를 가르자 그 100곳이 한꺼번에 드러났다.
진입점이 몇 개이고, 어디서 합류하고 어디서 갈라지는가
URL 처리를 시작시키는 진입점은 넷이다. 셋은 같은 함수로 합류하고, 하나만 다른 길로 간다.
token-social-record.handlertoken.flow 이벤트reconcileByRangesocial-record-testretryUnconvergedrecord()recordOneUrl()«backfill 도 중복 체크를 하나» 라는 질문의 답은 한다 이다. 기간 재수집은 자기 경로를 갖지 않고 record() 를 그대로 부르므로, 실시간 스트림과 글자 그대로 같은 처리를 받는다.
다른 것은 재시도 하나뿐이다. 그것이 §4 의 주제다.
tokenInfo 가 준 값이 저장될 때까지 단계마다 어떤 문자열이 되는가
아래 값은 지어낸 예시가 아니라 dev DB 에 실제로 들어 있는 URL 이다. tracker 가 twitter 필드에 단축 링크를 담아 줬고, 이 문서를 쓰는 시점에 아직 안 풀린 채 남아 있는 두 건 중 하나다. 단축 해제까지 포함해 파이프라인 전체를 지나는 값이라 예시로 골랐다.
하나 — 문자열이 바뀌는 단계는 둘뿐이다. 단축 해제(2)와 canonical 화(3). 그 뒤로 저장까지 같은 문자열이 그대로 흐른다.
둘 — 해제기가 돌려주는 값은 canonical 이 아니다. www. 와 추적 파라미터가 그대로 붙어 있다. 해제기의 계약이 «최종 정규화는 호출부가 한다» 라서 그렇다. 이것을 모르고 resolve() 결과를 바로 저장하면 비정규 URL 이 DB 에 들어간다.
셋 — 저장되는 platform_key 는 URL 이 아니다. 응답이 준 영상 id 다. URL 문자열은 social_urls[].url 과 entry_url 에만 남고, 객체의 정체성은 따로 산다.
재시도와 재사용은 A 의 어느 단계를 건너뛰는가
A 를 다시 그리지 않는다. A 의 단계 목록을 그대로 두고, 건너뛰는 것을 지운다.
record()실시간 · 기간 재수집 · 수동tokenInfo)recordOneUrl()재시도 배치B 에서 1·3 을 지운 것은 문제가 아니다. 재시도가 읽는 URL 은 이미 저장된 값이고, 저장된 값은 정의상 canonical 이다. 업스트림을 다시 부를 이유도 없다 — URL 과 심볼이 전부 우리 문서 안에 있다.
2 — 단축 해제를 안 한다. 그래서 저장된 단축 URL 은 영원히 안 풀린다. 고리가 닫혀 있다. unsupported 는 재시도 대상이고 → 재시도가 단축 URL 을 그대로 Generator 에 넘기고 → Generator 는 영상 id 를 못 뽑아 다시 unsupported 를 돌려주고 → 다시 재시도 대상이 된다. unsupported 는 «외부를 안 불렀다» 는 뜻이라 시도 횟수가 안 올라가고, 그래서 exhausted 로 얼어붙지도 못한다.
비용은 0이다. 외부 호출이 안 나가기 때문이다. 깨진 것은 수렴 모델 이다 — 영원히 못 끝나는 항목이 배치 목록에 남는다.
5 는 닫혔다(2026-08-12). 재시도도 게이트를 거치므로 다른 토큰이 이미 푼 URL 에는 호출이 안 나간다. 게이트 조회가 자기 토큰도 후보로 돌려주지만, 재시도 대상이라는 것은 그 URL 상태가 error·unsupported 라는 뜻이라 재사용 가능 상태 필터에서 자동으로 빠진다.
안 된다. 넣으면 Generator 에 넘기는 URL 은 해제된 값이 되는데 tokens.social_urls[].url 은 단축인 채로 남는다. 그러면 token_links.entry_url(해제됨)과 social_urls[].url(단축)이 서로 다른 문자열이 되고, §7 의 문자열 대조가 전부 빗나간다.
단축 해제를 한 곳에서만 하기로 한 결정(G-18)이 막으려던 것이 정확히 이 이원화다. 고칠 자리는 재시도가 아니라 저장된 값 이다.
코드 어디에서 URL 이 달라지고, 어디에서는 절대 안 달라지는가
| 위치 | 하는 일 | 근거 |
|---|---|---|
| TokenProfileReader.read | 변형 | URL 이 시스템에 들어오는 창구다. 업스트림 응답을 받아 해제 · canonical 화 · 중복 제거까지 끝낸다. 여기서 나간 뒤로는 아무도 URL 을 안 고친다. |
| ShortlinkResolver.resolve | 변형 | 리다이렉트 체인을 따라가 실제 주소를 얻는다. 통제 밖 값(Location 헤더)을 다룬다. 돌려주는 값은 canonical 이 아니다 — 그건 호출부(창구) 몫이다. |
| router.platformOf() | 읽기 | 호스트로 담당 소셜을 찾기만 한다. URL 을 돌려주지 않는다(2026-08-12). 예전 route() 는 canonical 화까지 해서 돌려줬고 호출부가 그 값을 저장했다 — 라우터가 저장 값의 출처가 되는 셈이었다.⚠️ 호스트를 얻는 데 parseCanonical(읽기)을 쓴다. 처음엔 toCanonical(변형)을 썼는데, 그러면 «안 고친다» 고 선언해 놓고 비정규 입력을 조용히 받아 준다 — 고친 문자열은 버리고 host 만 쓰니 티가 안 난다. 그 사이 중복 게이트가 이 URL 을 social_urls[].url 과 그대로 맞대므로, 라우팅은 성공했는데 매칭만 조용히 빗나간다.그래서 null 이 사라졌다. 담당 없는 호스트는 web 으로 떨어지므로 «판정 실패» 라는 상태가 없다. recordOneUrl 에 있던 unsupported 로 접는 분기도 함께 지웠다 — 그게 발동하는 유일한 조건이 «저장된 URL 이 비정규» 였는데, 그건 분류 규칙 문제가 아니라 우리 버그다. |
| website.rss · website.extract | 변형 | HTML 에서 뽑은 링크다. 통제 밖 값이다. |
Generator — outbound_urls | 변형 | 본문에서 뽑은 링크라 형태를 보장할 수 없는데 social_urls[].url 과 문자열을 맞댄다. toCanonicalAll 을 쓴다. |
Generator — media_urls · avatar_url · icon_url | 그대로 | 표시용은 정규화하지 않는다(2026-08-12). 맞대는 값이 아니라 canonical 화가 사줄 것이 없는데, www. 는 항상 떼고 제거 목록의 _t·_d·si·pp 와 이름이 겹치면 CDN 파라미터를 지운다 — 다른 그림이 오거나 403 이다. displayUrls() 는 null 만 걷어낸다. |
Generator — generate() 첫 줄 | 읽기 | 넘어온 URL 은 이미 canonical 이다. parseCanonical 로 분해만 하고, 어긋난 값이 오면 던진다. |
| fetcher.classify | 읽기 | 분해 결과에서 platform_key 를 뽑는다. URL 문자열을 손대지 않는다. |
| UrlDedupeGate | 읽기 | 문자열 동일성만 본다. |
| SocialGraphWriter | 읽기 | 받은 값을 그대로 저장한다. |
URL 을 고칠 수 있는 곳은 그 URL 이 우리 시스템에 처음 들어오는 지점뿐이다. 위 표에서 «변형» 인 넷은 전부 통제 밖의 값을 받는 자리다 — 업스트림 응답 · fetcher 응답 · 리다이렉트 헤더.
그 아래로는 아무도 안 고친다. 안쪽에서 또 고치면 «어디서 고쳐진 값인지» 를 추적할 수 없고, 규칙을 어긴 경로가 영영 안 드러난다.
예전에는 제너레이터마다 normalizeAll·normalized·normalizeOne·normalizedOne 이라는 사설 헬퍼가 14벌 복제돼 있었다. 이름조차 갈려 있었고, website 제너레이터에는 아예 없었다.
사설이라 아무것도 강제하지 않았고, 그래서 website 만 조용히 빠졌다. 지금은 공용 함수 둘로 모았다 — 안 쓰면 눈에 띈다.
어느 필드에 어떤 형태로 남는가
| 필드 | 형태 | 실제 값과 비고 |
|---|---|---|
| tokens.social_urls[].url | canonical | https://tiktok.com/@ayseqeo/video/7669905419661233438재시도 대상 선정과 재사용 판정이 이 문자열로 매칭한다. |
| token_links.entry_url | canonical | 같은 값. 한 URL 에서 fan-out 된 객체들이 전부 이 값을 공유해서 묶인다. |
| contents.outbound_urls[] | canonical | 본문에서 뽑은 외부 링크. social_urls[].url 과 문자열을 맞대야 해서 정규화가 필수다(§7). |
| accounts.outbound_urls[] | canonical | 프로필 소개란의 링크. 위와 같다. |
| accounts.avatar_url | 원본 | 표시용. 식별에도 매칭에도 안 쓴다 — 그래서 손대지 않는다(2026-08-12). |
| venues.icon_url | 원본 | 위와 같다. |
| contents.media_urls[] | 원본 | fetcher 가 준 그대로 담는다 — 모델 주석이 원래부터 그렇게 선언하고 있었다. 예전에는 7 소셜이 canonical 화하고 website 만 원본이라 갈려 있었는데, website 쪽이 맞았다. |
| contents.platform_key | URL 이 아니다 | 7669905419661233438 — 응답이 준 영상 id. web 만 예외로 URL 을 키로 쓴다(경로 있는 링크가 절반이 넘어 도메인으로 접으면 서로 다른 페이지가 한 행이 된다). |
| 원본 URL | — | 어디에도 저장하지 않는다. 정규화는 손실적이라 되돌릴 수 없지만, 되돌릴 이유가 없다 — 정체성 축이 platform_key 라서 URL 형태가 옛것이어도 객체가 갈라지지 않는다. |
정규화가 «있으면 좋은 것» 이 아니라 필수인 이유
세 곳이 URL 을 문자열 그대로 비교한다. 한쪽만 형태가 다르면 조용히 안 맞는다 — 오류가 아니라 «결과 없음» 으로 나타나기 때문에 눈에 안 띈다.
contents.outbound_urls 에 tokens.social_urls[].url 과 같은 값이 있는지 본다. 이것이 «이 콘텐츠가 토큰의 사이트를 가리키는가» 의 유일한 판정이다.social_urls[].url 과 맞대 «다른 토큰이 이미 처리했나» 를 본다. 안 맞으면 중복인데 다시 호출한다 — 유료 소셜이면 그대로 돈이다.(token_address, entry_url) 로 이전 링크를 지우고 다시 넣는다. 안 맞으면 지우지 못한 채 새로 넣어 링크가 두 배가 된다.canonical 화는 경로 접미사와 모르는 쿼리 파라미터를 남긴다. /photo/1 이나 ?s=20 같은 것이다. 같은 트윗을 가리키는 두 URL 이 다른 문자열로 저장될 수 있다.
그래서 정체성은 platform_key 가 맡고, URL 문자열은 «같은 진입 URL 인가» 를 묻는 데만 쓴다. 위 세 곳은 전부 후자다 — 객체가 같은지가 아니라 진입 경로가 같은지를 묻는다.
직전 구조와 무엇이 다른가 — 이 절은 변경 이력이다
계획(social-url-entry/design.md E-1 · E-4 · E-6)이 구현됐다. 이 절은 무엇이 어디로 옮겨갔는지 를 남겨 둔다 — 옮긴 이유를 모르면 되돌리려는 시도가 반복된다.
| 단계 | 하는 일 | 이전 담당 | 현재 담당 | 변화 |
|---|---|---|---|---|
| 1 | 업스트림 조회 | Processor.collect | TokenProfileReader.read | 담당 이동 |
| 2 | 단축 해제 | Processor.routeSocialUrls | TokenProfileReader.read | 담당 이동 |
| 3 | canonical 화 | Router.route | TokenProfileReader.read | 핵심 |
| 4 | 플랫폼 판정 | Router.route → {url, platform} | Router.platformOf → platform | 축소 |
| 5 | 재사용 판정 | UrlDedupeGate — A 에서만 | UrlDedupeGate — A · B 둘 다 | 확대 |
| 6 | 분해 | Generator → parseCanonical | 그대로 | |
| 7 | 분류 | Fetcher.classify | 그대로 | |
| 8 | 저장 | SocialGraphWriter | 그대로 | |
한 줄로 줄이면 — 단계 1·2·3 이 두 클래스에 흩어져 있던 것을 창구 하나로 모았고, Router 에는 4 만 남겼다. 6·7·8 은 손대지 않았다.
TokenInfo 를 직접 받는다website·twitter·telegram·discord)을 Processor 가 순회한다"none"·"TBA") 버리기도 Processor 가 한다TokenProfile 을 받는다 — TokenInfo 는 이 아래로 안 샌다| §4 의 문제 | 계획이 고치나 | 근거 |
|---|---|---|
| 재사용 판정을 안 한다 | 고쳤다 게이트를 붙였다 | 게이트가 자기 토큰도 후보로 돌려주지만, 자기 상태가 error·unsupported 라 재사용 대상에서 자동으로 빠진다. |
| 단축 해제를 안 한다 | 안 고친다 의도적이다 | 넣으면 §4 가 설명한 이원화가 생긴다. 창구가 «앞으로» 를 막고, 이미 저장된 2건은 dev 데이터라 방치한다. |
하나 — route() 를 쓰는 곳이 13군데. 창구를 먼저 넣은 것이 실제로 답이 됐다. URL 을 어디서 가져올지가 «창구가 준 값» 으로 자명해져 전환이 기계적이었다. 토큰 인스펙터는 이미 .platform 만 쓰고 있어 그대로 옮겨갔다.
둘 — Processor 테스트의 커버리지. 창구를 목으로 대체하지 않고 실물 창구에 스텁 SDK 를 물렸다. 그래서 기존 테스트 1068건이 하나도 깨지지 않았고, 필드 순회·쓰레기 버리기·중복 제거 검증도 사라지지 않았다. 창구 전용 8건과 재시도 게이트 4건을 새로 더했다.
tokens.first_transfer_at 을 채우는 일은 여기 없다. 값이 주소 조회 응답에 없고 범위 조회 응답에만 있어서 창구가 구조적으로 못 얻는다 — 축이 다른 작업이라 backlog B-1 로 분리했다.
이 문서를 쓰는 시점에 알고 있는 예외와, 언제 이 문서를 다시 열어야 하는가
| 항목 | 규모 | 상태 |
|---|---|---|
website media_urls |
기존 3건 | 판정 뒤집힘 «website 만 비정규» 를 버그로 보고 정규화를 붙였다가, 같은 날 되돌렸다(2026-08-12). canonical 화가 사주는 것은 문자열을 맞대는 값뿐인데 이미지 주소는 맞대는 데가 없다 — website 쪽이 처음부터 맞았고 나머지 7 소셜을 원본으로 맞췄다. 저장된 3건도 그래서 손댈 것이 없다. ⚠️ 이 자리를 두 번 틀리게 만든 것은 픽스처였다. 공용 계약 검사기가 *Url(s) 를 전부 잡고 있었는데도 5 소셜에 회귀 가드가 없던 것처럼 보였다 — 픽스처 URL 이 전부 정규형이라 정규화를 걸든 안 걸든 결과가 같았기 때문이다. 지금은 8 소셜 전부 www. 가 붙은 실데이터 모양으로 픽스처를 바꿔 뒀고, 소셜별로 되돌려 보는 사보타주로 하나씩 빨간불이 뜨는 것을 확인했다. |
| 저장된 단축 URL | 2건 | 방치 §4 의 고리에 걸려 영원히 안 풀린다. 마이그레이션을 계획했다가 철회했다 — dev 데이터라 보존 가치가 없다. 새로 들어오는 것은 파이프라인 A 의 2 단계가 막는다. |
x.com 과 twitter.com 은 지금 다른 값이다. 접으려면 canonical 화에 별칭 표가 들어가고, 그 순간 순수 함수가 «표에 의존하는 함수» 로 성격이 바뀐다.platform_key 를 못 만드는 소셜이 나올 때. 정체성 축이 그것 하나뿐이라 대안이 없다. 그때는 §7 의 «URL 은 정체성 축이 아니다» 를 다시 판단해야 한다.src/common/social-fetcher/url-normalizer.ts (변형·읽기 분리) ·
src/common/social-fetcher/shortlink-resolver.ts (단축 해제) ·
src/common/social-fetcher/social-fetcher.router.ts (플랫폼 판정) ·
src/modules/social-recording/social-record.processor.ts (파이프라인 A·B) ·
src/modules/social-recording/url-dedupe.gate.ts (재사용 판정) ·
src/modules/social-recording/social-graph.writer.ts (저장)