스크래퍼 견고하게 만들기: 레이아웃 변경, 알람, 재시도

사이트 레이아웃이 바뀌면 스크래퍼는 조용히 망가진다. 스키마 검사와 0건 알람, 헤더가 거짓말하는 인코딩 판별, 상한 있는 재시도, 메트릭과 로그를 연습 사이트에서 실측하고 시리즈를 마무리한다.

시리즈: 웹 스크래핑 강좌 6/6 (고급). 5강처럼 정기 실행되는 스크래퍼를 운영하면서, 망가진 걸 3주 뒤가 아니라 그날 알고 싶은 사람을 위한 회차입니다. 다 읽고 나면 스키마 검사와 0건 알람으로 레이아웃 변경을 잡아내고, 잘못된 문자 인코딩에 대처하고, 무엇을 재시도하고 무엇을 포기할지 정하고, 실행마다 메트릭 한 줄을 남길 수 있습니다.

위험한 건 죽는 스크래퍼가 아닙니다. 사이트 개편 뒤에도 계속 돌면서 매일 아침 "변화 0건"을 보고하고, 가격 비교 대시보드가 조용히 멈춰 버리게 만드는 스크래퍼입니다. 이 글은 그 대시보드를 맡은 사람을 위한 글이고, 끝에 시리즈 전체 체크리스트가 있습니다.

아래 실행은 모두 Apple Silicon Mac (M1 Pro), Python 3.9.6 표준 라이브러리만, 그리고 시리즈의 연습용 쇼핑몰을 띄운 PHP 8.5.6(php -S 127.0.0.1:18172)으로 했습니다. 이 쇼핑몰은 일부러 레이아웃을 바꾸고, 깨진 인코딩과 불안정한 응답을 돌려줄 수 있습니다. 경로는 ~/demo/ 로 줄였습니다.

조용한 실패를 재현하다

연습 쇼핑몰에는 상태 파일로 고를 수 있는 레이아웃 세 가지가 있습니다.

  • v1: <li class="product"> 와 <span class="price">. 파서가 기대하는 형태입니다.
  • v2: 개편. 상품은 모두 <article class="card" data-id="..."> 가 되고 가격은 <span class="amount"> 로.
  • v1b: 작은 변경. 가격 클래스 이름만 price-now 로 바뀌었습니다.

쇼핑몰을 v2 로 바꾸고 5강의 감시 스크립트를 그대로 돌렸습니다.

$ echo v2 > ~/demo/shop/state/layout
$ python3 shopwatch.py; echo "exit=$?"
2026-10-31T03:00:00Z pages=10 items=0 changes=0 secs=9.1
exit=0

10페이지를 가져왔고, 상품 0건, 변화 0건, 종료 코드 0. Python 입장에서는 아무 문제가 없습니다. "다음" 링크가 남아 있어서 페이지 넘기기는 잘 됐고, html.parser 는 <li class="product"> 를 한 번도 못 만났을 뿐입니다. cron 은 성공으로 봅니다. DB 에는 어제 가격이 아무 일 없었다는 듯 남아 있습니다.

v1b 는 반대로 시끄럽게 죽습니다. normalize() 가 가격으로 None 을 받기 때문입니다.

  File "~/demo/shopwatch/shopwatch.py", line 46, in normalize
    price = raw["price"].strip().lstrip("$").replace(",", "")
AttributeError: 'NoneType' object has no attribute 'strip'

사실 이쪽이 더 나은 결과입니다. 그리고 여기서 가장 흔한 실수가 나옵니다.

흔한 실수: 기본값으로 크래시를 "고친다"

손이 가는 수정은 한 줄입니다. price = (raw["price"] or "$0.00").strip(). 트레이스백은 사라집니다. v1b 에서 다음 실행 결과가 이렇습니다.

CHANGE SKU-0001 price 62.79 -> 0.00
CHANGE SKU-0002 price 76.09 -> 0.00
CHANGE SKU-0003 price 28.99 -> 0.00
...
2026-10-31T03:00:00Z pages=10 items=200 changes=200 secs=9.1
exit=0

