설계


질문 답변
이 크롤러의 주된 용도는? 검색 엔진 인덱스 생성용
매달 얼마나 많은 웹 페이지를 수집해야 하는가? 10억 개(1billion)의 웹 페이지를 수집해야 함
새로 만들어진 웹 페이지나 수정된 웹 페이지도 고려해야 하는가? YES. 이미 수집한 페이지라도 업데이트되면 다시 크롤링해야 합니다.
수집한 웹 페이지는 저장해야하는가? YES. 5년간 보관
중복된 컨텐츠는? 중복은 무시해도 됩니다. 같은 내용이면 한 번만 저장하면 돼요.

image.png

예시 시나리오: "https://news.naver.com" 크롤링


📍 Step 1: API Server

Input

사용자가 POST 요청:
{
  "url": "<https://news.naver.com>"
}

Output 예시 (Kafka Produce)

Topic: "urls-to-crawl"

Message:
{
  "url": "<https://news.naver.com>",
  "depth": 0,
  "timestamp": 1696723456,
  "request_id": "req-12345"
}

📍 Step 2: Priority Manager

Input (Kafka Consume)

Topic: "urls-to-crawl"

Message:
{
  "url": "<https://news.naver.com>",
  "depth": 0,
  "timestamp": 1696723456,
  "request_id": "req-12345"
}

처리

1. PageRank 계산
   도메인: "news.naver.com"

   Redis 조회:
   ┌────────────────────────────────┐
   │ DB 3: PageRank 점수            │
   ├────────────────────────────────┤
   │ GET pagerank:news.naver.com    │
   │ → 0.95 (캐시 히트!)            │
   └────────────────────────────────┘

2. 우선순위 점수 계산
   - PageRank: 0.95
   - 뉴스 카테고리: +5점
   - 업데이트 빈도 높음: +3점
   - 최종 점수: 95점

3. URL 분류
   95점 → P1 (높음) 큐로 분류

Output (Kafka Produce)

Topic: "priority-p1" (높음)

Message:
{
  "url": "<https://news.naver.com>",
  "priority_score": 95,
  "pagerank": 0.95,
  "depth": 0,
  "timestamp": 1696723456,
  "request_id": "req-12345"
}

📍 Step 3: Crawler Worker

Input (Kafka Consume - 4:3:2:1 비율)

Crawler Worker는 4개 토픽 구독:
- priority-p0: 40% 처리
- priority-p1: 30% 처리 ← 여기서 소비!
- priority-p2: 20% 처리
- priority-p3: 10% 처리

Message (priority-p1에서):
{
  "url": "<https://news.naver.com>",
  "priority_score": 95,
  "pagerank": 0.95,
  "depth": 0,
  "timestamp": 1696723456,
  "request_id": "req-12345"
}

처리 과정

3-1. Redis 체크: 방문 여부

┌────────────────────────────────────────┐
│ DB 1: 방문한 URL                         │
├────────────────────────────────────────┤
│ URL 해시 계산:                           │
│ hash("<https://news.naver.com>")         │
│ → "abc123def456"                       │
│                                        │
│ 1차 체크 (Bloom Filter):                 │
│ BF.EXISTS bloom:visited_urls abc123def │
│ → 0 (없음, 미방문!)                       │
│                                        │
│ 결론: 크롤링 진행 OK!                      │
└────────────────────────────────────────┘

3-2. Redis 체크: Rate Limit

┌────────────────────────────────────────┐
│ DB 5: Rate Limiting                    │
├────────────────────────────────────────┤
│ GET ratelimit:news.naver.com           │
│ → 1696723450 (마지막 요청 시간)           │
│                                        │
│ 현재 시간: 1696723456                  │
│ 경과 시간: 6초                         │
│                                        │
│ 1초 이상 지남? YES                     │
│ 결론: 요청 가능!                       │
└────────────────────────────────────────┘

3-3. DNS 조회

┌────────────────────────────────────────┐
│ DB 0: DNS 캐시                         │
├────────────────────────────────────────┤
│ GET dns:news.naver.com                 │
│ → "223.130.195.95" (캐시 히트!)       │
│                                        │
│ DNS 조회 시간: 0ms (캐시)              │
│ (조회했다면 10-200ms 걸렸을 것)        │
└────────────────────────────────────────┘

3-4. robots.txt 확인

┌────────────────────────────────────────┐
│ DB 4: robots.txt 캐시                  │
├────────────────────────────────────────┤
│ GET robots:news.naver.com              │
│ → {                                    │
│     "user_agent": "*",                 │
│     "disallow": ["/admin", "/api"],    │
│     "crawl_delay": 1                   │
│   }                                    │
│                                        │
│ "/" 경로 허용? YES                     │
│ 결론: 크롤링 가능!                     │
└────────────────────────────────────────┘

3-5. HTTP GET 요청 및 다운로드

HTTP 요청:
GET <https://news.naver.com>
Host: news.naver.com
User-Agent: MyBot/1.0

응답:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 524288

<!DOCTYPE html>
<html>
<head>
  <title>네이버 뉴스</title>
