링크를 모으는 일은 쉽다. 어려운 건 시간이 지나도 깔끔하게 유지하는 일이다. 개인 북마크든 팀 위키의 링크 섹션이든, 혹은 주소아지트나 비슷한 링크모음 서비스에 업로드되는 주소모음이든, 사람 손만으로는 중복과 변형 URL을 걸러내기 힘들다. 한 페이지가 추적 파라미터만 다른 형태로 반복해서 저장되고, 쇼트너와 원본이 섞여 올라오고, 모바일과 데스크톱 도메인이 나뉘어 새어 들어온다. 검색 속도는 느려지고 추천 품질도 떨어진다. 이 글은 그 문제를 실무적으로 풀어내는 자동화 스크립트 설계와 구현에 관한 기록이다. 수십만 건 단위에서 실제로 써본 전략과, 부딪혔던 엣지 케이스, 그리고 안전망을 어떻게 깔아야 하는지까지 담았다.
무엇을 중복으로 볼 것인가
중복 제거의 난이도는 정의에서 시작한다. 단순 문자열 비교로는 금방 벽을 만난다. 다음과 같은 경우가 자주 등장한다.
URL 문법적 변형. Http와 https 차이, www 존재 여부, 슬래시 유무, 대소문자 혼합, 포트 명시 유무가 얕은 중복을 만든다. 예를 들어 http://example.com 과 https://www.example.com/ 는 같은 문서일 가능성이 높다. 반대로 경로가 /index 와 /index.html 로 둘 다 배포되는 사이트도 있다. 어떤 규칙을 기본값으로 삼을지 팀 합의가 필요하다.
질문표 뒤의 추적 파라미터. Utm_source, gclid, fbclid, ref, spm 같은 파라미터는 콘텐츠와 무관하다. 보안 토큰이나 세션 식별자는 더 골치 아프다. 토큰을 무조건 지우면 접근이 막힐 수도 있기 때문이다. 유지해야 하는 파라미터의 화이트리스트와 지워야 하는 블랙리스트를 함께 운영하는 방식이 현실적이다.
프래그먼트. 문서 내부 앵커를 가리키는 #section 은 많은 경우 중복으로 본다. 다만 단일 문서에서 앵커별로 의미가 큰 문서라면 예외를 둔다. 예를 들어 API 문서의 각 메서드를 고정 링크로 공유한다면 프래그먼트를 유지해야 한다.
리다이렉트와 정규화. 쇼트 URL, 트래킹 URL, 심지어 canonical 메타 태그로 통합되는 경우가 있다. HEAD 나 GET 요청으로 최종 목적지를 따라가야 하는데, 이 행위는 네트워크 비용과 차단 리스크를 동반한다. 라우팅을 많이 쓰는 서비스는 HTTP 상태만으로는 부족해 HTML의 link rel=canonical 을 파싱해야 할 때도 있다.

