URL 가공 파이프라인

tokenInfo 가 준 URL 한 건이 세 파이프라인을 지나며 어디서 바뀌고 어디서 읽히기만 하는가.

URL 관련 판단을 다시 할 때 근거로 쓰는 정본이다. 코드가 바뀌면 이 문서도 같이 바꾼다. 최종 갱신 2026-08-12.

현행 이 문서는 지금 코드 기준이다

이전 판의 §1~§4 는 v4 코드 기준이라 «낡았다» 는 경고를 달고 있었다. 그 절들을 현행 구조로 다시 썼다. 그 사이 바뀐 것은 단축 링크 해제(G-18) · 정규화 함수 분리(toCanonical / parseCanonical) · URL 재사용 게이트 · 유입 창구 넷이다.

2026-08-12 에 창구 도입 · route() 축소 · 재시도의 재사용 판정 셋이 더 들어갔다. 무엇이 어디로 옮겨갔는지는 §9 에 남겼다.

§1읽는 법

이 문서가 계속 쓰는 두 단어가 각각 무엇을 뜻하는가

URL 을 다루는 코드는 두 가지 일 중 하나를 한다. 이 문서는 그 둘을 끝까지 구분해서 부른다.

변형 (transform)문자열이 달라진다
입력과 다른 문자열을 돌려준다. www. 를 떼거나, 추적 파라미터를 지우거나, 단축 링크를 실제 주소로 바꾼다.
돌려준 값이 저장된다. 그래서 이 자리가 «저장되는 URL 의 출처» 다.
함수는 toCanonical · toCanonicalAll · toCanonicalOne · ShortlinkResolver.resolve.
읽기 (parse)문자열은 그대로다
URL 을 구조로 풀기만 한다. 호스트·경로 조각·쿼리 파라미터를 꺼낸다.
돌려준 것은 저장되지 않는다. classifyplatform_key 를 뽑는 데만 쓴다.
함수는 parseCanonical. 입력이 canonical 이 아니면 고치지 않고 던진다.
왜 굳이 갈랐나

예전에는 normalizeUrl() 하나가 둘을 겸했다. 그래서 비정규 URL 이 파이프라인 안쪽까지 들어와도 조용히 고쳐졌고, 어느 경로가 규칙을 어겼는지 아무도 알 수 없었다.

실제로 그 일이 있었다. 제너레이터 테스트 100곳이 https://www.youtube.com/... 처럼 사람이 브라우저에서 복사한 형태를 넣고 있었는데, 프로덕션 경로는 그런 값을 절대 넘기지 않는다. 함수를 가르자 그 100곳이 한꺼번에 드러났다.

§2전체 지도

진입점이 몇 개이고, 어디서 합류하고 어디서 갈라지는가

URL 처리를 시작시키는 진입점은 넷이다. 셋은 같은 함수로 합류하고, 하나만 다른 길로 간다.

재시도 배치
retryUnconverged
수렴 안 한 URL 을 다시 연다
↓ 앞의 셋은 여기서 만난다 ↓      ↓ 이쪽만 따로 ↓
파이프라인 A — record()
창구(조회 → 해제 → canonical) → 플랫폼 판정 → 재사용 판정 → 수집
파이프라인 B — recordOneUrl()
저장된 URL 을 읽어서 → 재사용 판정 → 수집
여기서 이미 답이 하나 나온다

«backfill 도 중복 체크를 하나» 라는 질문의 답은 한다 이다. 기간 재수집은 자기 경로를 갖지 않고 record() 를 그대로 부르므로, 실시간 스트림과 글자 그대로 같은 처리를 받는다.

다른 것은 재시도 하나뿐이다. 그것이 §4 의 주제다.

§3파이프라인 A — 실제 URL 한 건 따라가기

tokenInfo 가 준 값이 저장될 때까지 단계마다 어떤 문자열이 되는가

아래 값은 지어낸 예시가 아니라 dev DB 에 실제로 들어 있는 URL 이다. tracker 가 twitter 필드에 단축 링크를 담아 줬고, 이 문서를 쓰는 시점에 아직 안 풀린 채 남아 있는 두 건 중 하나다. 단축 해제까지 포함해 파이프라인 전체를 지나는 값이라 예시로 골랐다.

이 단계에서 생긴 것 이 단계에서 사라진 것 남았지만 남을 이유가 약한 것
이 추적이 드러내는 것 셋

하나 — 문자열이 바뀌는 단계는 둘뿐이다. 단축 해제(2)와 canonical 화(3). 그 뒤로 저장까지 같은 문자열이 그대로 흐른다.

