
이 글에서는 크롤러가 같은 페이지를 반복해서 긁어오는 원인을 짚어보고, 파이썬 표준 라이브러리만으로 URL을 정규화해 중복 수집을 막는 코드를 실제로 확인하실 수 있어요. 핵심은 간단합니다. urllib.parse로 URL을 쪼갠 뒤 대소문자·포트·트레일링 슬래시·쿼리 파라미터 순서를 하나의 기준으로 통일하고, 그 결과 문자열을 집합(set)에 저장해서 이미 방문한 URL인지 검사하면 됩니다. 다만 쿼리 파라미터가 실제로 다른 콘텐츠를 구분하는 경우엔 이 규칙을 잘못 적용하면 오히려 수집해야 할 페이지를 건너뛰게 되는데, 그 경계선도 아래에서 같이 확인해볼게요.
왜 크롤러가 같은 페이지를 중복으로 수집할까요?
가장 흔한 원인은 URL은 다르지만 서버가 돌려주는 실제 콘텐츠는 완전히 같은 경우예요. 예를 들어 example.com/product/1과 example.com/product/1/은 트레일링 슬래시 하나 차이지만, 크롤러가 문자열을 그대로 비교하면 서로 다른 URL로 인식합니다.
여기에 더해 마케팅 트래킹 파라미터(utm_source, fbclid, gclid)가 붙은 URL, http와 https 혼용, 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
urlsplit과 urlunsplit의 정확한 동작 방식은 파이썬 공식 문서의 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 개수가 얼마나 줄어드는지 비교해보시는 걸 추천드려요. 쿼리 파라미터가 실제로 콘텐츠를 구분하는 사이트라면 트래킹 파라미터 목록만 별도로 관리하면서 필요한 파라미터는 남겨두는 방향으로 규칙을 조정하시면 됩니다.
