같은 URL을 두 번 긁고 있다면 점검할 정규화와 방문 기록 설계

같은 URL을 두

크롤러 속도가 느려지면 네트워크 지연이나 서버 응답 시간부터 의심하는 경우가 많은데, 실제로 요청 로그를 펼쳐보면 같은 URL을 두 번, 많게는 열 번 넘게 긁고 있다면 원인은 네트워크가 아니라 URL 정규화 누락인 경우가 훨씬 흔합니다. 쿼리스트링 순서나 대소문자, 트레일링 슬래시만 달라도 크롤러 입장에서는 완전히 다른 주소로 보이기 때문입니다. 이 글에서는 어떤 표기 차이를 정규화 대상으로 삼아야 하는지, 그리고 정규화한 URL을 어디에 기록해야 다음 실행에서도 중복을 걸러낼 수 있는지를 코드로 짚어보겠습니다.

같은 페이지인데 왜 다른 URL로 보일까요?

가장 흔한 원인은 쿼리 파라미터 순서입니다. ?a=1&b=2와 ?b=2&a=1은 서버 입장에서 같은 자원을 돌려주지만, 문자열 비교로 중복을 판단하는 크롤러에게는 다른 주소입니다. 스킴과 호스트는 대소문자를 구분하지 않는다고 알려져 있지만 경로는 서버 설정에 따라 구분되는 경우가 많아서, HTTP://Example.com/Page와 http://example.com/page를 같은 주소로 단정하면 안 됩니다.