</head>
<body>
  <a href="<https://news.naver.com/article/001>">기사1</a>
  <a href="<https://news.naver.com/article/002>">기사2</a>
  <a href="<https://sports.naver.com>">스포츠</a>
  ...
</body>
</html>

다운로드 완료: 512KB (524,288 bytes)
소요 시간: 487ms

3-6. S3 저장

S3 업로드:
Bucket: crawler-bucket
Key: raw/2025/10/14/abc123def456.html

동시에 압축 버전도 저장:
Key: compressed/2025/10/14/abc123def456.html.gz
크기: 512KB → 51KB (90% 압축)

3-7. Redis 업데이트

┌────────────────────────────────────────┐
│ 방문 기록 저장                         │
├────────────────────────────────────────┤
│ 1. Bloom Filter에 추가                 │
│ BF.ADD bloom:visited_urls abc123def    │
│                                        │
│ 2. 정확한 기록 저장                    │
│ SET visited:abc123def {                │
│   "url": "<https://news.naver.com>",     │
│   "timestamp": 1696723456,             │
│   "status": 200                        │
│ }                                      │
│ TTL: 30일                              │
│                                        │
│ 3. Rate Limit 업데이트                 │
│ SET ratelimit:news.naver.com 1696723456│
│ TTL: 10초                              │
└────────────────────────────────────────┘

Output (Kafka Produce)

Topic: "crawled-pages"

Message:
{
  "url": "<https://news.naver.com>",
  "s3_key": "raw/2025/10/14/abc123def456.html",
  "s3_key_compressed": "compressed/2025/10/14/abc123def456.html.gz",
  "original_size": 524288,
  "compressed_size": 52428,
  "status_code": 200,
  "content_type": "text/html",
  "headers": {
    "server": "nginx",
    "date": "Mon, 14 Oct 2025 12:34:56 GMT"
  },
  "download_time_ms": 487,
  "timestamp": 1696723456,
  "request_id": "req-12345",
  "snippet": "<!DOCTYPE html><html><head><title>네이버 뉴스..."  // 첫 500자
}

메시지 크기: 약 2KB


📍 Step 4: Content Processor

Input (Kafka Consume)

Topic: "crawled-pages"

Message:
{
  "url": "<https://news.naver.com>",
  "s3_key": "raw/2025/10/14/abc123def456.html",
  "original_size": 524288,
  "status_code": 200,
  "content_type": "text/html",
  "timestamp": 1696723456,
  "request_id": "req-12345",
  "snippet": "<!DOCTYPE html><html><head><title>네이버 뉴스..."
}

처리 과정

4-1. S3에서 HTML 다운로드

S3 GET:
Bucket: crawler-bucket
Key: raw/2025/10/14/abc123def456.html

다운로드:
<!DOCTYPE html>
<html>
<head>
  <title>네이버 뉴스</title>
</head>
<body>
  <a href="<https://news.naver.com/article/001>">기사1</a>
  <a href="<https://news.naver.com/article/002>">기사2</a>
  <a href="<https://sports.naver.com>">스포츠</a>
  <a href="/entertainment">연예</a>
  <a href="malformed-url">잘못된 링크</a>
</body>
</html>

4-2. HTML 파싱 (Jsoup)

Jsoup.parse(html)

추출된 정보:
- Title: "네이버 뉴스"
- Description: "최신 뉴스를 가장 빠르게"
- Keywords: "뉴스, 속보, 정치"
- Language: "ko"
- Links: 5개 발견

4-3. 메타데이터 추출

메타데이터:
{
  "title": "네이버 뉴스",
  "description": "최신 뉴스를 가장 빠르게",
  "keywords": ["뉴스", "속보", "정치"],
  "author": null,
  "publish_date": "2025-10-14",
  "language": "ko",
  "og_image": "<https://news.naver.com/og_image.jpg>"
}

4-4. 중복 콘텐츠 체크

┌────────────────────────────────────────┐
│ DB 2: 콘텐츠 해시                      │
├────────────────────────────────────────┤
│ 1. HTML 해시 계산                      │
│ SHA-256(html_content)                  │
│ → "sha256_xyz789abc"                   │
│                                        │
│ 2. Bloom Filter 체크                   │
│ BF.EXISTS bloom:content_hashes sha256_ │
│ → 0 (없음, 신규 콘텐츠!)               │
│                                        │
│ 3. 해시 저장                           │
│ SET content:sha256_xyz789abc {         │
│   "url": "<https://news.naver.com>",     │
│   "timestamp": 1696723456              │
│ }                                      │
│ TTL: 30일                              │
└────────────────────────────────────────┘

4-5. URL 추출

발견한 링크들:
1. "<https://news.naver.com/article/001>"     ← 절대 경로
2. "<https://news.naver.com/article/002>"     ← 절대 경로
3. "<https://sports.naver.com>"               ← 절대 경로
4. "/entertainment"                         ← 상대 경로!
5. "malformed-url"                          ← 잘못된 URL

