콘텐츠로 이동

대규모 웹 크롤러 시스템 설계

분류: Layer 9 - 아키텍처 & 설계 패턴

1. 크롤러는 무엇을 보장하는 시스템인가

섹션 제목: “1. 크롤러는 무엇을 보장하는 시스템인가”

크롤러(crawler) 는 시작 URL(seed URL)에서 링크를 발견하고, 다시 방문할 URL을 계속 선택하며, 웹 문서와 메타데이터를 수집하는 시스템이다. 정해진 몇 페이지에서 필드만 추출하는 스크레이퍼(scraper) 와 달리, 크롤러에는 “다음에 어디를 방문할 것인가”라는 탐색과 스케줄링 문제가 포함된다.

대규모 크롤러의 중심에는 URL Frontier가 있다. URL Frontier는 아직 가져오지 않았거나 다시 가져와야 할 URL을 보관하면서 우선순위, 방문 시각, 호스트별 요청 간격을 결정하는 영속 스케줄러다. 단순 FIFO(First-In, First-Out) 큐가 아니라 다음 두 질문에 동시에 답해야 한다.

  • 어떤 URL이 더 중요하거나 더 오래되었는가?
  • 그 URL의 서버에 지금 요청해도 되는가?

이때 politeness(정중함) 는 대상 서버에 과도한 부하를 주지 않도록 호스트별 동시성, 요청 간격, 오류 후 대기 시간을 제한하는 정책이다. 높은 전체 처리량을 얻더라도 한 호스트를 압박해서는 안 된다는 것이 핵심 철학이다.

대규모 설계의 목표는 “최대한 빨리 많이 받기”가 아니다. 다음 불변식을 장시간 유지하며 유용한 문서를 최신 상태로 수집하는 것이다.

  1. 허용되지 않은 URL은 fetch하지 않는다.
  2. 의미상 같은 URL은 같은 식별자로 수렴시킨다.
  3. 같은 호스트에는 정책보다 빠르게 요청하지 않는다.
  4. 실패와 재전달이 있어도 결과 저장은 멱등적이다.
  5. downstream이 느려지면 upstream 생성 속도도 줄어든다.
  6. 저장된 문서는 중요도와 변경 가능성에 따라 다시 수집된다.

2. 선행 기술의 한계와 크롤러 설계의 등장 문제

섹션 제목: “2. 선행 기술의 한계와 크롤러 설계의 등장 문제”

초기 크롤러는 BFS(Breadth-First Search, 너비 우선 탐색)에 가까운 단일 FIFO 큐로 설명할 수 있다. 한 페이지에서 링크 10개를 발견하고 각 페이지가 다시 10개를 만든다면 깊이 6에서 후보는 이론상 10^6 = 1,000,000개가 된다. 쿼리 파라미터, 달력, 세션 ID가 결합되면 실제 콘텐츠 수보다 URL 표현 수가 훨씬 빠르게 증가한다.

또한 같은 HTML에서 발견한 상대 링크는 같은 호스트에 몰린다. 여러 worker가 단일 큐에서 동시에 꺼내면 example.com에 수십 개 요청을 한꺼번에 보낼 수 있다. 인기 점수만 적용하면 중요한 호스트를 더 심하게 두드리고, 호스트별 순환만 적용하면 중요한 문서와 무한히 확장되는 URL을 같은 비중으로 처리한다.

Mercator는 이 충돌을 우선순위 선택과 호스트별 정중함을 서로 다른 큐 계층으로 분리해 해결했다. 프로덕션 설정은 crawling thread 수의 약 3배 back queue를 두고, 한 호스트의 다음 방문을 직전 다운로드 시간보다 10배 긴 시점으로 미뤘다. 약한 정중함을 적용한 수천만 문서 크롤에서도 운영자 불만이 있었다는 관찰은 politeness가 부가 기능이 아니라 시스템 제약임을 보여준다.

중복 판정도 단일 HashSet으로 끝나지 않는다. Mercator는 10억 URL checksum에 5GB 이상이 필요하고, 평균 8ms 디스크 seek로 중복을 검사하면 초당 약 750회 검사, 페이지당 링크 10개 가정에서 초당 약 75페이지로 제한된다고 분석했다. 수치는 당시 구현과 하드웨어에 묶여 있지만, 빠른 확률 필터와 정확한 영구 저장소를 계층화한다는 설계 원리는 남는다.

출처: Mercator 논문 https://www.cs.cornell.edu/courses/cs685/2002fa/mercator.pdf.


3. URL 발견에서 재수집까지: 하나의 상태 기계

섹션 제목: “3. URL 발견에서 재수집까지: 하나의 상태 기계”

컴포넌트 이름을 외우기보다 URL 하나가 상태를 바꾸는 흐름을 따라가면 설계 경계가 선명해진다.

먼저 흐름 전체에서 반복할 용어를 짧게 고정한다.

  • Canonicalization(정규화) 은 같은 리소스라고 판단한 URL 표현을 하나의 canonical URL로 수렴시키는 정책이다.
  • Originscheme + host + effective port의 조합이다. effective port는 URL에 명시된 포트가 있으면 그 값이고, 생략했다면 스킴의 기본 포트(http는 80, https는 443)다.
  • Deduplication(중복 제거) 은 같은 URL 작업이나 같은 콘텐츠를 반복 처리하지 않도록 동일성을 판정하는 과정이다.
  • URL fingerprint 는 canonical URL을 고정 길이 해시로 바꾼 주소 식별자다.
  • Content hash 는 응답 body 또는 정규화된 본문을 해시한 콘텐츠 식별자다.
  • Fetch는 HTTP로 응답을 내려받는 단계이고, parse는 응답에서 본문·메타데이터·새 링크를 추출하는 단계다.
  • Recrawl(재수집) 은 저장한 URL을 나중에 다시 방문해 freshness를 갱신하는 작업이다.
  • Backpressure(역압) 는 downstream 처리량이 부족할 때 그 신호로 upstream 생산 속도를 낮추는 제어다.
seed / parsed links
[1 발견] → [2 정규화] → [3 URL 중복 제거] → [4 Frontier 스케줄]
[5 fetch]
[7 저장] ← [6 parse + 콘텐츠 중복 제거]
│ │
│ └─ 새 링크 → 1
[8 recrawl 예약] ──────────────→ 4
단계입력 → 출력단계 불변식깨졌을 때 보이는 신호
발견raw link → 절대 URL출처 URL과 발견 시각을 잃지 않는다출처 없는 URL 급증, 깊이 계산 불가
정규화절대 URL → canonical URL같은 정책 아래 같은 의미는 같은 문자열로 수렴한다추적 파라미터별 fetch 폭증
URL 중복 제거canonical URL → URL fingerprint한 논리 URL의 frontier 등록을 원자적으로 결정한다같은 URL이 동시에 여러 파티션에 존재
스케줄URL + 정책 → eligible time우선순위가 높아도 host 제한을 건너뛰지 않는다특정 host의 429/503 집중
fetch요청 → 응답/실패timeout과 재전달에도 결과 키는 변하지 않는다retry storm, 연결 수 고갈
parsebody → 문서/링크/content hash동일 입력 파서 버전은 재현 가능한 결과를 만든다parser 버전별 문서 수 급변
저장문서 → versioned recordURL 최신 상태와 원본 버전을 구분한다중복 객체 증가, 최신 포인터 역행
재수집변경 신호 → next fetch time실패 재시도와 freshness 재방문을 구분한다인기 문서가 오래 stale 상태로 남음

Worked example: 한 상품 페이지가 다음 세 링크로 발견되었다고 하자.

https://shop.example/item/42?utm_source=newsletter
https://SHOP.EXAMPLE:443/item/42#reviews
https://shop.example/item/42?sort=recent