가짜 가격 인하 200건이 이력에 기록되고 종료 코드는 0. 이 알림을 보고 움직인 사람은 쓰레기 데이터로 판단하게 되고, 이력은 청소가 필요해집니다. 올바른 해결책은 기본값의 정반대입니다. 완전히 읽지 못한 레코드는 받지 않고, 세고, 그 수가 0이 아니면 실행을 실패로 만든다.

스키마 검사와 0건 알람

흐름도. 429·503·타임아웃은 재시도하며 가져오고, 헤더 charset, meta charset, UTF-8 순서로 디코드한 다음 레코드를 추출한다. 각 레코드는 SKU 형식, 비어 있지 않은 이름, 가격 형식, 재고 값이라는 스키마로 검사하고 실패는 불량 건수로 센다. 그다음 실행 전체를 검사해 0건, 예상보다 적은 건수, 불량 레코드, 가져오기 오류 중 하나라도 있으면 알람을 내고 종료 코드 2로 끝난다. 없으면 저장하고 0으로 끝난다. 매 실행마다 JSON 메트릭 한 줄을 쓴다.
각 검사가 실행의 어디에 들어가는가. 레코드 검사는 이름이 바뀐 필드를, 실행 단위 검사는 갑자기 아무것도 안 나오는 페이지를 잡는다.

robust.py 는 5강의 감시 스크립트(같은 폴더의 shopwatch.py 를 import)를 네 가지 아이디어로 감쌉니다. 상품마다 하는 레코드 검사, 실행 전체에 대한 실행 검사, 0이 아닌 종료 코드, 그리고 JSON 메트릭 한 줄입니다.

"""robust.py - lecture 6: the lecture 5 watcher plus checks that make failures loud.
Python 3.9+ stdlib. Needs shopwatch.py (lecture 5) in the same folder. Local practice shop only.
"""
import json, logging, os, random, re, socket, sqlite3, sys, time
import urllib.error, urllib.request
from email.utils import parsedate_to_datetime
from urllib.parse import urljoin
import shopwatch as sw

logging.Formatter.converter = time.gmtime                     # log in UTC
logging.basicConfig(format="%(asctime)sZ %(levelname)s %(message)s",
                    datefmt="%Y-%m-%dT%H:%M:%S", level=logging.INFO)
log = logging.getLogger("shopwatch")
RETRY_STATUS = {429, 500, 502, 503, 504}
rng = random.Random(42)                                        # fixed seed for the jitter

class GiveUp(Exception):
    pass

def retry_after(value):
    if not value: return None
    if value.isdigit(): return int(value)
    try: return max(0, parsedate_to_datetime(value).timestamp() - time.time())
    except (TypeError, ValueError): return None

def fetch_bytes(url, retries=3, timeout=2, metrics=None):
    for attempt in range(retries + 1):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": sw.UA})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return r.read(), r.headers
        except urllib.error.HTTPError as e:
            metrics["http_" + str(e.code)] = metrics.get("http_" + str(e.code), 0) + 1
            if e.code not in RETRY_STATUS: raise GiveUp(f"{e.code} {url}")   # 403/404: retrying won't help
            wait = retry_after(e.headers.get("Retry-After"))
        except (urllib.error.URLError, socket.timeout) as e:
            metrics["net_errors"] = metrics.get("net_errors", 0) + 1
            wait = None
        if attempt == retries: raise GiveUp(f"gave up after {retries + 1} tries: {url}")
        wait = min(60, wait if wait is not None else 2 ** attempt + rng.uniform(0, 0.5))
        metrics["retries"] = metrics.get("retries", 0) + 1
        log.warning("retry %d for %s in %.1fs", attempt + 1, url, wait)
        time.sleep(wait)

META = re.compile(rb'<meta[^>]+charset=["\']?([A-Za-z0-9_-]+)', re.I)

def decode(body, headers, metrics):
    meta = META.search(body[:2048])
    tried = [headers.get_content_charset(),                   # 1. HTTP header (can lie)
             meta.group(1).decode() if meta else None,        # 2. <meta charset> near the top
             "utf-8"]                                         # 3. default
    for i, cs in enumerate(c for c in tried if c):
        try:
            text = body.decode(cs)
        except (UnicodeDecodeError, LookupError):
            continue
        if i:  # the first candidate failed: count it so the run report shows it
            metrics["encoding_fallback"] = metrics.get("encoding_fallback", 0) + 1
            log.warning("header charset did not fit the bytes; decoded as %s", cs)
        return text
    metrics["encoding_fallback"] = metrics.get("encoding_fallback", 0) + 1
    log.warning("no declared charset fits; decoding as cp1252")
    return body.decode("cp1252", errors="replace")