정규화:
1. "<https://news.naver.com/article/001>"     ✅
2. "<https://news.naver.com/article/002>"     ✅
3. "<https://sports.naver.com>"               ✅
4. "/entertainment"
   → "<https://news.naver.com/entertainment>" ✅
5. "malformed-url"                          ❌ 제거

총 4개 URL 추출

4-6. URL 필터링

필터 규칙:
- 블랙리스트 도메인: ["spam.com", "malware.net"]
- 제외 파일: [".pdf", ".zip", ".exe"]
- 파라미터 정리: "?utm_source=..." 제거

필터링 결과:
1. "<https://news.naver.com/article/001>"     ✅ 통과
2. "<https://news.naver.com/article/002>"     ✅ 통과
3. "<https://sports.naver.com>"               ✅ 통과
4. "<https://news.naver.com/entertainment>"   ✅ 통과

최종: 4개 URL

4-7. 저장

MongoDB에 메타데이터 저장

Collection: pages

Document:
{
  "_id": "abc123def456",
  "url": "<https://news.naver.com>",
  "title": "네이버 뉴스",
  "description": "최신 뉴스를 가장 빠르게",
  "keywords": ["뉴스", "속보", "정치"],
  "language": "ko",
  "content_hash": "sha256_xyz789abc",
  "s3_key": "raw/2025/10/14/abc123def456.html",
  "size": 524288,
  "crawled_at": "2025-10-14T12:34:56Z",
  "status": 200,
  "extracted_urls": 4
}

S3는 이미 저장됨 (Crawler Worker가 함)

Output (Kafka Produce)

Topic: "urls-to-crawl" (순환!)

4개 메시지 발행:

Message 1:
{
  "url": "<https://news.naver.com/article/001>",
  "depth": 1,  ← depth 증가!
  "parent_url": "<https://news.naver.com>",
  "timestamp": 1696723460,
  "request_id": "req-12346"
}

Message 2:
{
  "url": "<https://news.naver.com/article/002>",
  "depth": 1,
  "parent_url": "<https://news.naver.com>",
  "timestamp": 1696723460,
  "request_id": "req-12347"
}

Message 3:
{
  "url": "<https://sports.naver.com>",
  "depth": 1,
  "parent_url": "<https://news.naver.com>",
  "timestamp": 1696723460,
  "request_id": "req-12348"
}

Message 4:
{
  "url": "<https://news.naver.com/entertainment>",
  "depth": 1,
  "parent_url": "<https://news.naver.com>",
  "timestamp": 1696723460,
  "request_id": "req-12349"
}


🔄 순환! 다시 Priority Manager로

이제 4개의 새 URL이 urls-to-crawl 토픽에 들어갔으므로, Priority Manager가 다시 소비해서 우선순위를 매기고, 이 과정이 무한 반복


📊 전체 흐름 타임라인

00:00.000  사용자가 API Server에 URL 제출
00:00.010  API → Kafka: urls-to-crawl

00:00.020  Priority Manager 소비
00:00.025  Redis에서 PageRank 조회 (5ms)
00:00.030  P1 큐로 분류
00:00.035  Kafka: priority-p1에 발행

00:00.040  Crawler Worker 소비 (4:3:2:1 비율로 P1 처리)
00:00.045  Redis: 방문 체크 (5ms) → 미방문
00:00.050  Redis: Rate Limit 체크 (5ms) → OK
00:00.052  Redis: DNS 조회 (2ms, 캐시 히트!)
00:00.057  Redis: robots.txt 조회 (5ms, 캐시 히트!)
00:00.060  HTTP GET 시작
00:00.547  HTTP 완료 (487ms) ← 가장 오래 걸림!
00:00.570  S3 업로드 (23ms)
00:00.575  Redis 업데이트 (5ms)
00:00.580  Kafka: crawled-pages에 발행

00:00.590  Content Processor 소비
00:00.610  S3 다운로드 (20ms)
00:00.660  HTML 파싱 (50ms)
00:00.670  메타데이터 추출 (10ms)
00:00.675  중복 체크 (5ms) → 신규
00:00.705  URL 추출 (30ms)
00:00.715  URL 필터링 (10ms)
00:00.735  MongoDB 저장 (20ms)
00:00.740  Kafka: urls-to-crawl에 4개 URL 발행

━━━━━━━━━━━━━━━━━━━━━━━━━━━
총 소요 시간: 740ms
병목: HTTP GET (487ms, 66%)


💡 기타

Kafka 메시지 크기

urls-to-crawl:      ~200 bytes (URL + 메타)
priority-p0~p3:     ~250 bytes (점수 추가)
crawled-pages:      ~2KB (메타 + snippet)
urls-to-crawl(순환): ~200 bytes

→ HTML 본문은 Kafka에 없음!
→ S3에만 저장됨

Redis 역할

DB 0: DNS 캐시           → 10-200ms를 0ms로!
DB 1: 방문 URL           → 중복 크롤링 방지
DB 2: 콘텐츠 해시        → 중복 콘텐츠 방지
DB 3: PageRank          → 우선순위 계산
DB 4: robots.txt        → 크롤링 규칙
DB 5: Rate Limiting     → 예의 지키기 (1초 간격)