모바일과 데스크톱 도메인. M.example.com 과 www.example.com 처리가 까다롭다. 둘이 완전히 같은 콘텐츠를 서빙하면 중복으로 보고 합치는 편이 낫다. 다만 섞여 있는 경우가 있다. M 도메인이 일부 경로만 존재한다면 무작정 합치면 404로 굴러간다.
국제 도메인과 Punycode. 한글 도메인은 IDNA 규약대로 정규화해 비교해야 한다. 브라우저가 알아서 처리해 주지만 스크립트는 직접 변환해야 일관성을 유지할 수 있다.
이것들을 종합하면 중복의 정의는 정교한 함수가 된다. 보수적으로 접근하면 중복을 덜 지우고, 공격적으로 접근하면 데이터는 깨끗해지지만 오탐이 늘어난다. 주소모음 서비스에서 사용자 노출을 생각하면 보수적, 내부 추천용 인덱스를 만들 때는 공격적이 유리했다. 목적에 따라 두 가지 프로파일을 병행 운영하는 편이 좋았다.
파이프라인 설계의 뼈대
처음부터 거대한 프레임워크를 만들 필요는 없다. 일주일 안에 돌아가는 것을 만들고, 그다음 주부터 위험한 부분을 덧붙이면 된다. 다음 파이프라인은 그런 식으로 자리잡았다.
- 파싱과 전처리. 입력 소스에서 URL, 제목, 태그, 메모 등 메타데이터를 구조화한다. HTML 북마크 파일, CSV, JSON, Markdown, 주소아지트 내보내기 포맷 등 포맷별 파서를 둔다. URL 정규화. 스킴, 호스트, 포트, 경로, 쿼리, 프래그먼트를 규칙에 따라 정리한다. IDNA, 소문자화, 중복 슬래시 제거, 디폴트 포트 삭제, 파라미터 필터링을 적용한다. 네트워크 해석. 가능한 범위에서 리다이렉트를 추적해 최종 URL을 얻고, HTML이면 canonical 링크를 읽는다. 과도한 호출을 막기 위해 도메인별 속도 제한과 캐시를 둔다. 내용 지문 생성. 빠르게 바이트 해시를 만들고, 가능하면 제목과 본문에서 텍스트 시그니처를 뽑아 근사 중복까지 잡는다. 실패 시에도 정규화 URL만으로 1차 중복을 제거한다. 병합 정책 적용. 두 항목이 충돌하면 우선순위를 정해 병합한다. 예를 들어 사용자 메모가 긴 쪽을 보존하고 태그는 합집합을 만들며, 저장 시점은 최신을 살린다.
각 단계는 독립 함수로 두고, 중간 결과를 파일이나 SQLite 같은 가벼운 DB로 남겨 재시도를 쉽게 만든다. 특히 네트워크 해석과 내용 지문 단계는 캐시 유무가 성능을 좌우한다.
입력 데이터와 내보내기 포맷
데이터가 흩어져 있으면 중복 제거의 효과가 줄어든다. 처음에 가장 시간을 쓴 부분은 포맷 정리였다. 브라우저 북마크는 Netscape 북마크 HTML 포맷을 내보내므로 파싱이 쉽다. 회사 내부 위키는 Markdown 링크 구문이 섞여 있었다. 주소모음 서비스에서 가져온 데이터는 CSV와 JSON으로 나뉘었다. 내 경험상 다음 전략이 안정적이었다.
모든 소스를 중간 표현으로 수렴시킨다. 필드는 최소한 url, title, tags, note, added_at, source 정도면 충분하다. Tags 는 콤마로 연결된 문자열보다 리스트로 정규화하는 편이 낫다.
저장 형식은 SQLite와 Parquet를 섞었다. SQLite는 조작과 쿼리가 편하다. 반면 대량 배치와 압축에는 Parquet가 유리하다. 스크립트는 SQLite를 읽고 쓰되, 배치를 끝낸 스냅샷은 Parquet로 떨궜다.
내보내기는 쓰는 곳에 맞춘다. 개인 브라우저로 재주입할 때는 Netscape HTML, 팀 문서에는 Markdown 목록, 주소아지트나 다른 링크모음에 재업로드할 때는 그 서비스의 가져오기 스펙에 맞추면 된다. 여기서도 정규화한 URL을 사용한다.
파이썬으로 구현하는 핵심 함수
언어나 런타임은 무엇이든 좋다. 다만 파이썬은 생태계가 넓고 네트워크와 텍스트를 다루는 라이브러리가 잘 갖춰져 있다. 현업에서 쓴 함수를 단순화해 공개 가능한 형태로 정리했다. 의존성은 requests, beautifulsoup4, tldextract 정도면 출발할 수 있다. 대량 처리에는 aiohttp를 붙인다.
Import re Import time Import hashlib Import html Import json Import sqlite3 From urllib.parse import urlparse, urlunparse, parse_qsl, urlencode, quote, unquote Import requests From bs4 import BeautifulSoup Import tldextract Import idna # 추적 파라미터 블랙리스트와 보존 화이트리스트 TRACKING_PARAMS = "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid", "yclid", "mc_cid", "mc_eid", "igshid", "spm", "ref" PRESERVE_PARAMS = "id", "q" DEFAULT_TIMEOUT = 8 HEADERS = "User-Agent": "LinkDeduper/1.2 (+https://example.org/tools)" Def normalize_host(host: str) -> str: # 소문자화와 IDNA 정규화 Host = host.strip().rstrip(".").lower() Try: # 이미 punycode면 idna.decode가 통과한다 Ascii_host = idna.encode(host).decode("ascii") # 비교를 위해 ascii punycode로 보관 Return ascii_host Except idna.IDNAError: Return host Def strip_default_port(parsed): Netloc = parsed.netloc If ":" in netloc: Host, port = netloc.split(":", 1) If (parsed.scheme == "http" and port == "80") or (parsed.scheme == "https" and port == "443"): Return host Return netloc Def clean_path(path: str) -> str: # 중복 슬래시 제거, 디코딩 가능한 범위만 디코딩 후 안전 문자만 재인코딩 Path = re.sub(r"/2,", "/", path) Try: Path = unquote(path) Except Exception: Pass Safe = quote(path, safe="/:@-._~!$&'()*+,;=") # 일부 사이트의 /index, /index.html 처리 정책은 환경에 맞게 조정 Return safe Def filter_query(query: str) -> str: If not query: Return "" Pairs = parse_qsl(query, keep_blank_values=True) Kept = [] For k, v in pairs: Lk = k.lower() If lk in PRESERVE_PARAMS: Kept.append((k, v)) Elif lk in TRACKING_PARAMS: Continue Elif lk.startswith("_") or lk.endswith("clid"): Continue Else: Kept.append((k, v)) If not kept: Return "" Kept.sort() Return urlencode(kept, doseq=True) Def canonicalize(url: str) -> str: """ 네트워크 호출 없이 적용하는 정규화. """ Url = url.strip() Parsed = urlparse(url) Scheme = parsed.scheme.lower() if parsed.scheme else "http" Host = normalize_host(parsed.hostname or "") # www 정규화 정책. 여기서는 www를 유지한다. 필요 시 제거 규칙을 적용. Netloc = host If parsed.port: If not ((scheme == "http" and parsed.port == 80) or (scheme == "https" and parsed.port == 443)): Netloc = f"host:parsed.port" Path = clean_path(parsed.path or "/") Query = filter_query(parsed.query) Fragment = "" # 대부분 프래그먼트는 제거 Normalized = urlunparse((scheme, netloc, path, "", query, fragment)) Return normalized Def follow_redirects(url: str, session: requests.Session, max_hops=5): """ HEAD 우선, 실패 시 GET으로 최종 목적지와 canonical 링크를 확인. """ Seen = set() Current = url For _ in range(max_hops): If current in seen: Break Seen.add(current) Try: Resp = session.head(current, allow_redirects=False, timeout=DEFAULT_TIMEOUT, headers=HEADERS) Except requests.RequestException: # 일부 서버는 HEAD를 싫어한다 Try: Resp = session.get(current, allow_redirects=False, timeout=DEFAULT_TIMEOUT, headers=HEADERS) Except requests.RequestException: Break If 300 <= resp.status_code < 400 and "Location" in resp.headers: Loc = resp.headers["Location"] Parsed = urlparse(current) # 상대 리다이렉트 보정 Next_url = urlparse(loc)._replace( Scheme=parsed.scheme if not urlparse(loc).scheme else urlparse(loc).scheme, Netloc=parsed.netloc if not urlparse(loc).netloc else urlparse(loc).netloc ) Current = canonicalize(urlunparse(next_url)) Continue # 200대 응답이면 canonical 태그를 확인 If 200 <= resp.status_code < 300 and "text/html" in resp.headers.get("Content-Type", ""): Try: Html_text = resp.text[:200_000] Soup = BeautifulSoup(html_text, "html.parser") Link = soup.find("link", rel=lambda v: v and "canonical" in v) If link and link.get("href"): Href = link["href"] If href.startswith("//"): Href = f"urlparse(current).scheme:href" Elif href.startswith("/"): Href = f"urlparse(current).scheme://urlparse(current).netlochref" Current = canonicalize(href) Except Exception: Pass Break Return current Def fetch_title_and_hash(url: str, session: requests.Session): """ 빠른 지문 생성을 위해 제목과 짧은 본문 텍스트 해시를 만든다. """ Try: Resp = session.get(url, timeout=DEFAULT_TIMEOUT, headers=HEADERS) Ct = resp.headers.get("Content-Type", "") Raw = resp.content[:512_000] Sha = hashlib.sha256(raw).hexdigest() Title = "" If "html" in ct.lower(): Soup = BeautifulSoup(raw, "html.parser") If soup.title and soup.title.text: Title = soup.title.text.strip() Else: H1 = soup.find("h1") If h1: Title = h1.text.strip() Return title, sha, resp.status_code Except requests.RequestException: Return "", "", None Def content_signature(title: str, sha256: str) -> str: """ 제목을 정규화해 단순화된 시그니처를 만든다. """ T = re.sub(r"\s+", " ", title.lower()).strip() T = re.sub(r"[\[\(【].*?[\]\)】]", "", t) # 괄호 안 잡음 제거 Core = (t[:120] + "|" + sha256[:16]) if sha256 else t[:120] Return core Class Deduper: Def __init__(self, db_path="dedupe_cache.sqlite"): Self.db = sqlite3.connect(db_path) Self._init_db() Self.session = requests.Session() Def _init_db(self): Cur = self.db.cursor() Cur.execute(""" Create table if not exists cache ( Url text primary key, Final_url text, Title text, Sha256 text, Status integer, Updated_at integer ) """) Cur.execute("create index if not exists idx_final on cache(final_url)") Self.db.commit() Def cached_get(self, url: str): Cur = self.db.cursor() Cur.execute("select final_url, title, sha256, status from cache where url=?", (url,)) Row = cur.fetchone() If row: Return row Final_url = follow_redirects(url, self.session) Title, sha, status = fetch_title_and_hash(final_url, self.session) Cur.execute("replace into cache(url, final_url, title, sha256, status, updated_at) values (?, ?, ?, ?, ?, ?)", (url, final_url, title, sha, status or 0, int(time.time()))) Self.db.commit() Return final_url, title, sha, status Def dedupe(self, items): """ Items: iterable of dicts with keys url, title, tags, note, added_at, source Returns: list of merged items and duplicates report """ By_url = By_sig = Kept = [] Dupes = [] For it in items: Raw = it.get("url", "").strip() If not raw: Continue Norm = canonicalize(raw) Final_url, fetched_title, sha, status = self.cached_get(norm) Canon = canonicalize(final_url) It_title = it.get("title") or fetched_title Sig = content_signature(it_title or fetched_title, sha) # 1차 키: 최종 canonical URL Key1 = canon # 2차 키: 내용 시그니처 Key2 = sig Winner = by_url.get(key1) or by_sig.get(key2) If not winner: By_url[key1] = it By_sig[key2] = it It["_norm_url"] = canon It["_sig"] = sig Kept.append(it) Else: Merged = self._merge(winner, it) # 인덱스 업데이트 By_url[key1] = merged By_sig[key2] = merged # 승자 교체 If winner in kept: Kept[kept.index(winner)] = merged Dupes.append("winner": merged, "loser": it) Return kept, dupes @staticmethod Def _merge(a, b): """ 간단한 병합 정책. 실제 운영에서는 더 세밀하게 작성. """ Def longer(x, y): Return x if len(x or "") >= len(y or "") else y Merged = dict(a) Merged["title"] = longer(a.get("title"), b.get("title")) # 태그 합집합 Tags_a = set(a.get("tags") or []) Tags_b = set(b.get("tags") or []) Merged["tags"] = sorted(tags_a.union(tags_b)) # 노트는 더 긴 쪽 Merged["note"] = longer(a.get("note"), b.get("note")) # 날짜는 최신 Merged["added_at"] = max(a.get("added_at") or "", b.get("added_at") or "") # 출처 누적 Src = set((a.get("source") or "").split(",")) | set((b.get("source") or "").split(",")) Merged["source"] = ",".join(sorted(s for s in src if s)) Return merged이 정도만으로도 첫 실행에서 중복의 절반 가까이를 잡아낸다. 공격적인 시그니처 병합을 켜면 더 많이 잡히지만, 제목이 비슷한 다른 기사까지 합쳐 버릴 수 있다. 그럴 때는 내용 해시의 비중을 올리고, 텍스트 유사도는 0.9 이상에서만 허용하는 식으로 보수적으로 조정한다. 텍스트 유사도 계산은 simhash나 MinHash를 붙이면 좋지만, 트래픽 비용과 구현 복잡도를 함께 고려해야 한다.
네트워크 호출을 다루는 요령
대량 처리에서 가장 큰 병목은 네트워크다. 서비스를 운영하면서 생긴 경험을 몇 가지 정리한다.
User-Agent는 솔직하게 밝히고, 속도를 낮춘다. 모호한 UA보다 명시적인 UA가 차단을 줄인다. 도메인당 동시 연결을 2에서 4 사이로 제한하고, 초당 요청 수를 1 이하로 묶으면 작은 사이트에 민폐를 덜 끼친다. Aiohttp를 쓴다면 TCPConnector의 limit perhost와 세마포어를 함께 쓰면 된다.
HEAD 요청으로 리다이렉트를 확인하고 GET은 최소화한다. 다만 HEAD를 금지한 서버가 있으니 실패하면 바로 GET으로 전환한다. 429나 503이 오면 지수 백오프로 늦춘다. Retry-After 헤더를 존중하면 더 좋다.
캐시가 생명줄이다. 최종 URL, 상태 코드, 제목, 내용 해시를 로컬 SQLite에 24시간 캐시해도 체감 품질에 영향이 거의 없다. 뉴스 사이트처럼 잦은 업데이트가 있는 곳만 캐시 시간을 줄인다.
Robots.txt를 읽느냐는 의견이 갈린다. 중복 제거 목적의 자동화 스크립트는 크롤러에 가깝지 않다. 그래도 가끔 robots 규칙을 엄격하게 인식하는 서비스가 있으니, 최소한 Disallow의 최상위 패턴 정도는 지켜 주는 편이 마찰을 줄인다.
주소 정규화의 회색지대
현장에서 부딪힌 회색지대를 몇 가지 더 짚고 간다.
Www를 없앨지 말지. 대세는 www 제거다. 그래도 일부 서비스는 루트 도메인이 앱으로 라우팅되고 www가 웹으로 라우팅된다. 무조건 제거하면 앱 딥링크가 되어 브라우저에서 열리지 않는다. 내 규칙은 제거를 기본으로 하되, 도메인 예외 리스트를 둔다. 예외 리스트는 운영 중 계속 늘어난다.
모바일 도메인의 취급. M, mobile, touch, amp 서브도메인은 제거를 기본으로 두되, AMP는 별도로 유지한다. AMP는 콘텐츠가 단순화되어 중복처럼 보이지만, 공유 의도가 다를 때가 많다. 주소모음에서 AMP를 유지하면 사용자가 모바일에서 훨씬 빠르게 열 수 있다.
경로의 index.html. Index.html을 제거하는 규칙은 단순하고 기분 좋다. 하지만 라우팅이 정교한 사이트에서는 /index 와 / 가 다른 문서를 가리킬 때가 있다. 규칙을 켠다면 안전망으로 404 캐시를 활용해 되돌릴 수 있어야 한다.