SKU = re.compile(r"^SKU-\d{4}$")
def check_item(raw):
    """Return a list of problems with one raw record (empty list = OK)."""
    p = []
    if not SKU.match(raw.get("sku") or ""): p.append("sku")
    if not (raw.get("name") or "").strip(): p.append("name")
    if not re.fullmatch(r"\$\d{1,3}(,\d{3})*\.\d{2}", (raw.get("price") or "").strip()): p.append("price")
    if (raw.get("stock") or "").strip() not in ("In stock", "Sold out"): p.append("stock")
    return p

def run(start, expected_min, max_pages=50, retries=3):
    m = {"pages": 0, "items": 0, "bad_items": 0, "errors": 0}
    t0, url, good = time.perf_counter(), start, []
    while url and m["pages"] < max_pages:
        try:
            body, headers = fetch_bytes(url, retries=retries, metrics=m)
        except GiveUp as e:                                   # without this page we have no "next" link
            log.error("%s - stopping this run", e); m["errors"] += 1
            break
        p = sw.ListParser(); p.feed(decode(body, headers, m)); m["pages"] += 1
        for raw in p.items:
            problems = check_item(raw)
            if problems:
                m["bad_items"] += 1
                if m["bad_items"] <= 3: log.error("bad record %s: %s", raw.get("sku"), ",".join(problems))
            else:
                good.append(sw.normalize(raw, url))
        m["items"] += len(p.items)
        url = urljoin(url, p.next) if p.next else None
        if url: time.sleep(0.2)                               # local demo; respect Crawl-delay for real sites
    m["secs"] = round(time.perf_counter() - t0, 2)
    alarms = []
    if m["items"] == 0: alarms.append("ZERO_ITEMS")
    elif m["items"] < expected_min: alarms.append(f"FEW_ITEMS<{expected_min}")
    if m["bad_items"]: alarms.append(f"BAD_ITEMS={m['bad_items']}")
    if m["errors"]: alarms.append(f"FETCH_ERRORS={m['errors']}")
    m["alarms"] = alarms
    print(json.dumps(m, sort_keys=True))                      # one metrics line per run
    return good, alarms

if __name__ == "__main__":
    start = sys.argv[1] if len(sys.argv) > 1 else sw.BASE + "/products?page=1"
    good, alarms = run(start, expected_min=int(sys.argv[2]) if len(sys.argv) > 2 else 150)
    if alarms:
        log.error("ALARM %s - nothing stored, yesterday's data kept", " ".join(alarms))
        sys.exit(2)                                           # cron + monitoring see a non-zero exit
    db = sqlite3.connect(os.environ.get("SHOPWATCH_DB", "shop.db"), timeout=5)
    db.executescript(sw.SCHEMA)
    log.info("stored %d items, %d changes", len(good), len(sw.store(db, good, sw.utc_now())))

(측정을 짧게 하려고 데모에서는 페이지 사이에 0.2초만 기다립니다. 남의 사이트라면 5강처럼 robots.txt 의 Crawl-delay, 최소 1초를 지키세요.)

같은 세 레이아웃을 robust.py 로 돌렸습니다.

# v1 (정상)
{"alarms": [], "bad_items": 0, "errors": 0, "items": 200, "pages": 10, "secs": 1.98}
2026-11-14T23:31:23Z INFO stored 200 items, 0 changes
exit=0

# v2 (개편)
{"alarms": ["ZERO_ITEMS"], "bad_items": 0, "errors": 0, "items": 0, "pages": 10, "secs": 1.94}
2026-11-14T23:31:25Z ERROR ALARM ZERO_ITEMS - nothing stored, yesterday's data kept
exit=2

# v1b (가격 클래스 이름만 변경)
2026-11-14T23:31:26Z ERROR bad record SKU-0001: price
2026-11-14T23:31:26Z ERROR bad record SKU-0002: price
2026-11-14T23:31:26Z ERROR bad record SKU-0003: price
{"alarms": ["BAD_ITEMS=200"], "bad_items": 200, "errors": 0, "items": 200, "pages": 10, "secs": 1.96}
2026-11-14T23:31:28Z ERROR ALARM BAD_ITEMS=200 - nothing stored, yesterday's data kept
exit=2

