어떤 수업을 찾고 있나요?

예: PowerShell, 네트워크, XSS, Step 138

전체 커리큘럼 둘러보기 →

웹 보안

Step 76. 크롤러와 자동화 — 매일 혼자 일하는 수집기

Step 76예상 실습 시간 · 4시간

Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 74~75를 마쳤다. requests로 요청을 보내고 BeautifulSoup으로 데이터를 뽑을 수 있다. CSV 파일을 파이썬으로 읽고 쓸 수 있다(Step 48).

  • 준비물: 파이썬, requests와 beautifulsoup4(Step 74에서 설치한 그것), 그리고 반나절의 여유. 오늘은 연습용 웹사이트도 직접 만듭니다 — 별도 설치 없이 파이썬만으로 됩니다.
  • 주의: 오늘 실습은 자신이 소유한 로컬 실습 환경으로 범위를 제한합니다. 크롤러가 방문하는 사이트는 여러분의 컴퓨터 안(127.0.0.1)에 여러분이 직접 지은 연습장뿐입니다. 외부 사이트에는 한 번도 접속하지 않습니다.

지금까지 우리의 스크립트는 “켜면 한 번 일하고 꺼지는” 장난감이었습니다. 진짜 도구는 다릅니다. 주인이 자는 동안에도 일하고, 어제 모은 것을 기억해서 같은 것을 두 번 가져오지 않고, 실패하면 조용히 기록을 남깁니다. 오늘의 프로젝트는 장난감을 도구로 진화시키는 일입니다. 페이지 순회, CSV 누적, 중복 제거, 오류 격리, 자동 실행 — 이 다섯 가지는 명언 수집이든 취약점 공지 수집이든 대상만 바꾸면 그대로 쓰이는, 보안 실무의 기본 골격입니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • 루프백에서 실습 사이트를 실행합니다.
  • 제한한 페이지를 순회하고 순환·외부 링크를 거부합니다.
  • 이전 기록과 현재 배치의 중복을 제거합니다.
  • 실패를 기록하고 0이 아닌 종료 상태를 반환합니다.
  • 선택적으로 단일 예약 실행을 구성하고 확인합니다.

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + requests, BeautifulSoup4 (Step 74~75에서 설치)
오늘의 도구 python -m http.server(연습 사이트), while 순회 루프, csv 모듈, set, cron / 작업 스케줄러
필요한 개념 페이지네이션(페이지 나누기), 누적 저장, 중복 제거, 오류 격리, 절대 경로
오늘의 산출물 crawler_core.py + crawler_archive.py + crawler_run.py, CSV·오류 로그·선택적 예약 실행

2-1. 페이지 순회 — 끝이 나올 때까지 넘기기

긴 목록은 한 페이지에 다 안 실려 있습니다. “다음 페이지” 링크를 따라가야 합니다. 이 순회의 골격은 언제나 같습니다. 한 페이지를 수집하고 → 다음 링크를 찾고 → 없으면 멈춘다. while 루프 하나면 됩니다.

2-2. 누적과 중복 제거 — set의 활용

매일 실행하면 어제 모은 것을 오늘 또 모읍니다. 막는 법은 단순합니다. 시작할 때 기존 CSV를 읽어 “이미 있는 항목”의 집합(set)을 만들어 두고, 새로 수집한 것이 그 집합에 있으면 건너뜁니다. set은 “있냐 없냐” 질문에 번개같이 답하는 자료구조라 수만 건과 비교해도 빠릅니다.

수집 실패를 명확히 보고하기

교정 진입점은 실패를 기록하고 0이 아닌 상태로 종료합니다. 전체 순회를 성공한 뒤에만 행을 저장합니다. 요청 실패를 빈 목록으로 반환하면 실패와 정상적인 빈 결과가 구분되지 않습니다.

2-4. 스케줄링 — 컴퓨터의 알람 시계