https://shop.example:443 origin에서 utm_source 제거가 의미를 바꾸지 않는다고 검증한 allowlisted policy가 있고 fragment를 제거한다면 앞의 두 URL만 같은 canonical URL로 수렴한다. 다른 origin의 같은 이름 query는 그대로 보존한다. URL fingerprint 두 개가 frontier에 들어가고, 같은 host queue에서 직렬화된다. fetch 후 본문이 같다면 content hash 단계에서 저장 객체는 하나를 공유할 수도 있지만, sort=recent URL 자체를 버릴지는 별도 제품 정책이다. URL 동일성, 콘텐츠 동일성, 저장 정책은 서로 다른 결정이다.


4.1 발견: 링크를 URL 후보로 바꾸기

섹션 제목: “4.1 발견: 링크를 URL 후보로 바꾸기”

Parser는 HTML의 링크를 현재 문서 URL에 대해 절대 URL로 해석하고, 지원하는 스킴(http, https)만 남긴다. 이때 source URL, anchor text, 발견 깊이, 발견 시각을 함께 기록하면 이후 priority와 trap 판정에 근거를 줄 수 있다.

새 링크를 발견 즉시 fetch queue에 넣으면 parser 속도가 fetch 속도를 결정하고 중복이 큐를 오염시킨다. 따라서 발견 결과는 먼저 정규화와 seen test를 통과한 뒤 frontier에 등록한다.

4.2 Canonicalization: 표현을 정책에 따라 수렴시키기

섹션 제목: “4.2 Canonicalization: 표현을 정책에 따라 수렴시키기”

정규화는 동일하다고 판단한 URL 표현을 하나의 canonical URL 문자열로 바꾼다. 여기서 canonical은 서버가 선언한 <link rel="canonical">과 구분해야 한다. 전자는 fetch 전의 crawler 정책이고, 후자는 fetch 후 페이지가 제공하는 색인 힌트다.