망가진 두 레이아웃 모두 잡혔고, 잘못된 데이터는 DB 에 들어가지 않았고, 종료 코드가 cron 에 전달됩니다. 비용은? 실제 레코드 200개에 check_item() 을 1,000번 반복해 5회 잰 중앙값은 레코드당 0.99 µs 였습니다. normalize() 는 약 6.7 µs, 예의 있는 대기 시간은 페이지당 약 1초입니다. 검사는 사실상 공짜입니다.

expected_min 정하는 법: 자기 메트릭 줄에 남은 건수에서 출발합니다. 상품 200개짜리 쇼핑몰에 150을 썼습니다. 이전 건수를 저장해 두면 "어제의 절반 미만" 같은 기준도 쓸 수 있습니다. 그리고 레이아웃마다 저장한 HTML 한 장씩을 테스트 픽스처로 두세요. 저장한 페이지로 파서를 돌리는 단위 테스트가 있으면, 배포 전에 파서와 사이트가 아직 맞는지 알 수 있습니다.

인코딩 변종: 헤더가 거짓말할 때

공공기관이나 오래된 사이트에서는 아직 EUC-KR 페이지를 만날 수 있고, 같은 문제가 그대로 생깁니다. 연습 쇼핑몰은 일부러 깨진 페이지 두 개를 줍니다. /enc/sjis 는 Shift_JIS 로 된 일본어 페이지이고 선언은 <meta charset="Shift_JIS"> 뿐입니다. PHP 에서 charset 을 보내지 않았는데도 헤더는 Content-Type: text/html;charset=UTF-8 로 도착했습니다. PHP 의 default_charset 설정이 붙인 것입니다. /enc/latin1 은 windows-1252 로 된 스페인어 페이지인데 헤더는 utf-8 이라고 합니다. 5강의 fetch() 는 헤더를 믿습니다.

UnicodeDecodeError: 'utf-8' codec can't decode byte 0x83 in position 60: invalid start byte
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 134: invalid continuation byte

흔한 응급처치인 errors="replace" 는 이걸 조용한 손상으로 바꿉니다.

['��ド�C�g', '�ۉ��}�O', '����m�[�g�i�`�T�j']
['Caf� molido', 'Pi�ata', 'Jalape�o en lata']

위의 decode() 는 헤더, <meta> charset, UTF-8 순으로 시도하고 대체 경로로 갈 때마다 셉니다.

WARNING header charset did not fit the bytes; decoded as Shift_JIS
WARNING no declared charset fits; decoding as cp1252
/enc/sjis ['卓上ライト', '保温マグ', '方眼ノート(A5)'] {'encoding_fallback': 1}
/enc/latin1 ['Café molido', 'Piñata', 'Jalapeño en lata'] {'encoding_fallback': 1}

encoding_fallback 이 0이 아닌 실행은 그래도 한번 들여다봐야 합니다. cp1252 는 추측일 뿐입니다. 이론과 이미 깨진 문자열 복구는 깨진 글자(모지바케) 디코드, 인코드, 복구와 문자 인코딩 판별 실측에서 다룹니다.

재시도할까, 포기할까

판단 그림. 429 나 503 이면 Retry-After 또는 지터를 섞은 지수 백오프만큼 기다리고 최대 3번 재시도한다. 타임아웃이나 연결 거부도 백오프 후 최대 3번. 403·404·400 은 재시도해도 소용없으니 바로 포기한다. 파싱이나 스키마 실패는 재시도하지 않고 알람을 내며 이전 데이터를 남긴다. 재시도를 다 쓰면 실행을 멈추고 알람을 낸다.
재시도할까, 포기할까. 시간이 해결할 수 있는 것만 횟수를 정해 재시도하고, 나머지는 멈추고 사람에게 알린다.