정해진 시각에 프로그램을 자동 실행시키는 장치가 운영체제마다 있습니다.

  • 리눅스/WSL: cron(크론). crontab -e로 여는 시간표에 0 9 * * * 명령어라고 적으면 “매일 9시 0분에 실행”입니다. 다섯 칸은 분·시·일·월·요일이고, *는 “매번”입니다.
  • 윈도우: 작업 스케줄러(Task Scheduler). 시작 메뉴에서 “작업 스케줄러”를 찾아 “기본 작업 만들기”로 등록합니다.

어느 쪽이든 핵심 주의는 같습니다. 절대 경로를 써야 합니다. 자동 실행은 여러분이 파일을 더블클릭할 때와 달리 “어느 폴더에서 실행되는지”를 모르는 상태로 시작하기 때문입니다.


3. 따라 하기

3-1. 연습 사이트 짓기 — 내 컴퓨터 안의 미니 웹

외부 사이트 대신, 우리가 직접 지은 사이트를 수집 대상으로 삼습니다. 페이지 세 장짜리 “명언 연습장”입니다. 작업 폴더에 site라는 폴더를 만들고 그 안에 파일 세 개를 만드세요.

입력 (site/index.html)

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>명언 연습장 1페이지</title></head>
<body>
<h1>명언 연습장</h1>
<div class="quote">
  <span class="text">"The world is a book, and those who do not travel read only one page."</span>
  <small class="author">Saint Augustine</small>
</div>
<div class="quote">
  <span class="text">"Life is what happens when you're busy making other plans."</span>
  <small class="author">John Lennon</small>
</div>
<div class="quote">
  <span class="text">"보안은 제품이 아니라 과정이다."</span>
  <small class="author">Bruce Schneier</small>
</div>
<ul class="pager">
  <li class="next"><a href="/page2.html">Next →</a></li>
</ul>
</body>
</html>

입력 (site/page2.html)

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>명언 연습장 2페이지</title></head>
<body>
<h1>명언 연습장</h1>
<div class="quote">
  <span class="text">"Simplicity is the soul of efficiency."</span>
  <small class="author">Austin Freeman</small>
</div>
<div class="quote">
  <span class="text">"The only truly secure system is one that is powered off."</span>
  <small class="author">Gene Spafford</small>
</div>
<ul class="pager">
  <li class="next"><a href="/page3.html">Next →</a></li>
</ul>
</body>
</html>

입력 (site/page3.html) — 마지막 페이지라 “다음” 링크가 없는 것이 포인트입니다.

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>명언 연습장 3페이지</title></head>
<body>
<h1>명언 연습장</h1>
<div class="quote">
  <span class="text">"Talk is cheap. Show me the code."</span>
  <small class="author">Linus Torvalds</small>
</div>
<div class="quote">
  <span class="text">"공격자는 규칙을 모른다. 방어자는 규칙을 지켜야 한다."</span>
  <small class="author">익명</small>
</div>
<ul class="pager">
</ul>
</body>
</html>

이제 이 폴더를 웹사이트로 서빙합니다. site 폴더의 바로 위(작업 폴더)에서:

입력 (터미널 1 — 창을 닫지 마세요)

python -m http.server 8800 --bind 127.0.0.1 --directory site

출력 (2026-09-09 실측):

