스크래핑

발견 소스로서의 사이트맵: 전체 사이트를 크롤링하지 않고 모든 URL 찾기

XML 사이트맵은 사이트의 URL과 때로는 변경된 시점을 나열합니다. 이를 제대로 읽는 방법과 확인 없이 lastmod를 절대 신뢰해서는 안 되는 이유를 설명합니다.

James Meadow

James Meadow

2026년 9월 29일 · 7 분 소요

가장 흔한 방식으로 페이지를 찾는 크롤러는 비용이 많이 든다. 페이지를 가져오고, 링크를 추출하고, 큐에 넣고, 반복한다. 이 방식은 작동하지만 예산 대부분을 내비게이션, 목록, 이미 본 페이지에 소모하며, 어디에서도 링크되지 않은 페이지는 여전히 놓칠 수 있다.

많은 사이트는 자사 URL 목록을 의도적으로 공개한다. XML 사이트맵은 검색 엔진이 페이지를 효율적으로 찾을 수 있도록 존재하며, 같은 파일이 데이터 팀에게 사이트에 무엇이 있는지, 어떻게 구성되어 있는지, 때로는 최근에 무엇이 바뀌었는지 알려준다. 이 가이드는 사이트맵을 제대로 찾고 읽는 방법을 다루며, 그중 가장 유용한 필드인 lastmod를 신뢰하기 전에 왜 검증이 필요한지 설명한다. 실제 사이트 두 곳에서 확인한 결과, 두 가지 서로 다른 방식으로 실패했다.

핵심 요약

  • 사이트맵은 파일당 최대 50,000개의 URL을 나열할 수 있고, 사이트맵 인덱스는 최대 50,000개의 사이트맵을 나열할 수 있으므로, 매우 큰 사이트도 완전한 목록을 게시할 수 있다.
  • 사이트맵은 보통 robots.txt에 Sitemap: 줄로 선언되어 있으므로, 위치를 추측하기 전에 그곳을 먼저 확인해야 한다.
  • lastmod는 가장 많은 페치를 절약해줄 수 있는 필드이면서 가장 신뢰도가 낮은 필드다. Google은 lastmod가 “일관되고 검증 가능하게” 정확한 경우에만 사용한다고 밝히고 있다.
  • 확인 결과, 한 대형 뉴스 발행사의 뉴스 사이트맵은 모든 항목에 동일한 타임스탬프, 즉 파일이 생성된 시점을 부여했다. 한 대형 문서 아카이브는 10,000개가 넘는 URL을 게시했는데 lastmod가 전혀 없었다.
  • 사이트맵은 탐색용으로 사용하고, 스케줄링에 활용하기 전에 사이트별로 lastmod를 검증하며, 링크 기반 크롤링을 안전망으로 유지하라.

프로토콜이 허용하는 것

사이트맵 프로토콜은 짧고, 정확히 알아둘 가치가 있다.

규칙세부 사항
크기 제한압축 해제 상태 기준으로 사이트맵 파일당 “50,000개를 넘지 않는 URL”과 “50MB(52,428,800바이트)를 넘지 않는” 크기
사이트맵 인덱스다른 사이트맵들을 나열하는 파일로, 동일하게 50,000개 항목과 50MB 제한이 적용됨
압축파일은 gzip으로 압축될 수 있으며, 압축 해제 시 제한 이내여야 함
lastmod선택 사항이며, W3C Datetime 형식으로, YYYY-MM-DD와 같이 날짜만 있을 수도 있음
탐색robots.txt의 Sitemap: 줄로 확인하며, 사이트는 여러 개를 나열할 수 있음

changefreq와 priority라는 두 가지 선택 필드는 무시해도 된다. Google은 “<priority>와 <changefreq> 값을 무시한다”고 명시하고 있으며, 다른 누구도 이를 신뢰할 이유는 거의 없다.

뉴스 사이트는 흔히 최근 기사만 다루는 별도의 뉴스 사이트맵을 게시하며, 발행일과 같은 추가 필드를 포함한다. 이런 파일은 작고 자주 재생성되며, 최근 콘텐츠를 모니터링하는 데 매우 유용하다.

사이트맵을 제대로 읽기

견고한 리더는 네 가지 작업을 해야 한다. robots.txt에서 사이트맵을 찾고, gzip을 처리하고, 사이트맵 인덱스를 재귀적으로 따라가고, lastmod를 비교 가능한 형태로 파싱하는 것이다. 또한 인덱스가 수천 개의 파일을 가리킬 수 있으므로 몇 개까지 가져올지 상한선을 두어야 한다.

import gzip
import io
import re
import urllib.request
from datetime import datetime, timezone

from defusedxml.ElementTree import fromstring  # pip install defusedxml

NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
UA = "Mozilla/5.0 (compatible; sitemap-reader)"
MAX_BYTES = 52_428_800  # the protocol's 50MB limit, uncompressed