둘 — 해제기가 돌려주는 값은 canonical 이 아니다. www. 와 추적 파라미터가 그대로 붙어 있다. 해제기의 계약이 «최종 정규화는 호출부가 한다» 라서 그렇다. 이것을 모르고 resolve() 결과를 바로 저장하면 비정규 URL 이 DB 에 들어간다.

셋 — 저장되는 platform_key 는 URL 이 아니다. 응답이 준 영상 id 다. URL 문자열은 social_urls[].urlentry_url 에만 남고, 객체의 정체성은 따로 산다.

§4파이프라인 B·C — A 와 무엇이 다른가

재시도와 재사용은 A 의 어느 단계를 건너뛰는가

A 를 다시 그리지 않는다. A 의 단계 목록을 그대로 두고, 건너뛰는 것을 지운다.

A · record()실시간 · 기간 재수집 · 수동
1업스트림 조회 (tokenInfo)
2단축 해제
3canonical 화
4플랫폼 판정
5재사용 판정
6Generator 호출
7행·링크 저장
B · recordOneUrl()재시도 배치
1업스트림 조회
2단축 해제 — 안 한다
3canonical 화
4플랫폼 판정
5재사용 판정 — 한다
6Generator 호출
7상태만 갱신
C · 재사용 히트A 의 5 단계에서 갈라진다
1업스트림 조회
2단축 해제
3canonical 화
4플랫폼 판정
5재사용 판정 — 히트
6Generator 호출
7링크만 복사

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 라는 뜻이라 재사용 가능 상태 필터에서 자동으로 빠진다.

B 에 단축 해제를 «넣으면» 되는 것 아닌가

안 된다. 넣으면 Generator 에 넘기는 URL 은 해제된 값이 되는데 tokens.social_urls[].url 은 단축인 채로 남는다. 그러면 token_links.entry_url(해제됨)과 social_urls[].url(단축)이 서로 다른 문자열이 되고, §7 의 문자열 대조가 전부 빗나간다.

단축 해제를 한 곳에서만 하기로 한 결정(G-18)이 막으려던 것이 정확히 이 이원화다. 고칠 자리는 재시도가 아니라 저장된 값 이다.

§5바뀌는 곳 vs 읽는 곳

코드 어디에서 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 만 조용히 빠졌다. 지금은 공용 함수 둘로 모았다 — 안 쓰면 눈에 띈다.

§6저장 결과

어느 필드에 어떤 형태로 남는가

필드형태실제 값과 비고
tokens.social_urls[].urlcanonicalhttps://tiktok.com/@ayseqeo/video/7669905419661233438
재시도 대상 선정과 재사용 판정이 이 문자열로 매칭한다.
token_links.entry_urlcanonical같은 값. 한 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_keyURL 이 아니다7669905419661233438 — 응답이 준 영상 id. web 만 예외로 URL 을 키로 쓴다(경로 있는 링크가 절반이 넘어 도메인으로 접으면 서로 다른 페이지가 한 행이 된다).
원본 URL어디에도 저장하지 않는다. 정규화는 손실적이라 되돌릴 수 없지만, 되돌릴 이유가 없다 — 정체성 축이 platform_key 라서 URL 형태가 옛것이어도 객체가 갈라지지 않는다.

§7문자열 동일성이 걸린 곳

정규화가 «있으면 좋은 것» 이 아니라 필수인 이유

세 곳이 URL 을 문자열 그대로 비교한다. 한쪽만 형태가 다르면 조용히 안 맞는다 — 오류가 아니라 «결과 없음» 으로 나타나기 때문에 눈에 안 띈다.

그런데 URL 문자열은 정체성 축이 될 수 없다

canonical 화는 경로 접미사와 모르는 쿼리 파라미터를 남긴다. /photo/1 이나 ?s=20 같은 것이다. 같은 트윗을 가리키는 두 URL 이 다른 문자열로 저장될 수 있다.

그래서 정체성은 platform_key 가 맡고, URL 문자열은 «같은 진입 URL 인가» 를 묻는 데만 쓴다. 위 세 곳은 전부 후자다 — 객체가 같은지가 아니라 진입 경로가 같은지를 묻는다.

§92026-08-12 에 바뀐 것

직전 구조와 무엇이 다른가 — 이 절은 변경 이력이다

적용됨 §1~§8 은 이미 이 결과를 반영하고 있다

계획(social-url-entry/design.md E-1 · E-4 · E-6)이 구현됐다. 이 절은 무엇이 어디로 옮겨갔는지 를 남겨 둔다 — 옮긴 이유를 모르면 되돌리려는 시도가 반복된다.