Serving HTTP on 0.0.0.0 port 8800 (http://0.0.0.0:8800/) ...

읽는 법: http.server는 파이썬에 내장된 미니 웹서버입니다. --directory site는 “site 폴더를 사이트 루트로 삼아라”라는 뜻이고, 8800은 포트 번호입니다. 브라우저에서 http://127.0.0.1:8800을 열어 명언이 보이면 성공입니다. python -m http.server를 site 폴더 안에서 실행했다면 --directory 없이도 됩니다.

왜: 크롤러를 배우다 실수로 남의 사이트에 부담을 주는 일을 원천 차단하는 구조입니다. 규칙이 바뀌는 일 없이, 몇 번이든 마음껏 수집해도 되는 우리만의 연습장이 생겼습니다.

3-2. 페이지 순회 크롤러 — 끝까지 넘기기

이 코드를 crawler_core.py로 저장하세요.

import time
from urllib.parse import urljoin, urlsplit
import requests
from bs4 import BeautifulSoup

BASE = "http://127.0.0.1:8800"


def collect_all(base=BASE, max_pages=20):
    origin = urlsplit(base)
    expected = (origin.scheme, origin.hostname, origin.port)
    if expected[0] != "http" or expected[1] != "127.0.0.1":
        raise ValueError("this exercise permits the owned loopback server only")
    rows, visited = [], set()
    url = base + "/"
    with requests.Session() as session:
        session.trust_env = False  # no environment proxy for this local lab
        while url:
            target = urlsplit(url)
            if (target.scheme, target.hostname, target.port) != expected:
                raise ValueError("next link leaves the permitted origin")
            if target.username or target.password:
                raise ValueError("credentials in URLs are not permitted")
            url = target._replace(fragment="").geturl()
            if url in visited or len(visited) >= max_pages:
                raise ValueError("pagination cycle or page limit")
            visited.add(url)
            with session.get(url, timeout=(2, 3), allow_redirects=False,
                             stream=True) as response:
                if response.status_code != 200:
                    raise ValueError(f"unexpected HTTP {response.status_code}")
                chunks, size = [], 0
                for chunk in response.iter_content(8192):
                    size += len(chunk)
                    if size > 262144:
                        raise ValueError("page exceeds 256 KiB")
                    chunks.append(chunk)
                body = b"".join(chunks).decode("utf-8", errors="strict")
            soup = BeautifulSoup(body, "html.parser")
            for quote in soup.select(".quote"):
                text = quote.select_one(".text")
                author = quote.select_one(".author")
                if text is not None and author is not None:
                    rows.append({"author": author.get_text(strip=True),
                                 "quote": text.get_text(strip=True)})
            nxt = soup.select_one("li.next a")
            href = nxt.get("href") if nxt else None
            url = urljoin(url, href) if href else None
            if url:
                time.sleep(0.2)
    return rows


if __name__ == "__main__":
    data = collect_all()
    print(f"Collected {len(data)} items")

입력 (터미널 2 — 서버는 켜 둔 채)

python crawler_core.py

출력 (2026-09-09 실측):

수집 7건

읽는 법: while 루프가 “다음 페이지 주소(url)가 있는 동안” 돕니다. 한 페이지를 수집하고 → li.next a에서 다음 주소를 찾고 → 0.2초 쉬고 → 다음 페이지로. 3페이지에는 다음 링크가 없으니 nxt가 None이 되어 반복이 끝납니다. 3 + 2 + 2 = 7건이 모인 것이 맞습니다.

왜: “수집하고, 다음을 찾고, 없으면 멈춘다” — 이 while의 모양은 모든 페이지네이션 크롤러에 그대로 재사용됩니다. 어제의 “한 페이지 수집”이 오늘 “끝까지 순회”가 됐습니다.

3-3. 예측해 보기 — 같은 명언이 두 번 수집될까

예측입니다. 사이트에 같은 명언이 두 페이지에 걸쳐 실려 있다면, data에는 어떻게 들어갈까요? 그리고 우리는 그것을 어떻게 알아챌 수 있을까요?

직접 확인: __main__에 한 줄을 추가해 보세요.

print("Deduplication check:", len(data), "->",
      len({(row["author"], row["quote"]) for row in data}))

출력 (2026-09-09 실측):

수집 7건
중복 제거 확인: 7 -> 7

읽는 법: 수집 건수와 set으로 중복을 제거한 건수가 같으면 “사이트 안에 중복이 없었다”는 뜻입니다. 다르게 나왔다면 set이 중복을 걸러 준 것이고요. 우리 연습장은 깨끗해서 7 -> 7입니다.

왜: “수집했다”와 “정리됐다”는 다릅니다. 이 한 줄의 검증 습관이 다음 단계의 CSV 누적에서 빛을 발합니다.

3-4. CSV 누적 저장과 중복 제거

CSV 함수를 같은 폴더의 두 번째 파일 crawler_archive.py에 저장하세요. 중복 기준은 (author, quote)이며 현재 배치 안의 중복도 제거합니다.

입력: crawler_archive.py

import csv
from pathlib import Path
from datetime import date

CSV_FILE = Path(__file__).resolve().with_name("quotes.csv")


def load_seen(path=CSV_FILE):
    if not path.exists():
        return set()
    with path.open(newline="", encoding="utf-8") as fp:
        return {(r["author"], r["quote"]) for r in csv.DictReader(fp)}


def save_new(rows, path=CSV_FILE):
    # One writer at a time; not a concurrent database replacement.
    seen = load_seen(path)
    fresh = []
    for row in rows:
        key = (row["author"], row["quote"])
        if key not in seen:
            seen.add(key)  # also deduplicate within this batch
            fresh.append(row)
    if not fresh:
        return 0
    new_file = not path.exists() or path.stat().st_size == 0
    with path.open("a", newline="", encoding="utf-8") as fp:
        writer = csv.writer(fp)
        if new_file:
            writer.writerow(["date", "author", "quote"])
        for row in fresh:
            writer.writerow([date.today().isoformat(),
                             row["author"], row["quote"]])
    return len(fresh)

첫 정상 실행을 위해 아래 import와 호출을 crawler_run.py에 저장하세요.

from crawler_core import collect_all
from crawler_archive import save_new

if __name__ == "__main__":
    rows = collect_all()
    print("New rows:", save_new(rows))

입력: 두 번 연달아 실행해 보세요.

python crawler_run.py
python crawler_run.py

출력 (2026-09-09 실측):

수집 7건, 새로 저장 7건
수집 7건, 새로 저장 0건

읽는 법: 첫 실행은 7건 전부 새 것이라 저장되고, 두 번째는 전부 이미 있는 것이라 0건입니다. load_seen이 기존 CSV를 set으로 읽어 두고, save_new가 그 set에 없는 것만 골라 파일 끝에 덧붙입니다("a"는 append, 이어 쓰기 모드).

왜: “수집 7, 저장 0″이라는 두 번째 출력이야말로 누적 시스템이 살아 있다는 증거입니다. 어제의 실행과 오늘의 실행이 CSV 파일을 통해 대화하고 있습니다. 실제로 quotes.csv를 열어 보면 헤더와 7줄이 들어 있습니다 (2026-09-09 실측):

날짜,저자,명언
2026-09-09,Saint Augustine,"""The world is a book, and those who do not travel read only one page."""
2026-09-09,John Lennon,"""Life is what happens when you're busy making other plans."""

(값 안의 따옴표가 두 번 겹쳐 보이는 것은 CSV의 표기 규칙입니다. 정상입니다.)

수집 실패를 명확히 보고하기

crawler_run.py를 아래 실패 보고 진입점으로 교체하고 나머지 두 파일을 유지하세요.

입력: crawler_run.py 전체 교체

from pathlib import Path
from datetime import datetime, timezone
import requests
from crawler_core import collect_all
from crawler_archive import save_new


def main():
    try:
        rows = collect_all()
        print("New rows:", save_new(rows))
        return 0
    except (requests.RequestException, OSError, ValueError) as exc:
        path = Path(__file__).resolve().with_name("crawler_error.log")
        with path.open("a", encoding="utf-8") as fp:
            fp.write(f"{datetime.now(timezone.utc).isoformat()} "
                     f"collection failed: {type(exc).__name__}\n")
        print("Collection failed; no empty-success result was saved")
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

실습 서버를 멈추고 python crawler_run.py를 실행한 뒤 0이 아닌 종료 상태와 오류 로그를 확인하세요. 수집 실패를 정상적인 빈 수집으로 기록하면 안 됩니다.

서버를 멈췄을 때 예상 출력 형식이며 정확한 오류 종류는 환경에 따라 다릅니다.

Collection failed; no empty-success result was saved

프로그램은 종료 상태 1을 반환하고 오류 종류를 crawler_error.log에 기록합니다.

2026-09-09 수집 실패: ConnectionError: HTTPConnectionPool(host='127.0.0.1', port=8800): Max retries exceeded with url: / (Caused by NewConnectionError(... [WinError 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다"))

진입점은 지원하는 오류를 처리하고 실패 상태로 종료합니다. 전체 순회가 성공한 뒤에만 save_new를 실행하므로 네트워크 실패를 성공한 빈 결과로 추가하지 않습니다.

왜: 관리되지 않는 자동화는 언젠가 조용히 멈춰 있고 아무도 모릅니다. 로그는 그 침묵을 깨는 장치입니다. 이 한 함수가 “장난감”과 “도구”를 가릅니다.

3-6. 자동 실행 등록 — 내일 아침의 나를 위해

마지막 조각 ⑤입니다. 운영체제별로 하나만 하면 됩니다. 등록 자체는 여러분의 시계와 계정에 달린 일이라, 아래 출력은 출력 예시입니다.

리눅스(WSL 포함) — 입력

crontab -e

입력 내용 (파일 맨 아래 줄에 추가)

0 9 * * * cd /absolute/lab && /absolute/lab/.venv/bin/python crawler_run.py >> crawler_run.log 2>&1

두 절대 경로를 자신의 실습 폴더와 Python 환경으로 바꾸고 세 스크립트를 함께 두세요. CSV·오류 로그는 스크립트 폴더 기준입니다. CSV 예제에는 프로세스 간 쓰기 잠금이 없으므로 동시 예약 실행을 허용하지 마세요. WSL 예약 실행에는 배포판과 스케줄러도 실행 중이어야 합니다.

Windows 작업 스케줄러에서 실습 환경의 Python 실행 파일을 지정하고 crawler_run.py의 절대 경로를 인수로 전달하세요. crawler_core.py와 crawler_archive.py를 옆에 둡니다. 작업 폴더를 명시하고 중복 실행을 막으며 종료 상태와 로그를 확인하세요.

왜: 등록하고 나면 내일 아침, 키보드를 만지지 않아도 프로그램이 일합니다. 등록 후에는 시각을 2~3분 뒤로 바꿔 “정말 혼자 도는가”를 테스트하고, 확인 후 원래 시각으로 돌려 두는 것이 정석입니다. 테스트가 끝나면 연습 사이트 서버도 꺼 두세요 — 자동 실행 시점에 서버가 꺼져 있으면 방금 만든 오류 격리와 로그가 대신 일해 줍니다.


4. 미션과 연습문제

미션 — 수집기 완성과 변종 만들기

  1. 세 크롤러 파일을 완성하고 CSV 중복 제거·실패 로그를 확인한 뒤 필요하면 단일 예약 실행을 구성하고 확인하세요.
  2. site 폴더에 page4.html을 추가하고(3페이지의 pager에 /page4.html 링크 연결), 크롤러를 다시 실행해 “새 페이지의 명언만” CSV에 추가되는지 확인합니다
  3. quotes.csv를 읽어 “저자별 명언 수”를 출력하는 별도 스크립트 stats.py를 작성합니다
  4. 실행한 명령과 관찰 결과(몇 건이 새로 저장됐는지)를 README에 기록합니다

연습문제

문제 1. 페이지 순회 while 루프가 멈추는 조건은 무엇이며, 우리 연습 사이트의 어느 부분이 그 조건을 만들어 주나요?

문제 2. 두 번 실행했을 때 “새로 저장 0건”이 나오는 원리를 load_seen과 save_new의 역할로 설명해 보세요.

문제 3. 자동 실행에서 절대 경로를 써야 하는 이유를 “실행 폴더”라는 관점에서 설명해 보세요.

문제 4. 오류 격리를 하지 않은 크롤러를 cron에 등록하면 어떤 일이 벌어지는지, “조용한 실패”라는 표현과 함께 설명해 보세요.


해설과 완료 기준 · 펼치기/접기

5. 모범 답안과 완료 기준

미션 모범 답안

page4.html 추가와 pager 연결 후 실행하면, 3페이지까지의 7건은 이미 있으니 새 페이지의 명언만 저장됩니다. 4페이지에 명언 2건을 넣었다면 “수집 9건, 새로 저장 2건”이 뜨는 것이 정답입니다 — 수집은 전체를 다시 하되, 저장은 새 것만 한다는 누적의 의미가 정확히 드러납니다.

stats.py의 뼈대:

import csv
from collections import Counter

with open("quotes.csv", newline="", encoding="utf-8") as fp:
    rows = list(csv.DictReader(fp))

counts = Counter(row["저자"] for row in rows)
for author, n in counts.most_common():
    print(f"{author}: {n}건")

실행하면 Saint Augustine: 1건 같은 식으로 저자별 건수가 나옵니다. 우리 연습장 데이터로는 전원 1건씩입니다.

검증하는 법: ① page4 추가 후 재실행에서 새 건수가 정확한가. ② quotes.csv의 줄 수가 헤더 + 누적 건수와 일치하는가. ③ 서버를 끄고 실행해도 죽지 않고 로그가 쌓이는가. ④ crontab 또는 작업 스케줄러의 등록 내용에 절대 경로만 있는가. 넷이 전부 ‘예’이면 완성입니다.

연습문제 해답

문제 1 해답. “다음 페이지 링크가 없는 것”이 멈춤 조건입니다. soup.select_one("li.next a")가 None을 돌려주면 url이 None이 되어 while이 끝납니다. 우리 연습 사이트에서는 page3.html의 비어 있는 <ul class="pager">가 그 조건을 만들어 줍니다 — 3페이지에는 li.next가 없습니다.

문제 2 해답. load_seen이 기존 quotes.csv를 읽어 “이미 저장된 명언”의 set을 만듭니다. save_new는 새로 수집한 목록에서 그 set에 이미 있는 것을 걸러내고(fresh), 남은 것만 파일에 덧붙입니다. 두 번째 실행에서는 수집된 7건 전부가 set에 있으므로 fresh가 비어 0건이 됩니다.

Q3: 진입점의 상대 경로는 스케줄러의 작업 폴더에 의존합니다. 교정한 CSV·로그 경로는 스크립트 폴더를 기준으로 합니다. 인터프리터·진입점은 절대 경로로 지정하고 나머지 상대 자원을 위한 작업 폴더도 정하세요.

Q4: 무인 작업에는 관찰 가능한 실패 상태가 필요합니다. 교정 진입점은 예외 종류를 기록하고 0이 아닌 종료 상태를 반환합니다. 스케줄러 상태·로그를 확인하세요. 오류를 삼키고 성공으로 보고하면 장애가 숨겨집니다.

완료 기준 체크리스트

  • [ ] python -m http.server로 로컬 연습 사이트를 띄울 수 있다
  • [ ] 페이지 순회 while 루프의 골격(수집→다음 찾기→없으면 멈춤)을 설명할 수 있다
  • [ ] 두 번 실행해 “새로 저장 0건”을 확인했다
  • [ ] 서버를 끈 상태에서 실행해 오류 로그가 남는 것을 확인했다
  • [ ] cron 또는 작업 스케줄러에 자동 실행을 등록하고 절대 경로를 썼다
  • [ ] 미션: page4 증분 저장과 저자별 집계까지 완료했다

6. 흔한 실수와 해결

벽 1. 크롤러가 ConnectionError로 죽는다

증상 (2026-09-09 실측):

requests.exceptions.ConnectionError: HTTPConnectionPool(host='127.0.0.1', port=8800): Max retries exceeded with url: / ... [WinError 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다

자신의 루프백 서버가 실행 중인지 확인한 뒤 crawler_run.py를 다시 실행하세요. 실패는 로그와 실패 종료 상태로 남기며 정상 데이터가 0건이라고 가장하지 않습니다.

벽 2. 서버가 “주소가 이미 사용 중”이라며 안 켜진다

증상 (2026-09-09 실측):

OSError: [WinError 10048] 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다

원인: 이전에 띄운 http.server가 8800번을 아직 붙들고 있습니다. Ctrl+C로 안 끝낸 창이 어딘가 살아 있는 것입니다.
해결: 서버 창을 찾아 끄거나, 다른 포트(8801 등)로 바꾸세요 — 크롤러의 BASE 포트도 같이 바꾸는 것을 잊지 마세요.

벽 3. CSV를 엑셀에서 열면 한글이 깨진다

Excel에서는 텍스트/CSV 가져오기로 UTF-8 파일을 여세요. BOM이 있는 UTF-8로 바꾸려면 읽기·쓰기를 모두 utf-8-sig로 맞추고 기존 파일도 일관되게 처리해야 합니다. 쓰기만 바꾸면 헤더 기반 중복 제거가 깨질 수 있습니다. Excel 동작은 버전·가져오기 방식에 따라 다릅니다.

벽 4. 중복이 계속 쌓인다

증상: 두 번 실행했는데도 같은 명언이 또 저장됩니다.
원인: 비교 기준과 저장 값이 미세하게 다릅니다. 수집 때 strip()을 빼먹어 앞뒤 공백이 붙은 채 저장되면, 다음 실행에서 “공백 없는 값”과 비교되어 다른 것 취급됩니다.
해결: 수집 단계의 strip을 확인하고, load_seen이 읽는 컬럼(“명언”)과 저장하는 컬럼이 같은 기준인지 점검하세요.

벽 5. cron이 조용히 아무것도 안 한다

증상: 등록했는데 시간이 지나도 CSV가 안 바뀝니다.
원인: 대부분 경로 문제입니다. cron은 여러분의 폴더 위치를 모른 채 실행됩니다.
해결: 스크립트 경로뿐 아니라 스크립트 안의 파일 경로(CSV_FILE, 로그)도 전부 절대 경로로 바꾸고, 크론 줄 끝에 >> 로그파일 2>&1를 붙여 조용한 실패에 귀를 달아 두세요. 등록 직후 시각을 2~3분 뒤로 당겨 시험하는 것이 확인의 정석입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
페이지네이션 긴 목록을 여러 페이지로 나누는 방식 — “다음” 링크를 따라 순회
누적 저장 기존 저장고를 읽어 새 것만 덧붙이기 (set + append)
중복 제거 set의 “있냐 없냐” 판정으로 이미 모은 것 건너뛰기
오류 격리 try/except + 로그 — 실패해도 죽지 않고 기록을 남김
스케줄링 cron(리눅스) / 작업 스케줄러(윈도우) — 절대 경로가 생명줄
조용한 실패 로그 없는 자동화가 멈춘 채 아무도 모르는 상태 — 로그로 예방

오늘의 명령어와 함수

명령/함수 하는 일
python -m http.server 8800 --bind 127.0.0.1 --directory site 폴더를 로컬 웹사이트로 서빙
soup.select(".quote") / select_one("li.next a") 명언 블록 / 다음 페이지 링크 찾기
csv.DictReader / csv.writer CSV 읽기(사전처럼) / 쓰기
open(..., "a", ...) 파일 끝에 이어 쓰기(append)
date.today().isoformat() 오늘 날짜를 “2026-09-09” 형태로
crontab -e 리눅스 자동 실행 시간표 편집

명령어보다 중요한 감각

오늘 완성한 골격 — 순회, 누적, 중복 제거, 오류 격리, 자동 실행 — 은 대상만 바꾸면 “위협 정보 수집기”가 됩니다. 보안 공지, CVE 갱신, 우리 조직 관련 언급 모니터링이 전부 이 다섯 조각 위에 서 있습니다. 반대로 이 골격이 허락 없이 남의 서비스에 향하면 부담과 침해가 되므로, 수집 대상은 언제나 “수집해도 되는 곳”(오늘처럼 내가 지은 연습장, robots.txt와 이용약관이 허락하는 곳)이어야 합니다.

자동화의 완성은 “돌아가게 만드는 것”이 아니라 “확인하는 습관”까지 포함합니다. 내일 아침 quotes.csv를 열어 줄 수를 보고, crawler_error.log가 비어 있는지 보는 그 10초가 자동화의 주인의 일입니다. 그리고 데이터가 수만 건으로 자라 검색이 필요해지면, 그때가 CSV를 데이터베이스(이 책 뒤쪽의 SQLite)로 갈아탈 시기입니다 — 골격은 같고 저장고만 바뀝니다.


전부 체크되면 Step 76 완료입니다.

한 단계 더 가까이

수업을 마쳤나요?

본문의 완료 기준을 확인한 뒤 직접 표시하세요.

화면이나 링크에 문제가 있나요? 알려주세요.
다음 수업