안전한 공통 규칙은 제한적이다.

  • 스킴과 호스트를 소문자로 바꾼다. URL path는 대소문자를 구분할 수 있으므로 전체 URL을 소문자화하지 않는다.
  • http:80, https:443처럼 스킴의 기본 포트만 제거한다.
  • HTTP 요청에 전송되지 않는 fragment(#reviews)를 fetch 식별자에서 제거한다.
  • username/password userinfo가 있는 HTTP(S) URL은 정규화 전에 거부한다.
  • query를 제외한 percent-encoding은 unreserved character에 한해서만 일관되게 정리할 수 있다.
  • query는 기본적으로 opaque 문자열로 보고 순서, 빈 값, percent-encoding을 원문 그대로 보존한다.
  • 추적 파라미터 제거, query 정렬·재인코딩, 후행 슬래시 통합은 의미 동등성을 검증한 origin만 allowlist에 넣어 적용한다.

fragment 제거는 HTTP fetch 경계의 규칙이다. 브라우저는 fragment를 서버에 보내지 않지만 client-side hash router는 #/products/42를 서로 다른 화면으로 렌더링할 수 있다. 이런 SPA를 수집한다면 fragment를 HTTP URL fingerprint에 섞지 말고, 별도의 render task나 virtual route key로 보존해 렌더링·링크 발견 정책에서 다룬다.

userinfo는 조용히 버릴 수 있는 표현 차이가 아니다. https://alice:secret@example.com/item/42https://bob:secret@example.com/item/42에서 userinfo를 제거해 같은 canonical URL로 합치면 서로 다른 자격 증명 시도를 하나의 fetch identity로 오인하고, 비밀이 로그·메트릭·큐에 남을 수 있다. 따라서 parser가 username 또는 password를 발견하면 canonicalization과 fingerprint 계산 전에 명시적으로 reject하고, 해당 URL은 fetch frontier에 넣지 않는다. 인증이 필요한 수집은 URL 문자열이 아니라 별도의 승인된 credential reference와 fetch policy로 모델링한다.

다음 반례 때문에 공격적인 전역 규칙은 위험하다.

https://example.com/Docs ≠ https://example.com/docs # path 대소문자
https://example.com/item?id=1 ≠ https://example.com/item?id=2 # 리소스 식별자
https://example.com/search?q=a&q=b # 중복 key와 순서
https://example.com/a?flag ≠ https://example.com/a?flag= # key-only와 빈 값
https://example.com/a?next=%2F ≠ https://example.com/a?next=/ # reserved character 인코딩
https://example.com/a? ≠ https://example.com/a # 빈 query delimiter
https://example.com/a/ ≠ https://example.com/a # 서버에 따라 다름

작은 구현은 규칙의 경계를 드러낸다.

from typing import Callable, NamedTuple
from urllib.parse import urlsplit, urlunsplit
class QueryPolicyResult(NamedTuple):
normalized_query: str
preserve_query_delimiter: bool
QueryPolicy = Callable[[str, bool], QueryPolicyResult]
def verified_shop_query_policy(
raw_query: str, had_query_delimiter: bool
) -> QueryPolicyResult:
# 이 origin에서 이 tracking-only query가 리소스 identity를 바꾸지 않음을 검증했다.
if raw_query == "utm_source=newsletter":
return QueryPolicyResult("", False)
return QueryPolicyResult(raw_query, had_query_delimiter)
QUERY_POLICIES: dict[tuple[str, str, int | None], QueryPolicy] = {
("https", "shop.example", 443): verified_shop_query_policy,
}
def normalize_url(
url: str,
query_policies: dict[tuple[str, str, int | None], QueryPolicy] | None = None,
) -> str:
# fragment delimiter 뒤의 ?는 query delimiter가 아니다.
before_fragment, _, _ = url.partition("#")
had_query_delimiter = "?" in before_fragment
p = urlsplit(url)
if p.username is not None or p.password is not None:
raise ValueError("HTTP(S) URL userinfo is not allowed")
scheme = p.scheme.lower()
host = (p.hostname or "").lower()
port = p.port
default_port = {"http": 80, "https": 443}.get(scheme)
effective_port = port if port is not None else default_port
if (scheme, port) in {("http", 80), ("https", 443)}:
port = None
netloc = host if port is None else f"{host}:{port}"
# 기본값은 raw query 보존이다. 검증된 origin만 별도 정책을 주입한다.
policy_key = (scheme, host, effective_port)
query_policy = (query_policies or {}).get(policy_key)
query_result = (
query_policy(p.query, had_query_delimiter)
if query_policy
else QueryPolicyResult(p.query, had_query_delimiter)
)
normalized = urlunsplit(
(scheme, netloc, p.path or "/", query_result.normalized_query, "")
)
# 정책 결과가 명시적으로 요구한 경우에만 빈 query의 ?를 복원한다.
if query_result.preserve_query_delimiter and not query_result.normalized_query:
normalized += "?"
return normalized
assert normalize_url(
"HTTP://Example.com:80/Path/?utm_source=x&id=42#s"
) == "http://example.com/Path/?utm_source=x&id=42"
assert normalize_url(
"https://EXAMPLE.com:443/a?flag"
) == "https://example.com/a?flag"
assert normalize_url("https://example.com/a?flag=").endswith("?flag=")
assert normalize_url("https://example.com/a?next=%2F").endswith("?next=%2F")
assert normalize_url("https://example.com/a?") == "https://example.com/a?"
assert normalize_url("https://example.com/a") == "https://example.com/a"
assert normalize_url("https://example.com/a?") != normalize_url(
"https://example.com/a"
)
assert normalize_url("https://example.com/a?#fragment") == "https://example.com/a?"
assert normalize_url("https://example.com/a#fragment?not-query") == (
"https://example.com/a"
)
assert normalize_url(
"https://shop.example/item/42?utm_source=newsletter", QUERY_POLICIES
) == "https://shop.example/item/42"
def assert_userinfo_rejected(url: str) -> None:
try:
normalize_url(url)
except ValueError as error:
assert str(error) == "HTTP(S) URL userinfo is not allowed"
else:
raise AssertionError(f"credentialed URL was canonicalized: {url}")
assert_userinfo_rejected("https://alice:secret@example.com/item/42")
assert_userinfo_rejected("https://bob:secret@example.com/item/42")

query_policy는 정규화한 문자열뿐 아니라 빈 query delimiter를 보존할지까지 QueryPolicyResult로 반환한다. 기본 opaque 정책은 /a?/a?로 유지하지만, allowlisted 정책이 tracking-only query를 의도적으로 제거하면 preserve_query_delimiter=False를 반환해 /item/42로 수렴시킨다. 정책이 parse_qsl/urlencode 같은 구조화 API로 재직렬화하거나 tracking key를 제거하려면 flagflag=, 중복 key 순서, %2F 같은 reserved character 인코딩까지 해당 origin에서 동등하다는 회귀 테스트가 먼저 있어야 한다. 실제 구현에서는 IDNA(Internationalized Domain Names in Applications, 국제화 도메인 처리 규칙), IPv6 literal, redirect 후 최종 URL 정책까지 URL 라이브러리로 처리해야 한다. 정규화 규칙을 바꾸면 기존 fingerprint가 모두 달라질 수 있으므로 canonicalization_version을 메타데이터에 남기는 편이 안전하다.

URL fingerprint는 문자열을 그대로 큐와 인덱스 키로 반복 저장하는 비용을 줄이고 샤딩 키를 안정화한다. Birthday bound는 균등한 해시 공간에 항목 수가 늘 때 임의의 두 값이 충돌할 확률을 근사하는 계산이다. 예를 들어 SHA-256 결과의 128비트를 사용하면 URL 10억 개에서 birthday bound로 계산한 충돌 확률은 대략 1.5 × 10^-21이다.

64비트 fingerprint는 URL 10억 개에서 충돌 확률이 무시할 수준이 아니다. 충돌이 곧 문서 누락이 되는 exact seen store라면 128비트 이상을 쓰거나, 충돌 시 원문 URL을 비교해야 한다. Mercator의 8바이트 checksum은 역사적 규모감으로 보되 현대 exact identity의 무조건적인 권장값으로 해석하지 않는다.

URL deduplication의 seen test는 보통 두 계층으로 나눈다.

  1. Bloom filter: 명백히 새 fingerprint를 메모리에서 빠르게 거른다.
  2. Exact store: “이미 봤을 가능성”이 있는 fingerprint를 RocksDB, Cassandra, ScyllaDB 같은 영구 저장소에서 확인한다.

Bloom filter는 false negative가 없지만 false positive는 있다. 즉 “처음 보는 URL”을 “봤을 가능성이 있음”으로 판정할 수 있다. Bloom 결과만으로 URL을 폐기하면 coverage가 조용히 줄어든다. 정확한 수집이 중요하면 positive 결과를 exact store에서 확인하고, 일부 누락을 허용하는 focused crawl이면 priority 감점으로 쓸 수 있다.

Bloom filter 비트 수는 다음 식으로 검산한다.

m = -n × ln(p) / (ln 2)^2
k = (m / n) × ln 2
n: 예상 항목 수
p: 허용 false-positive probability
m: 필요한 bit 수
k: 권장 hash 함수 수
n = 10억 가정bit/URL이론상 비트 배열권장 k
p = 1%9.59 bit1.20GB약 7개
p = 0.1%14.38 bit1.80GB약 10개

단위는 decimal GB(10^9 byte)다. 객체, alignment, replica, 확장 여유는 포함하지 않았다. “10억 URL을 약 2GB”라는 표현은 0.1% 수준의 이론 배열이나 구현 오버헤드를 둥글게 말할 때는 가능하지만, 1%의 순수 비트 배열 산식은 약 1.2GB다.

분산 seen test의 핵심 불변식은 같은 fingerprint의 등록 결정을 한 소유 파티션에서 원자적으로 처리하는 것이다. Bloom filter를 worker마다 독립 복제하고 check와 insert를 분리하면 두 worker가 동시에 miss를 보고 같은 URL을 frontier에 넣을 수 있다.


5. Frontier: 우선순위와 host별 politeness를 결합하기

섹션 제목: “5. Frontier: 우선순위와 host별 politeness를 결합하기”

Front queue는 중요도와 freshness를, back queue는 host별 요청 가능 시각을 담당한다.

[Priority 분류기]
┌────────────────────────────────┐
│ Front queues: high / mid / low │ URL priority
└────────────────────────────────┘
│ biased selection
┌────────────────────────────────┐
│ Back queues: host별 직렬 queue │ politeness key
│ B1 example.com / B2 news.com │
└────────────────────────────────┘
│ min-heap(next_fetch_at, queue_id)
[Fetcher worker pool]

Mercator 구조의 불변식은 다음과 같다.

  • 한 back queue는 한 host의 URL만 담는다.
  • 동일 host의 활성 fetch 수는 정책 상한을 넘지 않는다. 보수적인 기본값은 1이다.
  • min-heap의 key는 (next_fetch_at, queue_id)다.
  • high priority URL도 next_fetch_at보다 먼저 나갈 수 없다.
  • back queue가 빌 때만 front queue에서 새 host 작업을 채운다.
새 URL 등록:
priority 계산 → front queue
scheduler가 host key에 해당하는 back queue로 이동
Fetcher 요청:
heap.peek()의 next_fetch_at <= now 인 queue만 선택
url = back_queue.pop()
lease(url)
fetch(url)
retry_after_at = parse_retry_after(response.headers, now)
next_fetch_at = max(
now + policy_delay,
retry_after_at,
now + adaptive_delay,
)
heap.push(next_fetch_at, queue_id)

Front queue만 쓰면 인기 host를 폭격하고, back queue만 쓰면 중요도를 잃는다. 두 제약을 분리하면 priority 알고리즘을 바꿔도 host별 안전 한계는 유지된다.

여기서 세 항은 모두 절대 eligible timestamp로 맞춘다. Retry-After가 delta-seconds면 응답 수신 시각 now에 음수가 아닌 초를 더하고, HTTP-date면 UTC 절대 시각으로 파싱해 clock skew 때문에 과거가 된 값은 now로 올려 retry_after_at을 만든다. 헤더가 없거나 유효하지 않으면 retry_after_at = now로 두며, duration인 policy_delayadaptive_delay와 절대 시각을 직접 비교하지 않는다.

5.2 robots.txt는 권한 문서가 아니라 crawler 협약이다

섹션 제목: “5.2 robots.txt는 권한 문서가 아니라 crawler 협약이다”

robots.txt는 RFC 9309의 REP(Robots Exclusion Protocol), 즉 자동 crawler가 어떤 경로를 가져오지 말아야 하는지 알리는 공개 협약 파일이다. User-agent, Allow, Disallow 규칙을 해석해 fetch 전에 적용한다.

중요한 경계는 다음과 같다.

  • robots.txt의 허용은 접근 권한, 저작권 이용 허가, 계약상 허가, 법적 적법성을 뜻하지 않는다.
  • Disallow를 우회하지 않았다고 해서 인증 우회, 개인정보 수집, 서비스 약관 위반 문제가 사라지지 않는다.
  • 반대로 robots.txt는 보안 통제도 아니다. 민감한 URL은 인증과 인가로 보호해야 한다.
  • <meta name="robots" content="noindex">X-Robots-Tag는 응답을 fetch한 뒤 색인 여부를 정하는 힌트다. fetch 자체를 제어하는 robots.txt와 단계가 다르다.
  • Crawl-delay는 널리 쓰이는 확장이지만 RFC 9309 표준 필드는 아니다. 지원 여부를 명시해야 한다.

RFC 9309 기준으로 성공적으로 받은 규칙은 따라야 하고, 4xx처럼 파일이 unavailable이면 crawler는 다른 리소스에 접근할 수 있다. 5xx나 네트워크 오류처럼 unreachable이면 complete disallow로 가정한다. 이 상태가 30일 넘게 지속되면 crawler는 unavailable로 간주하거나 캐시된 규칙을 사용할 수 있다는 예외가 있다. 캐시는 표준 HTTP cache control을 사용할 수 있지만 unreachable 예외가 아니면 24시간보다 오래 쓰지 않는 것이 권고된다. 파서는 최소 500KiB를 처리해야 한다.

robots cache key는 scheme + authority(host + effective port)로 잡고 (parsed_rules, fetched_at, etag, last_modified)를 캐시하면 매 페이지마다 robots.txt를 다시 받는 N+1 요청을 피할 수 있다. http://example.com:80, https://example.com:443, https://example.com:8443은 서로 다른 정책을 제공할 수 있으므로 host 이름만으로 합치지 않는다. 여러 fetcher가 있으면 이 캐시와 refresh lease를 공유해 동시에 갱신하지 않도록 한다.

출처: RFC 9309 https://www.rfc-editor.org/rfc/rfc9309.html.

Rate limit(요청률 제한) 은 일정 시간 또는 동시 실행 구간에 허용할 요청 수를 제한하는 정책이다. 정적 제한과 응답 기반 적응을 겹쳐 사용한다.

Politeness 신호와 적용 단계

명시 규칙

robots.txt의 Allow/Disallow를 fetch 전에 판정

경로 접근 여부

정적 예산

host별 동시성·최소 간격·초당 요청 수를 제한

정상 응답에서도 항상

측정 적응

응답 시간 증가에 따라 다음 eligible time을 늦춤

느려지는 서버 보호

응답 지시

429 Retry-After와 503 cooldown을 host 상태에 반영

서버가 과부하를 알릴 때

Mercator의 간단한 휴리스틱은 다음 요청 간격을 직전 응답 시간의 10배 이상으로 둔다.

  • 직전 fetch 200ms → 최소 2초 간격, host당 최대 약 0.5 request/s.
  • 직전 fetch 5초 → 최소 50초 간격, host당 최대 약 0.02 request/s.
  • host별 최소 간격이 2초인데 전체 목표가 400 page/s라면, steady state에 최소 400 × 2 = 800개의 fetch 가능한 host가 필요하다.

마지막 수치가 중요하다. fetcher worker를 1,000개로 늘려도 활성 host가 100개뿐이고 host별 간격이 2초면 이론상 상한은 50 page/s다. 전체 처리량을 host limit을 깨서 맞추면 설계가 틀린 것이다.

429(Too Many Requests)에 Retry-After가 있으면 그 시각을 우선한다. 없으면 min(base_delay × 2^attempt, 1h)에 jitter를 더해 같은 host의 요청을 함께 늦춘다. 503(Service Unavailable)은 더 긴 cooldown 후보지만, 응답 본문과 서비스 계약 없이 무한 재시도하지 않는다.

출처: RFC 6585 https://www.rfc-editor.org/rfc/rfc6585.

5.4 DNS와 connection reuse는 최적화이지 면제권이 아니다

섹션 제목: “5.4 DNS와 connection reuse는 최적화이지 면제권이 아니다”

DNS(Domain Name System) 조회와 TCP/TLS 연결 수립은 페이지마다 반복하면 비싸다. 예를 들어 DNS 30ms, TCP+TLS 70ms, 서버 응답 100ms라면 매번 새 연결은 최소 약 200ms가 걸린다. keep-alive 연결을 재사용하면 후속 요청은 연결 수립 100ms를 줄일 수 있고 서버의 handshake 부담도 낮춘다.

선택 기준은 다음과 같다.

  • DNS 결과는 resolver TTL을 존중해 캐시한다. IP를 영구 고정하면 failover와 CDN routing을 놓친다.
  • 연결 풀은 origin(scheme + host + effective port)별로 제한한다. 서로 다른 host가 같은 IP를 공유해도 인증서와 정책 경계가 같다고 가정하지 않는다.
  • HTTP/2 multiplexing이 여러 stream을 허용해도 application-level host concurrency는 politeness 상한을 지킨다.
  • 연결 재사용률이 낮고 TLS handshake 비율이 높다면 worker를 늘리기 전에 pool key, idle timeout, DNS churn을 확인한다.

6. Fetch: 실패를 분류하고 재시도를 제한하기

섹션 제목: “6. Fetch: 실패를 분류하고 재시도를 제한하기”

Fetcher는 네트워크 I/O 중심 컴포넌트다. URL을 lease로 가져오고, robots/politeness를 다시 확인하고, 제한된 크기의 응답을 받아 원본 body와 메타데이터를 Parser에 넘긴다. DNS timeout, connect timeout, first-byte timeout, body timeout을 구분해야 느린 연결 하나가 worker를 영구 점유하지 않는다.

결과기본 처리이유와 경계
200parse로 전달content type과 최대 body 크기는 별도 검증
301/302/307/308redirect target을 정규화 후 등록최대 hop 예: 10, loop는 fingerprint로 탐지
404/410즉시 retry 안 함기존 문서라면 긴 tombstone recrawl은 가능
408/일시 timeout2~3회 지수 backoff + jitter동일 host 동시 retry 금지
429Retry-After 우선, host 전체 감속URL 하나가 아니라 host 예산 신호
500/502/503/504제한 재시도 후 cooldown장기 장애는 coverage보다 안정성 우선
DNS NXDOMAIN긴 negative cache이름이 없다는 응답은 즉시 반복할 이유 없음
DNS SERVFAIL짧은 backoff 후 제한 재시도resolver/권한 서버의 일시 장애일 수 있음
TLS 인증서 오류격리하고 재시도 중단검증 우회는 해결책이 아님

지수 backoff에는 jitter가 필요하다. worker 1,000개가 모두 60초 뒤 정확히 재시도하면 장애 복구 시점에 또 1,000개가 몰린다. 0.5~1.5 × base × 2^attempt처럼 분산시키고 host 단위 circuit state를 공유한다.

Poison URL은 가져오거나 파싱할 때마다 같은 worker를 crash, timeout, 메모리 고갈로 몰아넣는 URL이다. at-least-once 큐에서는 lease가 만료될 때마다 다시 살아나므로 전체 처리량을 잠식한다. fingerprint별 attempt와 실패 단계를 기록하고 예를 들어 5회 후 DLQ(Dead Letter Queue, 반복 실패 작업 격리 큐)로 옮긴다. DLQ 급증은 단순 오류 수가 아니라 “실패 작업이 정상 흐름으로 계속 재유입되는가”를 보는 신호다.

6.2 Crawler trap은 URL 개수가 아니라 성장 패턴으로 찾는다

섹션 제목: “6.2 Crawler trap은 URL 개수가 아니라 성장 패턴으로 찾는다”

Crawler trap은 실질 콘텐츠가 거의 늘지 않는데 무한하거나 매우 큰 URL 공간을 생성하는 사이트 구조다. 달력의 다음 달 링크, faceted search 조합, 세션 ID, 무한 pagination, redirect loop가 대표적이다.

신호예시 경계값먼저 할 일
깊이 증가 대비 unique content 정체depth 5~10 이후 content hash 증가율 < 1%해당 path priority 감점
쿼리 조합 폭발한 path에서 parameter 10개 초과allowlist 없는 조합 확장 중단
URL 길이 이상200자 초과 비율 급증session/token 포함 여부 확인
host budget 소진host당 100만 URL 후보host 일시 정지 후 패턴 샘플링
미래 달력/calendar/2099/12날짜 범위 상한 적용
redirect loop동일 fingerprint 재등장 또는 10 hopterminal failure로 기록

경계값은 보편 법칙이 아니라 시작점이다. API 문서처럼 path가 깊거나 정상 query가 많은 사이트에 전역 상한을 강제하면 coverage를 잃는다. hard drop보다 host/path별 priority 감점과 샘플링을 먼저 적용하면 false positive의 피해를 줄일 수 있다.

6.3 동적 페이지 렌더링은 별도 예산으로 분기한다

섹션 제목: “6.3 동적 페이지 렌더링은 별도 예산으로 분기한다”

일반 HTML은 HTTP 응답을 parse하면 되지만, 본문과 링크가 JavaScript 실행 후 생기는 동적 페이지는 headless Chromium 같은 렌더러가 필요할 수 있다. 모든 페이지를 브라우저로 열면 CPU와 메모리 비용이 fetcher 규모를 지배한다.

예를 들어 다음을 용량 계획 가정으로 두자.

  • 단순 fetch+parse: 페이지당 CPU 20ms, worker memory 20MB.
  • browser render: 페이지당 CPU 2초, browser process memory 300MB.
  • 10만 페이지 중 5%만 동적 페이지.

모두 렌더링하면 100,000 × 2s = 200,000 CPU-s지만, 5%만 렌더링하면 5,000 × 2s = 10,000 CPU-s다. 실제 배수는 페이지와 브라우저 설정에 따라 달라지므로 “10~100배”를 상수로 믿지 말고 p50/p95 CPU time과 peak RSS(Resident Set Size, 실제 점유 메모리)를 측정한다.

렌더링 분기 기준은 초기 HTML의 본문 길이, 링크 수, 알려진 framework marker, 사이트별 allowlist를 조합한다. fetch 결과가 비어 있다는 이유만으로 무한 렌더링 retry를 하지 않고, renderer 실패도 별도 attempt budget과 DLQ를 둔다.


Parser는 raw body에서 본문, 제목, 링크, 언어, canonical hint를 추출한다. HTML/JS 파싱은 CPU 중심이고, raw body를 큐 메시지에 직접 넣기보다 object storage pointer와 checksum을 전달하면 broker 크기와 재전달 비용을 줄일 수 있다.

7.1 Content hash와 URL fingerprint는 다른 질문에 답한다

섹션 제목: “7.1 Content hash와 URL fingerprint는 다른 질문에 답한다”

Content hash는 정확히 같은 콘텐츠를 찾고 저장 객체를 재사용하거나 변경 여부를 판정한다. URL fingerprint가 “같은 주소인가”에 답한다면 content hash는 “같은 바이트인가”에 답한다.

URL A ─┐
├─ 같은 content hash → exact duplicate body
URL B ─┘
URL C → 다른 content hash지만 SimHash distance 2 → near duplicate 후보

콘텐츠 해시를 raw HTML에 적용하면 광고 timestamp나 CSRF token 하나만 달라도 다른 값이 된다. 본문 추출 후 boilerplate를 제거한 normalized content hash를 함께 두면 논리적 변경 판정에는 유리하지만 파서 버전에 영향을 받는다. 따라서 raw_hash, normalized_hash, parser_version을 구분하는 편이 안전하다.

10억 문서에 SHA-256 32바이트만 저장하면 순수 hash payload는 32GB다. 인덱스 key, timestamp, replication factor 3을 포함하면 이 수치의 몇 배가 된다. “32GB면 충분하다”가 아니라 저장 모델의 하한으로 사용한다.

7.2 Near-duplicate: SimHash와 MinHash 선택

섹션 제목: “7.2 Near-duplicate: SimHash와 MinHash 선택”

URL이 다르고 바이트도 조금 다르지만 실질 본문이 거의 같은 mirror, syndication 기사, 광고만 다른 페이지가 있다.

Shingle은 문서에서 뽑은 연속된 k개 token 또는 character 조각 하나이며, 문서는 이런 shingle 집합으로 표현할 수 있다. Hamming distance는 같은 길이의 두 bit string에서 서로 다른 bit 위치의 개수다.

  • SimHash: 문서 feature를 64비트 fingerprint로 압축하고 Hamming distance로 유사성을 판단한다.
  • MinHash + LSH(Locality-Sensitive Hashing): shingle 집합의 Jaccard 유사도를 근사하고 비슷한 signature 후보를 묶는다.
조건SimHash 동작권장 선택
긴 웹 본문, 광고·timestamp만 변화작은 Hamming distance로 잘 모임SimHash 우선
짧은 문서 300자 미만몇 단어 변화에 fingerprint가 크게 흔들림MinHash + 3~5 shingle 또는 exact key 병행
한중일 다국어tokenizer가 나쁘면 유사도 과소평가언어별 tokenizer 후 비교
코드·로그숫자와 식별자 변화가 과대 반영NUM, ID 정규화 후 SimHash
Jaccard 0.8 같은 기준이 제품 계약SimHash distance와 직접 대응하지 않음MinHash + LSH

Precision은 중복으로 판정한 문서 중 실제 중복의 비율이고, recall은 실제 중복 문서 중 판정기가 찾아낸 비율이다. Google의 2007년 연구는 80억 페이지 저장소에서 64비트 SimHash와 Hamming distance k=3을 사용했을 때 precision과 recall이 약 0.75 수준이라고 보고했다. 이는 해당 논문의 데이터와 판정 조건에서 얻은 결과이며 모든 데이터셋의 보장값이 아니다.

따라서 near-duplicate 판정은 처음부터 삭제보다 다음처럼 단계화한다.

  1. exact content hash 일치: body 객체 재사용, URL 메타데이터는 유지.
  2. near-duplicate 고신뢰: 대표 문서와 cluster 연결, crawl priority 감점.
  3. 짧거나 중요한 문서: MinHash 또는 제품 key로 재확인.
  4. 모델/threshold 변경: 과거 cluster를 재생성할 수 있게 version 저장.

출처: Google Research, “Detecting Near-Duplicates for Web Crawling” https://research.google.com/pubs/archive/33026.pdf.


8. 저장과 recrawl: 한 번 받은 뒤가 더 어렵다

섹션 제목: “8. 저장과 recrawl: 한 번 받은 뒤가 더 어렵다”

Recrawl은 이미 저장한 URL을 다시 방문해 변경 여부와 freshness를 갱신한다. 새 URL 발견만 처리하면 오래된 가격, 삭제된 문서, 변경된 정책이 계속 남는다.

저장 모델은 최소한 다음을 구분한다.

  • URL record: canonical URL, fingerprint, last fetch, next fetch, status, robots/politeness key.
  • fetch record: fetched_at, status, final URL, headers, raw hash, object pointer.
  • parsed document: parser version, normalized content hash, extracted fields.
  • link edge: source fingerprint, target fingerprint, discovered_at.

최신 URL record를 덮어쓰더라도 raw fetch를 versioned object로 남기면 parser 개선과 감사에 유리하다. 반대로 모든 응답을 영구 보존할 필요가 없는 제품은 unchanged 응답의 body를 content-addressed storage로 공유하고 메타데이터만 추가할 수 있다.

재수집은 고정 cron보다 문서별 next fetch time으로 frontier에 다시 넣는 편이 낫다.

문서 성격시작 간격 예변경 신호조정
속보/가격10분연속 변경더 짧게, host budget 하한은 유지
제품 상세1일ETag/본문 불변 지속2일, 4일처럼 점진 확대
기술 문서7일sitemap lastmod, 과거 변경률변경 직후 일시 단축
404/410 tombstone30일이전에 존재했던 URL즉시 retry 대신 긴 확인 주기

HTTP conditional request의 If-None-MatchIf-Modified-Since를 쓰면 unchanged 문서가 304 Not Modified로 body 전송을 생략할 수 있다. 그러나 ETag가 없거나 매 요청마다 바뀌는 사이트도 있으므로 header만 신뢰하지 않고 실제 content hash 변경률을 관측한다.

Worked example: 하루 100만 URL budget에서 신규 후보가 80만 개, recrawl 대상이 50만 개라면 둘 다 처리할 수 없다. freshness SLA(Service-Level Agreement)가 1시간인 10만 개 가격 URL을 먼저 예약하고, 나머지 90만 slot을 신규 discovery와 저변경 recrawl 사이에 priority로 배분한다. “신규 URL 우선”을 고정하면 기존 가격이 stale해지고, “recrawl 우선”을 고정하면 coverage가 멈춘다.

재수집 실패 신호는 fetch error rate만이 아니다. now - last_successful_fetch의 p95가 문서군 SLA를 넘거나, 변경이 잦은 문서가 낮은 priority queue에 오래 머무는 것이 직접적인 freshness 실패다.


Fetcher, Parser, Storage, Frontier는 자원 병목이 달라 독립적으로 확장한다.

컴포넌트주 자원확장 축대표 실패 신호
Frontier메모리 + 영구 I/Ofingerprint partitiondedup latency, eligible queue starvation
Fetcher네트워크 I/O + connection활성 host, connection pooltimeout, handshake 비율, 429
ParserCPU + memorycore, parser partitionraw queue lag, OOM poison URL
Storagedisk/network I/Oshard, write bandwidthwrite p95, throttling, object backlog
RendererCPU + memorybrowser slotrender queue age, peak RSS
Frontier ─fetch task→ Fetcher ─raw pointer→ Parser ─document→ Storage
▲ │ │
└──────── discovered URL ────────┘ └─ recrawl metadata → Frontier

Backpressure는 queue 적체 같은 피드백으로 downstream 처리량에 맞춰 upstream을 감속한다. 큐가 있다는 사실만으로 backpressure가 생기지는 않는다. 무한히 쌓이는 큐는 장애를 늦게 드러내는 버퍼일 뿐이다.

고정 lag 임계값보다 단위가 있는 계산이 낫다.

backlog_seconds = queue_lag_messages / effective_consume_rate_messages_per_second
lag_growth_rate = produce_rate - consume_rate

Parser가 초당 500페이지를 처리할 때 lag 10,000은 약 20초 분량이다. producer 600/s, consumer 500/s가 5분 지속되면 lag는 100 × 300 = 30,000 증가한다. Fetcher를 400/s로 낮추면 spare capacity가 100/s이므로 이 30,000건을 비우는 데 다시 300초가 걸린다.

따라서 다음처럼 해석한다.

  • lag가 높지만 줄고 있다: 이미 회복 중일 수 있으므로 무조건 scale-out하지 않는다.
  • lag와 oldest age가 함께 단조 증가한다: 실제 처리 용량 부족 또는 downstream hang이다.
  • raw queue만 증가하고 fetch 성공률은 정상이다: Fetcher보다 Parser CPU와 storage write를 본다.
  • discovered URL queue만 증가한다: canonicalization/dedup partition hot key 또는 trap을 본다.
  • storage latency가 증가한다: parser consumer를 더 늘리면 storage를 더 압박할 수 있으므로 fetch admission을 낮춘다.

Kafka에서는 consumer lag의 단일 숫자보다 offset commit 진행과 지속 증가 추이를 본다. Amazon SQS(Simple Queue Service)에서는 대기 메시지, in-flight 메시지, oldest age를 함께 본다. 구체적인 lag > 10,000은 예시일 뿐이며 메시지당 비용과 소비율 없이 다른 시스템에 복사하면 안 된다.

분산 큐는 URL을 최소 한 번 전달하는 at-least-once 모델을 흔히 사용한다. worker가 fetch와 저장을 끝낸 직후 ack 전에 죽으면 같은 URL이 다시 전달될 수 있다. 정확히 한 번 전달을 가정하기보다 중복 실행을 견디는 멱등 처리가 핵심이다.

Fetch generation은 같은 canonical URL의 논리적 recrawl 시도마다 새로 발급하는 generation ID다. 같은 논리적 fetch가 SQS에서 redelivery되거나 retry될 때는 동일 generation을 유지하므로 URL fingerprint + fetch generation idempotency key가 안정적으로 유지된다.

메커니즘역할구현 예
lease/visibility timeout작업을 일정 시간 한 worker에 임대SQS visibility, Redis lease
heartbeat긴 작업의 lease 연장10~30초 주기 연장
idempotency key중복 저장 결과를 같은 key로 수렴URL fingerprint + fetch generation
checkpoint재시작 지점 보존Kafka offset, RocksDB snapshot
DLQ반복 실패 작업 격리max receive count 예: 5회

SQS visibility timeout은 메시지를 다른 일반 수신에서 잠시 숨기는 장치이지 배타 락이나 exactly-once 보장이 아니다. 표준 큐의 at-least-once 전달 특성상 timeout이 끝나기 전에도 같은 메시지의 duplicate delivery가 가능하므로 두 worker가 동시에 처리할 수 있다는 전제로 멱등성을 둔다.

SQS 시나리오는 다음과 같다.

1. Worker A가 URL 메시지를 받고 2분 visibility lease를 얻는다.
2. 처리 중 heartbeat로 lease를 연장한다.
3-a. 성공하면 fingerprint와 generation key로 멱등 저장 후 메시지를 삭제한다.
3-b. 죽으면 lease 만료 후 Worker B가 같은 메시지를 다시 받는다.
4. 5회 같은 단계에서 실패하면 DLQ로 보내 poison URL을 격리한다.

visibility timeout은 평균보다 p95 처리 시간과 heartbeat 실패 감지 시간을 기준으로 잡는다. fetch p95가 45초이고 heartbeat가 15초라면 초기 2분은 합리적인 시작점이다. 너무 짧으면 정상 작업이 재전달되고, 너무 길면 죽은 worker의 복구가 늦다.

AWS SQS 표준 큐는 약 120,000 in-flight 메시지 한도가 있고, short polling은 한도에서 OverLimit, long polling은 새 메시지가 없는 것처럼 보일 수 있다. visibility timeout은 최초 수신부터 최대 12시간이며 연장해도 이 한도가 리셋되지 않는다. 이 값은 서비스 계약이 바뀔 수 있으므로 설계 시 공식 문서를 다시 확인한다.

출처: AWS SQS visibility timeout https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html, LinkedIn Burrow https://engineering.linkedin.com/apache-kafka/burrow-kafka-consumer-monitoring-reinvented.


용량 계획은 숫자보다 단위와 가정을 먼저 적는다. 다음은 “30일에 10억 페이지의 초기 crawl”이라는 예시다.

pages_per_second
= 1,000,000,000 page / (30 day × 86,400 second/day)
≈ 386 page/second
body_bytes_per_second
= 386 page/s × 100,000 byte/page
= 38,600,000 byte/s
≈ 38.6 MB/s ≈ 309 Mb/s

100KB를 decimal 100,000 byte, 1MB를 1,000,000 byte로 두었다. header, redirect, robots, retry, TLS overhead를 제외했으므로 네트워크 목표를 400Mb/s처럼 여유 있게 잡을 수 있다. 압축 전 HTML 크기와 wire bytes를 혼동하면 parser 메모리와 네트워크 산식이 어긋난다.

페이지당 평균 10개 링크면 discovery 입력은 약 3,860 URL/s다. 정규화 후 70%가 이미 본 URL이라면 exact 신규 등록은 약 1,158 URL/s다. dedup 서비스는 fetch rate 386/s가 아니라 발견 rate 3,860/s를 기준으로 용량을 잡아야 한다.

raw body lower bound
= 1,000,000,000 page × 100,000 byte/page
= 100,000,000,000,000 byte
= 100 TB (decimal) ≈ 90.9 TiB

여기에 HTTP metadata, parsed fields, link graph, index, replication이 더해진다. 반대로 gzip 압축과 exact duplicate body 공유는 줄인다. replication factor 3을 적용한 raw 하한만 300TB이므로 “페이지 평균 100KB” 하나로 최종 저장비를 단정하지 않는다.

URL fingerprint 128비트는 순수 값만 16 byte × 10억 = 16GB, content SHA-256은 32GB다. 하지만 key-value index overhead와 replica를 포함한 실제 용량은 별도 benchmark가 필요하다.

Little’s Law는 안정 상태 시스템의 평균 동시 작업 수 L이 평균 도착률 λ와 평균 체류 시간 W의 곱, 즉 L = λW라는 관계다. Fetcher 하나가 평균 동시 요청 100개를 유지하고 평균 응답 시간이 500ms라면 이를 단순 적용한 이론 처리량은 100 / 0.5s = 200 page/s다. 두 worker면 400 page/s지만, 이는 충분한 활성 host와 네트워크가 있다는 가정이다.

host당 최소 간격이 2초라면 386 page/s를 내려면 적어도 772개의 eligible host가 필요하다. 실제로는 DNS 오류, robots 제한, 느린 host가 있으므로 더 많은 host 다양성이 필요하다. worker 수, host 다양성, politeness delay 중 가장 작은 상한이 전체 처리량을 결정한다.

크롤러가 외부 웹 페이지를 받는 트래픽은 주로 internet → VPC 방향이다. 이를 단순히 “outbound 1GB”라고 부르면 방향이 틀린다. 다만 private subnet의 NAT Gateway를 통과하면 방향과 무관하게 NAT가 처리한 byte에 data processing charge가 붙는다.

페이지 1만 개 × 평균 wire body 100KB는 decimal 기준 약 1GB다. AWS의 Ohio 예시 단가 $0.045/GB를 그대로 가정하면 NAT data processing은 약 $0.045지만, NAT hourly charge와 region별 가격, cross-AZ, 외부로 나가는 별도 data transfer는 포함하지 않은 값이다. 가격은 변할 수 있으므로 계산 구조만 보존하고 공식 가격표를 확인한다.

출처: AWS VPC pricing https://aws.amazon.com/vpc/pricing/.


비교핵심 차이
Crawler vs ScraperCrawler는 링크를 따라 URL 공간을 발견하고 스케줄한다. Scraper는 정해진 페이지에서 필드를 추출한다.
BFS vs Priority crawlBFS는 깊이를 균등하게 넓힌다. Priority는 중요도·freshness·변경률에 예산을 집중한다.
URL canonicalization vs rel=canonical전자는 fetch 전 crawler 정책, 후자는 fetch 후 사이트가 주는 색인 힌트다.
URL fingerprint vs Content hash전자는 주소 동일성, 후자는 exact body 동일성을 판정한다.
Content hash vs SimHash전자는 바이트 단위 exact duplicate, 후자는 near-duplicate 후보를 찾는다.
Bloom filter vs HashSetBloom은 작은 메모리와 false positive를 교환한다. HashSet은 정확하지만 overhead가 크다.
robots.txt vs 접근 권한robots.txt는 crawler 협약이며 인증·인가·법적 허가가 아니다.
robots Disallow vs noindexDisallow는 fetch 전, noindex는 fetch 후 색인 단계에 적용된다.
Crawl-delay vs adaptive delayCrawl-delay는 비표준 정적 힌트, adaptive delay는 관측 응답과 오류 기반 정책이다.
At-least-once vs exactly-once effect중복 전달은 허용하되 idempotency key로 저장 결과를 한 번의 효과처럼 만든다.

Exactly-once를 “분산 시스템에서 절대 불가능”이라고만 외우면 경계를 놓친다. 일부 broker와 transactional sink는 제한된 경계 안에서 exactly-once semantics를 제공한다. 그러나 HTTP fetch, 외부 서버, object storage까지 포함한 end-to-end 효과를 원자적으로 묶기는 어렵다. 이 크롤러에서는 at-least-once 전달과 멱등 저장을 기본값으로 삼는다는 뜻이다.

법적·윤리적 판단도 아키텍처 밖으로 미루지 않는다. 공개 URL, robots 허용, 기술적으로 fetch 가능이라는 세 사실은 수집·저장·재배포가 허용된다는 결론과 같지 않다. 인증 우회 금지, 개인정보 최소화, 약관과 관할 법 검토, 삭제 요청 처리 같은 별도 통제가 필요하다.


12. 실패 신호로 설계를 역추적하기

섹션 제목: “12. 실패 신호로 설계를 역추적하기”
관측 신호의미 후보먼저 확인할 불변식
특정 host의 429/503만 급증host rate/concurrency 과다high priority가 politeness를 우회했는가
TLS handshake 비율과 latency 증가connection reuse 실패origin pool key와 idle timeout
fetch rate 정상, raw queue age 증가Parser/Storage 병목backpressure가 Fetcher admission에 연결됐는가
discovered URL만 폭증, unique content 정체crawler trappath별 성장률과 query 조합 상한
Bloom positive 증가와 신규 coverage 급감filter 포화 또는 false positive 오용positive를 exact store에서 확인하는가
같은 fingerprint가 동시 fetchcheck-and-insert raceseen store 소유 파티션과 원자성
DLQ에 한 fingerprint 반복poison URL실패 단계별 attempt budget이 있는가
browser queue만 오래됨dynamic 분기 과다 또는 renderer 병목초기 HTML 분류와 render budget
저장량은 급증, unique content hash는 정체duplicate body 미공유raw/normalized hash 저장 정책
fetch 성공률 정상, 문서 freshness SLA 위반recrawl priority starvation신규와 재수집 예산을 분리했는가
in-flight와 oldest age 동시 증가zombie worker/downstream hangheartbeat만 연장하고 완료가 멈췄는가

실패 신호는 바로 knob를 조정하라는 명령이 아니다. 예를 들어 lag가 높다는 이유만으로 Parser를 늘리면 Storage 병목을 악화할 수 있다. producer rate, consumer rate, oldest age, downstream latency를 함께 보고 병목 단계를 먼저 식별한다.


  • 검색 엔진: 수천억 URL에서 coverage, freshness, ranking signal을 함께 수집한다.
  • SEO 도구: backlink graph와 변경 이력을 구축한다.
  • 가격 모니터링: 중요 상품은 짧은 recrawl, 저변경 상품은 긴 간격을 둔다.
  • 컴플라이언스·데이터셋 수집: provenance와 삭제 정책을 URL/fetch version에 연결한다.
  • AI 학습 데이터: exact/near duplicate 제거와 품질 분류가 저장비와 학습 분포에 직접 영향을 준다.
  • 사내 통합 검색: Confluence, Notion, GitHub 같은 connector를 host로 보고 cursor, rate limit, recrawl을 적용한다.
  • 외부 API polling: URL Frontier 대신 resource key frontier를 쓰지만 rate limit, retry, idempotency, backpressure는 동일하다.
  • Kubernetes work queue: URL fingerprint 대신 resource key를 dedup하고, 실패 key에 rate-limited retry를 적용한다.

조건부 참고: crawler 단계를 broker 기반 event pipeline으로 분리할 때만 EDA Basics를 참고한다. Kubernetes controller의 work queue와 crawler frontier를 비교할 때만 Kubernetes Basics를 참고한다. 두 문서는 이 토픽의 필수 선수지식이 아니다.

Common Crawl은 공개 웹 crawl을 WARC(Web ARChive) 형식으로 배포하는 대표 사례다. Heritrix와 Apache Nutch는 구현을 살펴볼 수 있는 crawler이며, Frontera와 crawler-commons의 url-frontier는 frontier 경계를 별도 서비스로 다룰 때 참고할 수 있다.

추가 학습 키워드는 다음과 같다.

  • Focused Crawling: 특정 주제와 관련된 URL에 priority를 집중하는 방식.
  • PageRank·HITS: link graph를 URL priority 신호로 바꾸는 정보 검색 알고리즘.
  • Bloom filter·SimHash: 확률적 URL 중복 판정과 near-duplicate 판정의 오차·메모리 trade-off.
  • MinHash·LSH: Jaccard 유사도 기반 near-duplicate 후보 검색.
  • Two Generals Problem: 통신 실패가 있는 분산 시스템에서 상대의 수신 여부를 확정하기 어려운 이유와 재전달·멱등성의 관계.
  • Politeness vs Coverage: host 보호와 제한된 시간 안의 수집 범위가 충돌하는 근본 trade-off.
  • JavaScript rendering at scale: browser pool 격리, render budget, cache 정책.
  • hiQ Labs v. LinkedIn: 공개 웹 크롤링의 법적 쟁점을 공부할 사례. 판결을 일반 허가로 확대 해석하지 않고 관할과 후속 절차를 함께 확인해야 한다.

실습 A: robots.txt의 단계와 경계 확인

섹션 제목: “실습 A: robots.txt의 단계와 경계 확인”

자신이 소유하거나 실습 허가를 받은 로컬 사이트에서 robots.txt를 구성하고 parser 결과를 확인한다.

import urllib.robotparser
rp = urllib.robotparser.RobotFileParser()
rp.set_url("http://localhost:8000/robots.txt")
rp.read()
print(rp.can_fetch("study-crawler", "http://localhost:8000/"))
print(rp.can_fetch("study-crawler", "http://localhost:8000/private/"))
print(rp.crawl_delay("study-crawler"))

합격선: Allow/Disallow 결과를 설명하고, 허용 결과가 인증이나 법적 허가를 뜻하지 않는 이유를 말할 수 있다.

4.2절의 함수를 실행하고 다음 테스트를 추가한다.

assert normalize_url("https://EXAMPLE.com/Docs") != normalize_url(
"https://example.com/docs"
)
assert normalize_url("https://example.com/a?id=1") != normalize_url(
"https://example.com/a?id=2"
)
assert normalize_url("https://example.com/a#top") == normalize_url(
"https://example.com/a#bottom"
)
assert normalize_url("https://example.com/a?flag") != normalize_url(
"https://example.com/a?flag="
)
assert normalize_url("https://example.com/a?next=%2F") != normalize_url(
"https://example.com/a?next=/"
)
assert normalize_url("https://example.com/a?") != normalize_url(
"https://example.com/a"
)
assert normalize_url("https://example.com/a?#fragment") == "https://example.com/a?"
assert normalize_url("https://example.com/a#fragment?not-query") == (
"https://example.com/a"
)
assert normalize_url(
"https://shop.example/item/42?utm_source=newsletter", QUERY_POLICIES
) == "https://shop.example/item/42"
assert_userinfo_rejected("https://alice:secret@example.com/item/42")
assert_userinfo_rejected("https://bob:secret@example.com/item/42")

합격선: host와 path의 대소문자 정책이 다른 이유, query 제거·정렬·재인코딩이 검증된 origin별 allowlist 규칙이어야 하는 이유를 설명할 수 있다. 또한 기본 opaque /a?와 정책이 query를 제거한 /item/42의 delimiter 처리가 다른 이유, fragment delimiter 뒤의 ?를 query delimiter로 오인하면 안 되는 이유, 자격 증명 포함 URL을 frontier 전에 거부해야 하는 이유를 말할 수 있다.

from math import log
for p in [0.01, 0.001]:
n = 1_000_000_000
m_bits = -n * log(p) / (log(2) ** 2)
k = (m_bits / n) * log(2)
print(p, m_bits / 8 / 1_000_000_000, k)

같은 capacityerror_rate로 실제 Bloom filter 구현을 만들 수 있다면 이론 bit array 크기와 함께 bit array의 실제 byte 길이, 직렬화 크기, 생성 전후 process RSS를 기록한다. 객체 header, word alignment, hash seed, allocator 여유 때문에 어느 값이 늘어났는지 구분한다.

합격선: 1%와 0.1%에서 약 1.20GB, 1.80GB가 나오는 과정을 단위와 함께 설명하고, 실제 구현 overhead를 최소 한 가지 측정값으로 관찰해 이론값과 구분한다.

자신의 로컬 사이트에서만 다음 조건으로 100개 URL을 처리한다.

  • host당 동시성 1, 최소 간격 1초.
  • 깊이 3, redirect 최대 10회.
  • canonical URL seen test와 content hash를 별도 기록.
  • robots.txt Disallow URL fetch 0건.
  • 반복 실패 5회 URL을 별도 목록으로 격리.

합격선: 중복 URL 수, exact duplicate content 수, 평균 host 간격, retry 수를 출력하고 서로 다른 지표인 이유를 설명한다.

Mercator 논문의 Figure 1을 보고 front queue N개, back queue M개, next-fetch heap을 그린다. priority가 높은 동일 host URL 두 개와 priority가 낮은 다른 host URL 하나를 넣고 실제 dequeue 순서를 손으로 추적한다.

합격선: high priority가 host delay를 우회하지 않는 과정을 설명할 수 있다.

페이지 1만 개, 평균 wire body 100KB, host 간격 1초, 평균 응답 500ms를 가정한다.

  1. 총 wire body가 decimal 1GB인지 계산한다.
  2. 목표 완료 시간 10분이면 평균 약 16.7 page/s가 필요한지 계산한다.
  3. host가 10개뿐일 때 politeness 상한이 10 page/s임을 확인한다.
  4. NAT Gateway 처리 비용에는 data processing 외 hourly/region 요인이 있음을 적는다.

합격선: worker를 늘려도 host 다양성 때문에 10분 목표를 달성할 수 없는 반례를 설명한다.

Common Crawl WARC 파일 하나를 warcio로 읽어 페이지 수, 평균 응답 크기, 상위 10개 host, exact content hash 중복률을 집계한다.

합격선: 상위 host 집중과 중복률이 frontier partition과 저장량에 어떤 영향을 주는지 설명한다.


대규모 웹 크롤러 설계 이해도 점검

  • crawler와 scraper의 차이, URL Frontier의 역할을 설명할 수 있는가?
  • 발견→정규화→URL 중복 제거→스케줄→fetch→parse→저장→recrawl 흐름과 각 단계 불변식을 말할 수 있는가?
  • robots.txt가 접근 권한·보안 통제·법적 허가가 아닌 crawler 협약인 이유를 설명할 수 있는가?
  • front queue와 back queue가 각각 priority와 host politeness를 푸는 이유를 설명할 수 있는가?
  • URL 전체 소문자화와 query 전역 제거가 만드는 반례를 들 수 있는가?
  • URL fingerprint, content hash, SimHash가 각각 답하는 동일성 질문을 구분할 수 있는가?
  • Bloom filter의 false positive가 누락으로 이어지는 조건과 exact store의 역할을 설명할 수 있는가?
  • 429, 503, DNS 실패, poison URL에 서로 다른 retry 정책이 필요한 이유를 설명할 수 있는가?
  • DNS와 connection reuse가 처리량을 높여도 host rate limit을 바꾸지 않는 이유를 말할 수 있는가?
  • crawler trap과 dynamic page를 성장률·render budget으로 판정할 수 있는가?
  • queue lag를 backlog seconds로 바꾸고 backpressure가 필요한 단계를 찾을 수 있는가?
  • recrawl priority와 freshness SLA가 신규 URL coverage와 충돌하는 예를 설명할 수 있는가?
  • 처리량·네트워크·저장량 산식의 page, second, byte, bit, GB/TiB 단위를 검산할 수 있는가?


  1. 크롤러는 URL을 발견하고 다시 방문하는 시스템이며, Frontier가 priority와 host별 eligible time을 함께 관리한다.
  2. 전체 흐름은 발견→정규화→URL dedup→스케줄→fetch→parse/content dedup→저장→recrawl이고 각 단계의 동일성 기준이 다르다.
  3. robots.txt는 crawler 협약이지 접근 권한이나 법적 허가가 아니며, politeness는 host rate limit·응답시간·429/503을 함께 반영한다.
  4. URL fingerprint, content hash, SimHash를 구분하고 retry·poison URL·trap·동적 렌더링을 제한된 예산으로 다뤄야 조용한 누락과 폭주를 막는다.
  5. 큐 lag는 소비율과 함께 해석하고 backpressure로 upstream을 늦추며, 용량 계산은 page/s, byte/s, bit/URL 같은 단위와 가정을 먼저 적는다.

최종 수정: 2026-07-13