크롤러가 같은 페이지를 중복 수집할 때 — URL 정규화로 막기

크롤러가 같은 페이지를

이 글에서는 크롤러가 같은 페이지를 반복해서 긁어오는 원인을 짚어보고, 파이썬 표준 라이브러리만으로 URL을 정규화해 중복 수집을 막는 코드를 실제로 확인하실 수 있어요. 핵심은 간단합니다. urllib.parse로 URL을 쪼갠 뒤 대소문자·포트·트레일링 슬래시·쿼리 파라미터 순서를 하나의 기준으로 통일하고, 그 결과 문자열을 집합(set)에 저장해서 이미 방문한 URL인지 검사하면 됩니다. 다만 쿼리 파라미터가 실제로 다른 콘텐츠를 구분하는 경우엔 이 규칙을 잘못 적용하면 오히려 수집해야 할 페이지를 건너뛰게 되는데, 그 경계선도 아래에서 같이 확인해볼게요.

왜 크롤러가 같은 페이지를 중복으로 수집할까요?

가장 흔한 원인은 URL은 다르지만 서버가 돌려주는 실제 콘텐츠는 완전히 같은 경우예요. 예를 들어 example.com/product/1example.com/product/1/은 트레일링 슬래시 하나 차이지만, 크롤러가 문자열을 그대로 비교하면 서로 다른 URL로 인식합니다.

여기에 더해 마케팅 트래킹 파라미터(utm_source, fbclid, gclid)가 붙은 URL, httphttps 혼용, www 유무, 포트 번호(:80, :443) 표기 여부, 쿼리 파라미터 순서가 뒤바뀐 경우까지 겹치면 같은 페이지 하나가 수십 개의 변형 URL로 크롤링 큐에 쌓이게 돼요. 대규모 크롤러일수록 이런 변형이 기하급수적으로 늘어나서 같은 페이지를 여러 번 수집할 뿐 아니라 디스크·대역폭도 그만큼 낭비하게 됩니다.

URL 정규화, 어떤 기준으로 통일하면 될까요

정규화는 “같은 페이지를 가리키는 URL 표현을 하나의 기준 문자열로 맞추는 작업”이에요. 실무에서 가장 자주 쓰이는 규칙을 정리하면 아래와 같습니다.