규칙은 시간이 해결할 수 있는 것만, 상한을 두고 재시도한다입니다. 429 나 503 은 "지금은 안 된다"는 뜻입니다. Retry-After 가 있으면(초 단위 또는 HTTP 날짜) 따르고, 없으면 지수 백오프에 약간의 무작위 지터를 더해 여러 클라이언트가 동시에 재시도하지 않게 합니다(3강에서 실측했습니다). 403 이나 404 는 2초 뒤에도 그대로이고, 파싱 실패는 영원히 그대로입니다. 특히 403 은 "이 봇은 사양한다"는 뜻일 수 있습니다. 올바른 반응은 멈추고 약관을 읽는 것이지, 더 세게 시도하는 것이 아닙니다.

$ python3 robust.py 'http://127.0.0.1:18172/gone/products?page=1'
2026-11-14T23:33:01Z ERROR 403 http://127.0.0.1:18172/gone/products?page=1 - stopping this run
{"alarms": ["ZERO_ITEMS", "FETCH_ERRORS=1"], "bad_items": 0, "errors": 1, "http_403": 1, "items": 0, "pages": 0, "secs": 0.01}
2026-11-14T23:33:01Z ERROR ALARM ZERO_ITEMS FETCH_ERRORS=1 - nothing stored, yesterday's data kept
$ echo $?
2

득실을 재기 위해 /flaky/products 를 썼습니다. 3번에 1번은 Retry-After: 1 이 붙은 503, 7번에 1번은 3초 동안 멈추고, 클라이언트 타임아웃은 2초입니다. 매 실행 전에 요청 카운터를 0으로 돌리므로 실패 패턴은 매번 같습니다. 10페이지, 설정마다 3회.

재시도 건진 상품 실행 시간(중앙값) 일어난 일
0 40 / 200 0.43초 3페이지의 첫 503 에서 멈춤
1 80 / 200 5.90초 503 뒤 타임아웃으로 한 번뿐인 재시도를 다 씀
3까지 200 / 200 14.34초 503 5번, 타임아웃 2번에 대해 재시도 7번
막대그래프. 재시도 없음은 첫 실패에서 멈춰 200건 중 40건을 0.43초에 얻었다. 재시도 1회는 80건을 5.90초. 최대 3회 재시도는 503 응답 5번과 타임아웃 2번에 대한 재시도 7번 끝에 200건 전부를 14.34초에 얻었다.
불안정한 연습용 엔드포인트(3번에 1번 503, 7번에 1번 3초 멈춤, 클라이언트 타임아웃 2초)에서 건진 상품 수. 10페이지, 설정마다 3회, 결과 동일. 실행 시간 중앙값 0.43초, 5.90초, 14.34초.

3회 재시도 실행 로그에 대기 시간이 보입니다. Retry-After: 1 이면 1.0초, 헤더 없는 타임아웃에는 지터 섞인 백오프(두 번째 시도에서 2.3초)입니다.

WARNING retry 1 for http://127.0.0.1:18172/flaky/products?page=3 in 1.0s
WARNING retry 1 for http://127.0.0.1:18172/flaky/products?page=5 in 1.0s
WARNING retry 2 for http://127.0.0.1:18172/flaky/products?page=5 in 2.3s
...
{"alarms": [], "bad_items": 0, "errors": 0, "http_503": 5, "items": 200, "net_errors": 2, "pages": 10, "retries": 7, "secs": 14.33}

목록 페이지 하나가 실패했다고 왜 실행 전체를 멈출까요? "다음" 링크가 그 페이지에 있기 때문입니다. page=N+1 을 추측하면 연습 쇼핑몰에서는 되지만, 실제 사이트에서는 링크된 적 없는 URL 로 들어갈 수 있습니다. DB 에서 꺼낸 상세 페이지처럼 서로 독립된 URL 이라면 연속 실패를 세다가, 예를 들어 세 번 연속이면 멈춥니다. 사이트가 죽어 있을 때 계속 두드려 봐야 아무에게도 득이 없습니다.