단계별 — 담당이 어디로 옮겨갔나

단계하는 일이전 담당현재 담당변화
1업스트림 조회Processor.collectTokenProfileReader.read담당 이동
2단축 해제Processor.routeSocialUrlsTokenProfileReader.read담당 이동
3canonical 화Router.routeTokenProfileReader.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 은 손대지 않았다.

Processor 가 보는 것이 이렇게 바뀐다

이전Processor 가 업스트림 타입을 안다
업스트림 응답 TokenInfo직접 받는다
필드 4종(website·twitter·telegram·discord)을 Processor 가 순회한다
쓰레기 값("none"·"TBA") 버리기도 Processor 가 한다
중복 제거도 Processor 가 한다
받은 URL 이 정규화됐는지 호출부가 알아야 한다
현재Processor 는 canonical 배열만 받는다
TokenProfile 을 받는다 — TokenInfo 는 이 아래로 안 샌다
필드 순회는 창구 안으로
쓰레기 버리기도 창구 안으로
중복 제거도 창구 안으로
받은 URL 은 이미 canonical 이다 — 판단할 것이 없다

파이프라인 B(재시도)가 얻은 것과 못 얻은 것

§4 의 문제계획이 고치나근거
재사용 판정을 안 한다 고쳤다 게이트를 붙였다 게이트가 자기 토큰도 후보로 돌려주지만, 자기 상태가 error·unsupported 라 재사용 대상에서 자동으로 빠진다.
단축 해제를 안 한다 안 고친다 의도적이다 넣으면 §4 가 설명한 이원화가 생긴다. 창구가 «앞으로» 를 막고, 이미 저장된 2건은 dev 데이터라 방치한다.
착수 전에 적어 둔 위험 둘 — 실제로 어떻게 됐나

하나 — route() 를 쓰는 곳이 13군데. 창구를 먼저 넣은 것이 실제로 답이 됐다. URL 을 어디서 가져올지가 «창구가 준 값» 으로 자명해져 전환이 기계적이었다. 토큰 인스펙터는 이미 .platform 만 쓰고 있어 그대로 옮겨갔다.

둘 — Processor 테스트의 커버리지. 창구를 목으로 대체하지 않고 실물 창구에 스텁 SDK 를 물렸다. 그래서 기존 테스트 1068건이 하나도 깨지지 않았고, 필드 순회·쓰레기 버리기·중복 제거 검증도 사라지지 않았다. 창구 전용 8건과 재시도 게이트 4건을 새로 더했다.

이 절이 그리지 않는

tokens.first_transfer_at 을 채우는 일은 여기 없다. 값이 주소 조회 응답에 없고 범위 조회 응답에만 있어서 창구가 구조적으로 못 얻는다 — 축이 다른 작업이라 backlog B-1 로 분리했다.

§10아직 안 맞는 것

이 문서를 쓰는 시점에 알고 있는 예외와, 언제 이 문서를 다시 열어야 하는가

항목규모상태
website media_urls 기존 3건 판정 뒤집힘 «website 만 비정규» 를 버그로 보고 정규화를 붙였다가, 같은 날 되돌렸다(2026-08-12). canonical 화가 사주는 것은 문자열을 맞대는 값뿐인데 이미지 주소는 맞대는 데가 없다 — website 쪽이 처음부터 맞았고 나머지 7 소셜을 원본으로 맞췄다. 저장된 3건도 그래서 손댈 것이 없다.

⚠️ 이 자리를 두 번 틀리게 만든 것은 픽스처였다. 공용 계약 검사기가 *Url(s) 를 전부 잡고 있었는데도 5 소셜에 회귀 가드가 없던 것처럼 보였다 — 픽스처 URL 이 전부 정규형이라 정규화를 걸든 안 걸든 결과가 같았기 때문이다. 지금은 8 소셜 전부 www. 가 붙은 실데이터 모양으로 픽스처를 바꿔 뒀고, 소셜별로 되돌려 보는 사보타주로 하나씩 빨간불이 뜨는 것을 확인했다.
저장된 단축 URL 2건 방치 §4 의 고리에 걸려 영원히 안 풀린다. 마이그레이션을 계획했다가 철회했다 — dev 데이터라 보존 가치가 없다. 새로 들어오는 것은 파이프라인 A 의 2 단계가 막는다.

이 문서를 다시 열어야 하는 순간

근거 코드 — 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 (저장)

관련 문서 — social-url-dedupe/design.md (재사용 규칙) · social-url-entry/design.md (예정된 변경) · schema-v5.html (저장 스키마)