쿼리 파라미터의 회피. 토큰 기반 인증이 필요한 자료는 파라미터를 지우면 사라진다. Domain allowlist를 만들어 이 도메인들의 파라미터는 무조건 보존한다는 규칙을 병행했다. 반대로 추적 파라미터는 remove list를 넓게 잡되, 분류에 자신 없으면 일단 보존한다.
대량 처리와 성능
십만 건을 넘기면 병렬화 없이는 밤새 돌아도 끝나지 않는다. Aiohttp로 500 동시 요청을 때리면 서버가 싫어할 뿐만 아니라, 로컬 머신도 소켓 한계에 부딪힌다. 도메인당 2에서 4, 전체 동시 64에서 128 정도가 무난했다. CPU보다 IO가 병목이라 코루틴 모델이 효과적이다.
캐시 적중률을 높이는 게 성능의 절반이다. 이미 본 도메인에 대한 첫 요청에서 robots, 리다이렉트 규칙, canonical 성향을 요약해 두면 다음 요청의 판단을 빠르게 할 수 있다. 예를 들어 t.co는 301을 거의 항상 쓰니 HEAD만으로 충분하고, Medium은 canonical을 자주 사용하니 HTML 파싱이 필요하다는 식으로 도메인 프로파일을 쌓는다.
Bloom filter는 디스크 쓰기 이전의 빠른 중복 탐지에 유용했다. 메모리에 필터 하나를 놓고 정규화 URL을 먼저 대조하면 DB 라운드트립이 줄어든다. 오탐을 허용하는 구조라 중복을 놓칠 수 있지만, 두 번째 방어선으로 DB가 있으니 크게 문제 되지 않았다.
품질과 오탐 관리
첫 주차 배치에서 23에서 27퍼센트 정도가 중복으로 묶였다. 그중 오탐이 2에서 3퍼센트였다. 대부분 모바일 도메인과 프래그먼트가 원인이었다. 오탐을 줄이는 데 가장 효과적이었던 조치 세 가지를 기록해 둔다.
Canonical 태그 신뢰도 가중치. Canonical이 있으면 점수를 크게 올리고, 없으면 URL 변형만으로 합치지 않도록 했다. 특히 쇼핑몰의 캠페인 페이지는 경로만 비슷하고 내용은 다른 경우가 많다.
제목 유사도 임계치 상향. 초기에 레벤슈타인 0.85를 임계치로 썼는데, 0.92로 올리니 오탐이 크게 줄었다. 대신 못 잡는 중복이 조금 늘었다. 알림 없이 합치는 것보다는 남겨 두는 편이 안전했다.
도메인 예외 리스트. News.yahoo.co.jp 같은 대형 포털은 동일 경로에 다른 기사 슬롯을 시간별로 바꾸기도 한다. 예외 리스트에 올리고 URL만으로는 합치지 않았다.
사용자 보고 기능을 붙였더니 오탐 발견 속도가 빨라졌다. 주소아지트처럼 사용자 기반 주소모음에서는 특히 유용했다. 스크립트가 만든 병합 결과에 사람 손으로 취소 버튼 하나가 달리면, 그 다음 배치부터는 같은 패턴을 예외로 바로 학습하도록 피드백 루프를 설계했다.
에러, 타임아웃, 그리고 탄력성
현실의 네트워크는 늘 고르지 않다. 5퍼센트 안팎의 요청이 타임아웃이나 TLS 핸드셰이크 에러로 실패한다. 그때의 기본기는 간단하다. 짧은 타임아웃, 세 번 이내 재시도, 백오프, 그리고 실패 캐시다. 실패 캐시는 중요하다. 같은 URL을 계속 재시도하며 전체 배치를 느리게 만들기 때문이다. Status 0과 함께 실패 시각을 기록하고, 다음 배치에서 6시간 뒤에나 다시 시도하도록 했다.
리다이렉트 루프는 생각보다 자주 발생한다. 특히 잘못된 상대 경로 Location 헤더가 문제다. 루프 감지 세트를 두고, 루프를 만나면 원본을 그대로 보존하고 경고를 남긴다. 의외로 이런 URL은 사용자가 공유하려는 의도와 먼 경우가 많아, 품질에 큰 영향을 주지 않았다.
보고서와 사람 친화적 결과물
자동화 스크립트는 결과를 사람에게 보여 줄 때 비로소 완성된다. 중복 제거는 파괴적 작업이다. 무엇을 지우고 무엇을 남겼는지, 어떤 규칙 때문에 그런 결정을 했는지 설명이 필요하다. 실무에서는 다음을 지켰다.
각 병합 건에 이유를 남긴다. Canonical 일치, 리다이렉트 일치, 제목 유사도 0.95, 내용 해시 일치 등 신호를 남겨 CSV로 내보냈다. 관리자는 보고서를 훑어보고 예외 규칙을 업데이트할 근거를 얻는다.
변경 로그를 보존한다. 처음 30일은 모든 삭제를 되돌릴 수 있도록 tombstone 테이블을 유지했다. 클라이언트는 동일 URL을 추가하려 하면 병합 기록을 보여주고 복원 옵션을 함께 제공했다.
UI는 소심해야 한다. 주소모음의 최종 리스트를 덮어쓰기보다는, 신규 후보 컬렉션을 만들어 검토 후 반영하도록 했다. 자동 반영은 내부 추천 인덱스에만 적용했다.
주소아지트, 링크모음 운영에서의 적용 포인트
플랫폼마다 특성이 있다. 주소아지트처럼 사용자 제출이 빠르게 늘어나는 서비스는 쇼트 URL 비중이 높다. T.co, bit.ly, lnkd.in 같은 도메인은 리다이렉트를 반드시 따라가야 정규화가 가능하다. 반면 팀 위키의 링크모음은 내부 도메인이 많아 네트워크 해석을 최소화해도 품질이 유지된다.
주소아지트에서는 태그가 중요 신호였다. 같은 최종 URL이라도 태그 구성이 크게 다르면 중복으로 묶지 않고 병렬로 유지했다. 사용자는 서로 다른 맥락에서 같은 기사 링크를 저장하기도 한다. 반대로 추천 파이프라인에서는 최종 URL 기준으로 묶고 태그만 합쳤다.
링크모음의 프론트엔드는 썸네일 캐시를 쓴다. 중복 병합 시 대표 썸네일을 고르는 규칙이 필요했다. 해상도, 용량, 가로세로 비율을 기준으로 점수를 매겨 높은 점수의 이미지를 대표로 삼았다. 또한 소셜 카드 이미지가 자주 바뀌는 도메인을 예외로 둬 제목이나 설명 변화에 휘둘리지 않게 했다.
테스트와 검증
중복 제거는 유닛 테스트가 특히 값어치를 한다. 문자열 정규화는 케이스를 잘게 쪼개서 고정시켜야 regression이 줄어든다. 다음처럼 테스트 셋을 쌓았다.
국내외 대표 도메인 200개를 선정해 변형 URL 10종씩을 만들었다. Http와 https, www 유무, 파라미터 유무, 대소문자, 모바일 링크모음 서브도메인, 프래그먼트, 포트 명시 등. 각 변형이 같은 키로 떨어지는지 확인한다.
리다이렉트 패턴 샘플을 저장한다. 쇼트너, 미러 도메인, 자사 서비스의 캠페인 트래킹 URL을 모아 최종 목적지를 가상 서버로 돌려 테스트했다. 모의 서버는 httpbin과 nginx rules를 섞어 구성했다.
오탐 회피 케이스를 따로 모은다. 같아 보이지만 달라야 하는 URL들을 레드 리스트로 관리한다. 이 리스트는 운영 중 발견되는 즉시 추가했다. CI는 레드 리스트가 합쳐지면 실패하도록 했다.
배포와 운영
크론으로 매일 자정에 전체 배치를 돌리는 건 간단하지만, 축적량이 늘면 배치 시간이 길어진다. 증분 모드가 필요하다. 최근 하루 들어온 링크만 타겟으로 잡고, 캐시와 그래프를 이용해 관련된 후보만 검토하는 방식으로 시간을 줄였다. 모든 항목을 매번 다시 요청하지 않아도 품질 유지는 가능했다.
로그는 다음 네 가지면 충분했다. 처리 수, 중복 수, 오탐 추정치, 평균 응답 시간. 오탐 추정치는 샘플 100건을 임의 추출해 수동 검토한 비율을 사용한다. 매주 이 지표를 팀 슬랙에 올리면, 규칙 조정의 타이밍을 잡기 좋다.
비상 스위치도 달아둔다. 특정 도메인에서 대량 403이 뜨거나, CDN 지역 장애로 응답이 느릴 때 배치를 중단할 수 있어야 한다. 환경 변수 하나로 네트워크 단계를 건너뛰고 URL 정규화만 수행하는 모드를 마련해 두면 데이터 정리는 계속 진행할 수 있다.
간단한 CLI와 실행 흐름
작업자는 명령 한 줄로 결과물을 보고 싶어 한다. 다음처럼 CLI를 얹어두면 온보딩이 쉬워진다.
Import argparse From pathlib import Path Def load_items(path: Path): # 실제로는 CSV, JSON, HTML 등 포맷별 로더를 둔다 With open(path, "r", encoding="utf-8") as f: Return json.load(f) Def save_items(path: Path, items): With open(path, "w", encoding="utf-8") as f: Json.dump(items, f, ensure_ascii=False, indent=2) Def main(): Ap = argparse.ArgumentParser() Ap.add_argument("--input", required=True, help="입력 JSON 파일 경로") Ap.add_argument("--output", required=True, help="정리된 JSON 파일 경로") Ap.add_argument("--report", required=True, help="중복 보고서 JSON 경로") Args = ap.parse_args() Items = load_items(Path(args.input)) D = Deduper() Kept, dupes = d.dedupe(items) Save_items(Path(args.output), kept) Save_items(Path(args.report), dupes) Print(f"입력 len(items)건, 보존 len(kept)건, 중복 len(dupes)건") If __name__ == "__main__": Main()이 정도면 주소모음이든 팀 링크모음이든 당장 실전에 투입할 수 있다. 이후에는 포맷 로더를 추가하고, 도메인 예외와 파라미터 리스트를 외부 설정 파일로 뽑아내 버전 관리하면 된다.
도입 전 후 체크리스트
- 정규화 규칙 합의와 예외 도메인 초기 리스트 준비 캐시와 실패 기록 저장소 마련, 만료 정책 결정 네트워크 한도와 백오프 정책, User-Agent 문구 확정 리포트 포맷과 검토 플로우 결정, 롤백 경로 확인 초기 배치 샘플 검토로 오탐 비율 측정, 기준선 수립
실제 적용에서의 수치와 깨달음
실제 서비스에 붙였을 때의 수치를 공유한다. 하루 평균 신규 URL 12,000건, 주간 액티브 도메인 3,200개였다. 최초 전체 배치에서 26.4퍼센트가 중복으로 묶였고, 증분 모드로 전환한 뒤에는 일일 처리 시간이 5시간에서 48분으로 줄었다. 캐시 적중률은 78에서 85퍼센트 사이를 왔다 갔다 했다. 비즈니스 지표 쪽에서는 추천 피드의 노출 다양성이 늘었고, 클릭 스루가 4에서 7퍼센트 범위로 소폭 상승했다.
반면 역효과도 있었다. 팀별 주소모음에서 같은 기사에 다른 노트를 단 링크를 병합해 버려, 회고에서 불만이 나왔다. 병합 정책에 메모 충돌 시 별도 항목으로 분리하는 옵션을 추가했다. 같은 URL이라도 메모가 다르면 둘 다 남기고, 피드에는 하나만 노출하는 식의 절충안이 잘 먹혔다.
또 하나, 한글 도메인에서 IDNA 정규화가 빠진 채로 몇 주가 흘렀다. 검색과 정렬에서 의도치 않은 분리가 생겼다. 뒤늦게 idna를 붙이고 캐시를 재생성했다. 교훈은 간단하다. 국제화는 초기에 붙여야 한다.
마무리
주소모음과 링크모음의 품질은 결국 중복을 얼마나 똑똑하게 다루느냐에 달려 있다. 완벽을 꿈꾸기보다, 목적에 맞는 정의와 신뢰 가능한 파이프라인을 먼저 세우는 게 중요하다. 정규화, 네트워크 해석, 내용 지문, 병합 정책, 보고서라는 다섯 조각이 맞물리면, 스크립트는 조용히 일한다. 주소아지트 같은 플랫폼에서라면 사용자 경험을 해치지 않도록 보수성과 피드백 루프를 챙기면 된다. 작은 도구 하나로 링크의 숲이 다시 길을 드러낼 때, 팀의 검색과 탐색이 놀랄 만큼 가벼워진다.