누군가 읽게 될 로그와 메트릭

  • 실행마다 JSON 한 줄. 페이지 수, 건수, 불량 건수, HTTP 상태별 횟수, 재시도, 인코딩 대체, 소요 시간, 알람. metrics.jsonl 에 덧붙여 두면 "실행별 건수" 그래프는 Python 몇 줄로 그릴 수 있고, 이미 쓰는 모니터링 도구로 보낼 수도 있습니다.
  • 로그 타임스탬프는 UTC(logging.Formatter.converter = time.gmtime 과 Z). 5강 데이터와 같은 시계입니다.
  • 종료 코드를 약속으로. 0 = 저장 완료, 2 = 알람, 그 밖 = 크래시. cron 은 비어 있지 않은 출력을 메일로 보낼 수 있고, 래퍼 스크립트로 종료 코드 2를 메신저 알림으로 바꿀 수도 있습니다. 래퍼 쪽은 셸 스크립트 견고하게 만들기: cron 과 로그를 보세요.
  • 불량 레코드는 전부가 아니라 처음 몇 건만 로그에. 세 건이면 "깨진 건 price" 라는 걸 알 수 있습니다.
  • 알람은 드물고 진짜여야 합니다. 매일 울리는 알람은 무시됩니다. 누군가의 휴대폰에 연결하기 전에 일주일치 메트릭으로 기준값을 다듬으세요.

자바스크립트로 그려지는 페이지: JSON 부터 찾는다

거의 빈 채로 도착해서 브라우저 안에서 스스로를 채우는 페이지가 있습니다. 연습 쇼핑몰의 /app 이 그렇습니다.

/app bytes: 339 items found: 0 | root text: Loading...
/api/products: pages 10 items 20 {'sku': 'SKU-0001', 'name': 'Desk lamp 1', 'price_cents': 6279, 'in_stock': True}

urllib 이 받는 건 339바이트짜리 껍데기와 "Loading..." 이라는 글자뿐입니다. 페이지의 스크립트는 /api/products?page=1 을 부릅니다. 실제 브라우저라면 개발자 도구의 네트워크 탭(Fetch/XHR 로 필터)에서 그 호출을 찾아 JSON 을 바로 읽을 수 있습니다. 구조화돼 있고 작고, HTML 파싱이 필요 없고, 가격은 처음부터 센트 단위입니다. 여기서는 브라우저를 쓰지 않았으므로 이 단계는 설명만 하고 실행하지 않았습니다. 그런 엔드포인트를 쓰기 전에 약관을 확인하세요. 공개되지 않은 엔드포인트는 초대장이 아니고, 공공데이터라면 같은 데이터가 공공데이터포털에 API 로 올라와 있는 경우가 많습니다.

헤드리스 브라우저(Playwright, Selenium)는 페이지의 자바스크립트를 대신 실행해 줍니다. 여기서는 설치도 실행도 하지 않았습니다. 워커마다 브라우저 하나만큼의 비용이 들고, 몇 자릿수 느리고, 상대 서버에는 무거운 방문자로 보입니다. 기본 수단이 아니라 마지막 수단으로 다루세요.

시리즈 정리: 체크리스트와 윤리 복습

스크래퍼를 정기 실행에 올리기 전에.

확인할 것 다룬 강의
공식 API, 피드, 내보내기가 있는가. 있으면 그걸 쓴다 1강
robots.txt 가 그 경로를 허용하는가. Crawl-delay 는? 1강
약관이 자동 접근과 저장을 허용하는가. 사이트 약관과 해당 지역 법을 확인 1강, 4강
봇이 연락처 URL 과 함께 자기 이름을 밝히는가 1강
대기, 429 처리, 재시도 상한이 있는가 3강, 6강
필요한 필드만 저장하고, 필요 없는 개인정보는 갖고 있지 않은가 4강, 5강
실행이 멱등하고, UTC 타임스탬프와 트랜잭션 하나로 묶였는가 5강
레이아웃 변경, 잘못된 인코딩, 0건이면 알람이 울리는가 6강
0이 아닌 종료 코드가 사람에게 전달되는가 6강

그리고 이 시리즈에서 일부러 한 번도 하지 않은 것. CAPTCHA 우회, 차단을 피하려는 IP·프록시 로테이션, 브라우저인 척하기, 남의 사이트에 로그인해서 데이터 모으기. 사이트가 안 된다고 하면 답은 안 되는 겁니다. 접근을 요청하거나 공식 경로를 쓰세요.