def fetch(url, opener=None):
    opener = opener or urllib.request.build_opener()
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with opener.open(req, timeout=45) as r:
        body = r.read(MAX_BYTES + 1)
    if body[:2] == b"\x1f\x8b":
        body = gzip.GzipFile(fileobj=io.BytesIO(body)).read(MAX_BYTES + 1)
    if len(body) > MAX_BYTES:
        raise ValueError(f"{url} exceeds the 50MB sitemap limit")
    return body


def sitemap_roots(origin, opener=None):
    """Sitemaps declared in robots.txt, falling back to /sitemap.xml."""
    try:
        robots = fetch(origin.rstrip("/") + "/robots.txt", opener).decode("utf-8", "replace")
        found = re.findall(r"(?im)^\s*sitemap:\s*(\S+)", robots)
    except OSError:
        found = []
    return found or [origin.rstrip("/") + "/sitemap.xml"]


def parse_lastmod(value):
    if not value:
        return None
    value = value.strip().replace("Z", "+00:00")
    try:
        dt = datetime.fromisoformat(value)
    except ValueError:
        return None
    return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)


def walk(url, opener=None, max_files=20, _seen=None):
    """Yield (loc, lastmod) from a sitemap or sitemap index, following nested indexes."""
    seen = _seen if _seen is not None else set()
    if url in seen or len(seen) >= max_files:
        return
    seen.add(url)
    root = fromstring(fetch(url, opener))
    if root.tag == NS + "sitemapindex":
        children = sorted(root.findall(NS + "sitemap"),
                          key=lambda s: parse_lastmod(s.findtext(NS + "lastmod")) or datetime.min.replace(tzinfo=timezone.utc),
                          reverse=True)
        for child in children:
            yield from walk(child.findtext(NS + "loc").strip(), opener, max_files, seen)
    else:
        for u in root.findall(NS + "url"):
            yield u.findtext(NS + "loc").strip(), parse_lastmod(u.findtext(NS + "lastmod"))


def changed_since(origin, since, opener=None, max_files=20):
    """URLs whose sitemap lastmod is newer than `since`, plus those with no lastmod at all."""
    changed, undated, total = [], [], 0
    for root in sitemap_roots(origin, opener):
        for loc, lastmod in walk(root, opener, max_files):
            total += 1
            if lastmod is None:
                undated.append(loc)
            elif lastmod > since:
                changed.append((loc, lastmod))
    return {"total": total, "changed": changed, "undated": undated}

인덱스 워커는 인덱스가 날짜를 제공하는 경우 자식 사이트맵을 최신 순으로 먼저 방문하므로, 상한선이 걸린 실행에서도 대형 사이트에서 가장 최근에 업데이트된 부분을 먼저 읽게 된다. 안전을 위한 세부 사항이 두 가지 있다. 사이트맵은 타인의 서버로부터 오는 신뢰할 수 없는 입력이므로, 코드는 이를 defusedxml로 파싱하는데, 이는 표준 XML 파서가 작은 파일을 기가바이트 단위로 부풀릴 수 있는 엔티티 트릭을 거부한다. 또한 다운로드와 압축 해제된 크기 모두를 프로토콜의 50MB 제한으로 제한하므로, 압축된 파일이 무한정 팽창할 수 없다. opener 매개변수를 사용하면 특정 시장 버전의 사이트를 확인해야 할 때 요청을 프록시를 통해 라우팅할 수 있으며, 이는 다른 어떤 페치에도 동일하게 적용되는 방식이다.

lastmod는 확인한 후에만 신뢰하라

lastmod는 증분 수집이 필요로 하는 것을 정확히 약속한다. 마지막 실행 이후 바뀐 항목의 목록이다. 어디서나 신뢰할 수 있다면 크롤러는 바뀐 페이지만 가져오고 나머지는 건너뛸 수 있을 것이다. 하지만 어디서나 신뢰할 수 있는 것은 아니며, 우리가 확인한 두 테스트 사이트는 두 가지 흔한 실패 유형을 모두 보여준다.

실패 유형 1: lastmod가 생성 시각이다. 우리는 한 대형 뉴스 발행사의 뉴스 사이트맵을 읽었다. 날짜가 기재된 모든 항목은 1초 차이의 두 타임스탬프 중 하나를 갖고 있었는데, 이는 각 기사가 바뀐 시점이 아니라 파일이 생성된 시점이었다. “최근 6시간 이내에 바뀐 것”을 필터링하면 파일 안의 모든 URL이 반환되었다. 이를 그대로 사용하면 그 lastmod는 매번 모든 것을 재페치하게 만들 것이다.

실패 유형 2: lastmod가 아예 없다. 우리는 대형 기술 문서 아카이브의 사이트맵을 읽었다. 10,236개의 URL이 나열되어 있었지만, lastmod가 있는 항목은 하나도 없었다. 이는 탐색용으로는 완벽하게 좋은 소스지만, 무엇을 재페치할지 결정하는 데는 전혀 도움이 되지 않는다.