트레일링 슬래시(/list와 /list/), 기본 포트 표기(:80, :443), 프래그먼트(#section)도 같은 문제를 일으킵니다. 여기에 더해 공백이나 물결표 같은 문자를 인코딩하는 방식도 변수가 됩니다. 어떤 문자를 어떤 방식으로 바꿔 써야 하는지는 규격으로 정해져 있어서, 대문자 인코딩과 소문자 인코딩이 같은 문자를 가리키는지까지 확인해야 완전한 퍼센트 인코딩 처리가 됩니다.

정규화 범위를 정하는 기준 — 쿼리 파라미터부터 가려내기

모든 표기 차이를 무조건 지우는 방식은 위험합니다. 쇼핑몰 목록 페이지처럼 ?page=2가 실제로 다른 콘텐츠를 가리키는 경우도 있고, utm_source 같은 추적용 파라미터처럼 콘텐츠와 무관한 경우도 섞여 있기 때문입니다. 그래서 정규화 전에 해당 사이트의 라우팅 구조를 먼저 살펴보고, 콘텐츠를 바꾸는 파라미터와 추적용 파라미터를 나눠서 다뤄야 합니다.

웹 크롤러가 네트워크 노드를 순회하는 추상적 시각화

URL은 네트워크 위치와 자원 경로를 함께 담는 식별자인 URI의 한 형태라서, 같은 자원을 가리키는 표기가 여러 개 존재할 수 있다는 점 자체가 설계상의 전제입니다. 따라서 정규화 규칙은 “표기를 통일한다”보다 “이 사이트에서 콘텐츠를 결정하는 요소만 남긴다”는 기준으로 세우는 편이 안전합니다.

파이썬 표준 라이브러리로 정규화 함수 만들기

파이썬 3의 urllib.parse 모듈만으로도 기본적인 정규화를 처리할 수 있습니다. 스킴과 호스트를 소문자로 바꾸고, 기본 포트를 제거하고, 쿼리 파라미터를 정렬한 뒤 프래그먼트를 떼어내는 구조입니다.

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

def normalize_url(url: str) -> str:
    scheme, netloc, path, query, fragment = urlsplit(url)
    scheme = scheme.lower()
    netloc = netloc.lower()
    if scheme == "http" and netloc.endswith(":80"):
        netloc = netloc[:-3]
    if scheme == "https" and netloc.endswith(":443"):
        netloc = netloc[:-4]
    if not path:
        path = "/"
    query_pairs = sorted(parse_qsl(query))
    query = urlencode(query_pairs)
    return urlunsplit((scheme, netloc, path, query, ""))

urls = [
    "HTTP://Example.com:80/list?b=2&a=1#section2",
    "http://example.com/list?a=1&b=2",
]
for u in urls:
    print(normalize_url(u))
http://example.com/list?a=1&b=2
http://example.com/list?a=1&b=2

표기가 다른 두 주소가 같은 문자열로 정리되는 것을 확인할 수 있습니다. 다만 이 함수는 추적 파라미터 제거나 IDN 도메인 처리까지는 다루지 않으므로, 실무에서는 Scrapy가 내부적으로 쓰는 w3lib.url.canonicalize_url 같은 함수를 참고해 더 많은 경우를 보완하는 편이 좋습니다.

방문 기록은 규모에 맞춰 저장소를 고르세요

정규화만으로는 중복을 막을 수 없습니다. 정규화된 URL을 어딘가에 기록해두고, 다음 요청 전에 그 기록을 조회하는 과정이 반드시 필요합니다. 규모와 재실행 조건에 따라 적절한 저장소가 달라집니다.

브라우저 주소창에 표시된 URL 경로와 쿼리스트링

방식 중복 판별 속도 재시작 시 유지 적합한 규모
메모리 집합(Python set) 매우 빠름 유지 안 됨 단일 실행, 수십만 건 이하
로컬 SQLite 유니크 인덱스 빠름 유지됨 단일 머신, 중간 규모
Redis Set 빠름 설정에 따라 유지 여러 워커가 공유하는 분산 크롤러
RDB 유니크 인덱스 쿼리 부하에 따라 다름 유지됨 운영 DB와 같이 관리해야 하는 경우

단일 프로세스로 짧게 끝나는 수집이라면 메모리 집합으로 충분하지만, 여러 워커가 같은 큐를 나눠 처리하거나 중단 후 이어서 돌려야 한다면 Redis Set이나 DB 유니크 인덱스처럼 프로세스 바깥에 상태를 두는 저장소가 필요합니다.

정규화가 오히려 손해를 보는 경우

프래그먼트를 무조건 제거하는 규칙은 오래된 방식의 단일 페이지 애플리케이션에서 문제가 됩니다. #!/product/123처럼 프래그먼트 자체가 콘텐츠를 구분하는 라우팅 방식을 쓰는 사이트라면, 프래그먼트를 지우는 순간 서로 다른 상품 페이지가 같은 URL로 뭉쳐버립니다. 쿼리 파라미터를 정렬하고 추적용 키를 걷어내는 규칙도 마찬가지로, 그 파라미터가 실제로는 A/B 테스트 분기나 페이지네이션 상태를 담고 있다면 서로 다른 콘텐츠를 중복으로 처리해 수집 누락이 생깁니다. 정규화 규칙을 사이트별로 다르게 적용해야 하는 이유가 여기에 있습니다.

정규화했는데도 중복이 남는다면?

가장 먼저 리다이렉트 체인을 확인해보세요. URL A가 301로 URL B로 넘어가는 구조라면, 크롤러는 A도 정규화해서 기록해야 하고 최종 도착지인 B도 따로 기록해야 합니다. 둘 중 하나만 기록하면 다음 실행에서 같은 체인을 다시 따라가게 됩니다.

같은 콘텐츠가 서로 다른 도메인에서 서빙되는 경우도 흔합니다. 이때는 URL 문자열만 비교해서는 해결이 안 되고, 페이지에 적힌 canonical 태그 값을 함께 읻어서 방문 기록의 기준 키로 삼는 방법이 더 안정적입니다. 정규화 규칙과 방문 기록 저장소를 따로 설계했다면, 지금 쓰는 크롤러의 로그에서 같은 정규화 결과가 몇 번 반복되는지부터 확인해보시기 바랍니다.

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

Leave a Comment