솔직한 한계와 버전 메모

  • Python 3.9.6(표준 라이브러리)과 PHP 8.5.6 내장 서버로 실행했습니다. 레이아웃, 인코딩, 장애는 모두 우리 연습 사이트가 만든 것이고 실제 쇼핑몰에서 관찰한 것이 아닙니다. 실제 개편은 부분적이고 점진적(A/B 테스트)인 경우가 많아서 레코드 단위 검사가 더 중요합니다.
  • Python 의 shift_jis 코덱은 Windows 의 cp932 보다 엄격합니다. 한국어 쪽도 euc-kr 은 cp949 보다 좁아서, 확장 한글이 들어간 페이지는 cp949 가 필요할 수 있습니다. 여기서 확인한 건 보여 준 상품명 다섯 개뿐입니다.
  • requests, BeautifulSoup, Scrapy, Playwright 는 쓰지 않았습니다. Scrapy 에는 재시도와 자동 속도 조절 미들웨어가 있고, requests 에는 response.apparent_encoding 이 있습니다. 공식 문서를 근거로 한 설명이며 여기서는 실행하지 않았습니다.
  • 시간은 루프백 값입니다. 실제 네트워크에서는 재시도마다 지연이 더해집니다. 지터는 random.Random(42) 라서 숫자가 재현됩니다.

연습 문제

  1. "지난 실행보다 너무 적다" 검사를 추가해 보세요. metrics.jsonl 의 마지막 줄을 읽고, 오늘 items 가 그 절반 미만이면 알람을 냅니다.
  2. 그날 세 번째 실행 이후로 사이트가 모든 페이지에 403 을 돌려주기 시작했습니다. User-Agent 를 바꿔서 재시도해야 할까요? 어떻게 해야 할까요?

정답

  1. run() 전에 마지막 줄을 읽습니다. last = json.loads(open("metrics.jsonl").read().splitlines()[-1])["items"](파일이 없을 때 처리 포함). 실행 뒤에 if m["items"] < last / 2: alarms.append("DROP_VS_LAST") 를 하고 json.dumps(m) 을 파일에 덧붙입니다. 고정된 expected_min 도 남겨 두세요. 개편 직후 첫 실행이 망가지면 다음 날에는 "어제의 절반" 기준을 통과해 버리기 때문입니다.
  2. 아닙니다. 차단을 피하려고 User-Agent 를 바꾸는 건 우회 행위입니다. 실행을 멈추고(GiveUp 이 이미 그렇게 합니다), 알람을 내고, robots.txt 와 약관을 확인하고, 빈도를 낮추고, 사이트 운영자에게 연락하거나 공식 API 를 쓰세요.

시리즈 안내

자주 묻는 질문

스크래퍼가 에러 없이 0건이라고만 보고하는 이유는 뭔가요?

HTML 파서에게 아무것도 못 찾은 것은 에러가 아니기 때문입니다. 연습 쇼핑몰을 새 레이아웃으로 바꾸자 5강의 감시 스크립트는 10페이지를 모두 가져오고 상품 0건, 종료 코드 0으로 끝났습니다. 0건이나 예상보다 적은 건수를 실패로 보고 0이 아닌 종료 코드를 내는 실행 단위 검사를 넣어야 cron 과 모니터링이 알아챕니다.

스크래퍼는 어떤 에러를 재시도해야 하나요?

시간이 해결할 수 있는 것만입니다. 429 Too Many Requests, 503 같은 5xx, 타임아웃, 연결 거부를 Retry-After 나 지수 백오프로, 횟수 상한을 두고 재시도합니다. 403, 404, 400 은 재시도하지 말고, 파싱이나 스키마 실패는 절대 재시도하지 않습니다. 불안정한 엔드포인트에서 재시도 없음은 200건 중 40건, 최대 3회는 200건 전부를 14.34초에 얻었습니다.

자바스크립트로 그려지는 페이지는 어떻게 수집하나요?

먼저 브라우저 개발자 도구의 네트워크 탭을 봅니다. 페이지는 대개 JSON 엔드포인트에서 데이터를 불러오고, 그쪽이 읽기 쉬우며 API 로 공개된 경우도 많습니다. 연습 페이지에서 urllib 에는 'Loading...' 과 상품 0건만 보였고, JSON 엔드포인트는 페이지당 20건을 돌려줬습니다. 쓰기 전에 약관을 확인하세요. 헤드리스 브라우저는 마지막 수단입니다.

  • #스크래핑
  • #고급
  • #Python
  • #모니터링
  • #로그
  • #재시도
  • #인코딩

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