이것이 Google 자체 가이드가 “페이지의 마지막 수정과 비교하는 등의 방법으로 일관되고 검증 가능하게 정확한 경우”에만 lastmod를 사용한다고 밝히는 이유이며, 여러분도 스스로 같은 테스트를 적용해야 하는 이유다. 사이트의 lastmod를 기준으로 스케줄링하기 전에:

  1. 분포를 확인하라. 대부분의 항목이 하나의 타임스탬프를 공유하거나, 값이 파일 생성 시각을 따라간다면, 그 필드는 페이지 변경을 나타내지 않는 것이다.
  2. 표본을 추출해 비교하라. 페이지 표본을 가져와서 lastmod를 구조화 데이터의 자체 dateModified나 콘텐츠 지문 같은 독립적인 신호와 비교하라. dateModified를 추출하는 방법은 HTML 파싱을 그만두자에서, 노이즈에 빠지지 않고 콘텐츠를 비교하는 방법은 대규모 변경 감지에서 다룬다.
  3. 사이트별로 점수를 매겨라. 콘텐츠가 바뀌었을 때 lastmod도 함께 바뀐 빈도, 그리고 콘텐츠가 바뀌었는데 lastmod는 바뀌지 않은 빈도를 사이트별로 기록하라. 이 테스트를 통과한 사이트에서만 lastmod를 스케줄링에 사용하고, 계속 확인하라. 사이트의 사이트맵 생성기는 예고 없이 바뀔 수 있기 때문이다.

수집 파이프라인에서 사이트맵이 차지하는 위치

사이트맵은 유일한 단계가 아니라 첫 번째 단계로서 가장 강력하다.

  • 탐색. 사이트맵은 잘 연결되지 않은 페이지를 포함해 목록을 직접 제공한다. 카탈로그와 콘텐츠 사이트의 경우, 이는 흔히 이용 가능한 가장 완전한 URL 목록이다.
  • 분류. 사이트맵은 흔히 제품, 카테고리, 기사, 동영상 같은 콘텐츠 유형이나 섹션별로 나뉘어 있다. 이 분할은 페이지를 하나도 가져오기 전에 사이트가 어떻게 구성되어 있는지 알려준다.
  • 증분 수집, 위의 검증을 통과한 사이트에서, 그리고 최근 기사를 위한 뉴스 사이트맵을 통해 이루어진다.
  • 커버리지 확인. 크롤러가 찾은 것과 사이트맵이 나열한 것을 비교하면 무엇을 놓치고 있는지 알 수 있다.

링크 기반 크롤링은 안전망으로 유지하라. 사이트맵은 오래되었거나 불완전하거나, 사이트가 색인되기를 원하는 것으로만 의도적으로 제한되어 있을 수 있다. 그리고 페이지 자체에 대해서는 사이트의 robots.txt를 존중하라. 사이트맵에 URL이 나타난다는 것은 검색 엔진에게 색인을 요청하는 초대일 뿐, robots.txt, AI 옵트아웃과 예약 신호에서 다루는 사이트가 요청한 다른 어떤 것도 포기한다는 의미는 아니다.

이런 방식으로 사용하면 사이트맵은 페치 예산이 보통 낭비되는 두 지점, 즉 페이지를 찾기 위한 내비게이션과 바뀌지 않은 페이지를 재페치하는 것을 줄여준다. 두 번째 절감은 전적으로 lastmod가 신뢰할 수 있는지에 달려 있으며, 이것이 비용을 고려한 크롤링 스케줄링이 검증되지 않은 lastmod를 사실이 아니라 힌트로 취급해야 하는 이유다.

결론

사이트맵은 웹에서 가장 저렴한 URL 목록이다. 사이트가 표준 형식으로 무엇을 찾아주기를 원하는지 알려주는 것이다. robots.txt에서 읽어내고, gzip과 중첩된 인덱스를 처리하며, 가져올 양에 상한선을 두고, 우선 탐색용으로 사용하라.

그런 다음 lastmod를 Google과 같은 방식으로 다뤄라. 해당 사이트에 대해 정확하다고 입증된 경우에만 유용하다는 것이다. 우리가 확인한 한 사이트에서는 파일이 생성된 시각에 불과했다. 다른 사이트에서는 아예 존재하지 않았다. lastmod가 유효한 사이트에서는 재페치를 크게 줄일 수 있다. 어떤 유형의 사이트를 다루고 있는지 아는 유일한 방법은 직접 확인하는 것이다.

출처 및 참고 자료

시작할 준비가 되셨나요?

205M개 이상의 IP, 195개 이상의 국가를 지원하는 Shifter의 레지덴셜 프록시를 $0.10/GB부터 이용해보세요.

시작하기