항목 정규화 전 정규화 후 이유
스킴/호스트 대소문자 HTTP://Example.COM http://example.com RFC상 스킴·호스트는 대소문자 구분 없음
기본 포트 https://example.com:443/ https://example.com/ 443은 https 기본 포트라 생략 가능
트레일링 슬래시 /product/1/ /product/1 서버 설정에 따라 다르지만 대부분 동일 응답
쿼리 파라미터 순서 ?id=5&color=red ?color=red&id=5 정렬해야 문자열 비교가 가능
트래킹 파라미터 ?utm_source=naver&id=5 ?id=5 콘텐츠와 무관, 제거해야 중복 방지 효과가 큼
프래그먼트(#) /page#section2 /page 서버에 전달되지 않는 클라이언트 전용 값

이 규칙을 코드 없이 사람이 눈으로 판단하려고 하면 URL 몇 개만 쌓여도 놓치는 경우가 생기니, 함수로 고정해두는 편이 안전해요.

파이썬으로 정규화 함수 직접 만들어보세요

아래 코드는 Python 3.11 기준이며 표준 라이브러리인 urllib.parse만 사용해서 별도 설치 없이 바로 실행할 수 있어요. urlsplit으로 URL을 5개 요소로 분해하고, parse_qsl로 쿼리 문자열을 키-값 쌍으로 바꾼 뒤 정렬해서 다시 조립하는 방식입니다.

from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode

TRACKING_PARAMS = {"utm_source", "utm_medium", "utm_campaign", "fbclid", "gclid"}

def normalize_url(url: str) -> str:
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    netloc = parts.netloc.lower()

    if scheme == "http" and netloc.endswith(":80"):
        netloc = netloc[:-3]
    if scheme == "https" and netloc.endswith(":443"):
        netloc = netloc[:-4]

    path = parts.path.rstrip("/") or "/"

    query_pairs = [
        (k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
        if k not in TRACKING_PARAMS
    ]
    query = urlencode(sorted(query_pairs))

    return urlunsplit((scheme, netloc, path, query, ""))


urls = [
    "https://Example.com:443/product/1?utm_source=naver&id=5&color=red",
    "https://example.com/product/1/?color=red&id=5",
]

for u in urls:
    print(normalize_url(u))

실행하면 두 URL이 완전히 동일한 문자열로 출력돼요.

https://example.com/product/1?color=red&id=5
https://example.com/product/1?color=red&id=5

이제 이 정규화 결과를 set에 넣어서 중복 여부만 확인하면 됩니다.

seen = set()
to_crawl = []

for raw_url in urls:
    key = normalize_url(raw_url)
    if key not in seen:
        seen.add(key)
        to_crawl.append(raw_url)

print(len(to_crawl))  # 1

urlspliturlunsplit의 정확한 동작 방식은 파이썬 공식 문서의 urlsplit 항목에서 인자별 반환값을 확인하실 수 있어요.

정규화만으로 못 잡는 중복 사례도 있습니다

위 규칙이 항상 안전한 건 아니에요. 세 가지 경우엔 오히려 정규화가 데이터 손실로 이어질 수 있습니다.

웹 네트워크 노드와 링크 연결 구조 다이어그램

첫 번째는 쿼리 파라미터가 실제 콘텐츠를 구분하는 경우예요. ?page=2, ?category=shoes 같은 파라미터를 트래킹 파라미터로 착각해서 제거해버리면, 서로 다른 상품 목록 페이지가 하나로 합쳐져 크롤러가 2페이지 이후 데이터를 아예 수집하지 못하게 됩니다.

두 번째는 프래그먼트가 실제 라우팅 역할을 하는 SPA(싱글 페이지 애플리케이션)예요. example.com/#!/product/5처럼 해시뱅(#!) 뒤에 실제 페이지 정보가 담긴 구조라면 프래그먼트를 제거하는 순간 서로 다른 상품 페이지가 전부 같은 URL로 뭉개집니다. 이런 사이트는 정규화 규칙에서 프래그먼트 제거를 예외 처리해야 해요.

세 번째는 세션마다 새로 발급되는 파라미터예요. ?token=처럼 매 요청마다 값이 랜덤하게 바뀌는 파라미터가 있으면, 정규화를 아무리 잘해도 같은 페이지가 계속 새 URL로 인식됩니다. 이 경우엔 URL 비교 대신 응답 본문의 해시값(예: hashlib.sha256(html.encode()).hexdigest())으로 콘텐츠 자체가 같은지 비교하는 방식을 추가해야 합니다.

규모별로 중복 제거 방식을 다르게 선택해보세요

크롤링 대상 URL 개수에 따라 저장 방식을 바꿔야 메모리 문제 없이 운영할 수 있어요.

방식 저장 데이터 메모리 사용 정확도 적합한 규모
set에 정규화 URL 그대로 저장 문자열 원본 100% 수만 건 이하
set에 SHA1 해시만 저장 40자 고정 해시 작음 사실상 100% 수백만 건
Bloom Filter 비트 배열 매우 작음 위양성 존재(다른 URL을 중복으로 오판할 가능성) 수억 건 이상

파이썬 크롤링 프레임워크인 Scrapy는 두 번째 방식과 유사하게, 요청을 정규화한 뒤 SHA1로 지문을 만들어 비교하는 DUPEFILTER_CLASS 설정을 기본으로 제공하고 있어요. 직접 만든 크롤러가 아니라 Scrapy를 쓰고 계신다면 이 설정값만 확인해도 충분한 경우가 많습니다.

쿼리 파라미터 순서만 다른데 왜 다른 페이지로 잡히나요?

크롤러 대부분이 URL을 바이트 단위 문자열로 비교하기 때문이에요. ?id=5&color=red?color=red&id=5는 사람 눈엔 같은 의미지만 컴퓨터 입장에서는 순서가 다른 별개의 문자열입니다. 위 코드처럼 parse_qsl로 분해한 뒤 sorted()로 정렬하는 과정을 거쳐야 비로소 두 URL이 같다고 판단할 수 있어요. 정렬 없이 문자열만 비교하는 크롤러라면 이 한 줄만 추가해도 중복 수집량이 눈에 띄게 줄어듭니다.

크롤러가 같은 페이지를 중복 수집하는 문제는 대부분 URL 표현 방식의 차이에서 시작됩니다. 먼저 지금 쓰고 있는 크롤러가 URL을 어떤 기준으로 비교하는지 로그를 찍어 확인해보시고, 위 normalize_url 함수를 그대로 적용한 뒤 수집 큐에 들어가는 URL 개수가 얼마나 줄어드는지 비교해보시는 걸 추천드려요. 쿼리 파라미터가 실제로 콘텐츠를 구분하는 사이트라면 트래킹 파라미터 목록만 별도로 관리하면서 필요한 파라미터는 남겨두는 방향으로 규칙을 조정하시면 됩니다.

FastAPI CORS 설정이 프로덕션에서만 막힐 때 점검 순서

Leave a Comment