스크래핑

멱등성 스크레이퍼 설계: 중복 없이 재시작하기

스크레이퍼는 죽는다. 크롤링 도중 열 번을 강제 종료해봤다: 단순한 버전은 최대 4.3배의 행을 저장했고, 멱등성 버전은 정확히 500개를 저장했다. 후자를 만드는 방법.

Chris Collins

Chris Collins

2026년 10월 2일 · 8 분 소요

모든 스크레이퍼는 결국 중간에 멈춘다. 컨테이너가 재스케줄되거나, 배포로 인해 워커가 재시작되거나, 머신이 메모리 부족에 빠지거나, 누군가 Ctrl+C를 누른다. 그다음에 무슨 일이 일어나느냐가 데이터의 신뢰성을 좌우한다. 단순히 처음부터 다시 시작하는 스크레이퍼는 모든 것을 다시 가져오고, 그렇게 설계되지 않았다면 모든 것을 다시 기록한다.

멱등적인(idempotent) 스크레이퍼란 같은 작업을 두 번 실행해도 한 번 실행한 것과 같은 결과를 내는 스크레이퍼다. 언제든 죽일 수 있고, 다시 시작해도 결국 각 레코드가 정확히 하나씩만 남는다. 이 가이드는 그것을 가능하게 하는 네 가지 설계 결정을 검증된 코드와 함께 보여주고, 크롤링 도중 열 번 죽였을 때의 결과를 공개한다.

핵심 요약

  • 프로세스가 어느 두 줄 사이에서든 죽을 수 있다고 가정하라. 재시작이 진행 중이던 단 한 페이지만 반복하도록 설계하라.
  • 모든 레코드에 스크레이핑한 시점이 아니라 그 레코드가 무엇인지(예: 출처와 SKU)에서 파생된 ID를 부여하라. 그러면 반복된 쓰기는 중복이 아니라 업데이트가 된다.
  • 크롤링 프런티어를 영구 저장소에 보관하고, URL을 완료로 표시하는 작업을 결과를 저장하는 것과 같은 트랜잭션 안에서 수행하라.
  • 락 대신 리스(lease)를 사용하라. 그러면 충돌한 워커가 가져간 작업이 자동으로 다시 사용 가능해진다.
  • 상품 500개를 대상으로 실행마다 열 번씩 죽인 테스트에서, 나이브한 스크레이퍼는 1,286개에서 2,165개 사이의 행으로 끝났고 요청 수는 최대 4.3배에 달했다. 멱등적인 스크레이퍼는 정확히 500개의 올바른 행으로 끝났고 반복된 요청은 최대 3번뿐이었다.

재시작이 중복을 만드는 이유

스크레이퍼의 흔한 초기 버전은 이런 식이다. 첫 페이지에서 시작해서, 페이지네이션을 따라가며, 모든 상품에 대해 행을 삽입하고, 진행하면서 커밋한다. 중단되기 전까지는 완벽하게 작동한다. 재시작하면 어디까지 진행했는지에 대한 기억이 전혀 없으므로 처음부터 다시 시작하고, 이미 저장했던 모든 상품은 새로운 자동 증가 ID로 다시 한 번 삽입된다. 몇 번 죽이고 나면 테이블에는 대부분의 상품에 대해 여러 복사본이 남고, 어느 것이 최신인지 알려주는 것은 아무것도 없다.

나중에 중복을 제거하는 것은 가능하며, 엔티티 해석이 이를 다루지만, 이는 증상을 치료하는 것일 뿐이다. 중복은 정확성을 해치기 전에 먼저 비용을 발생시킨다. 반복된 모든 페이지는 두 번 지불한 대역폭이며, 이는 곧바로 클린 레코드당 비용에 반영된다.

스크레이퍼를 멱등적으로 만드는 네 가지 결정

1. 레코드 ID를 데이터에서 파생시켜라

레코드의 ID는 그 레코드가 무엇인지에서 나와야 한다. 즉 출처와 SKU, 리스팅 ID, 정규화된 URL 같은 고유 키의 조합이다. 이것들을 해시로 묶으면 같은 상품은 어떤 실행에서든, 어떤 머신에서든 항상 같은 ID를 받는다. 다시 쓰는 것은 업서트(upsert)가 된다. 레코드가 존재하고 변경되지 않았다면 아무 일도 일어나지 않는다. 변경되었다면 업데이트되고 변경 시각이 기록된다.

2. 프런티어를 영구적으로 유지하라

방문할 URL 목록과 완료된 URL 목록은 메모리가 아니라 데이터베이스에 있어야 한다. 이미 알려진 URL을 추가하는 것은 아무 동작도 하지 않아야 하므로, 이미 처리한 리스팅 페이지에서 링크를 다시 발견하더라도 문제가 되지 않는다.

3. 결과와 진행 상황을 함께 커밋하라

위험한 순간은 결과를 저장하는 것과 URL이 완료되었음을 기록하는 것 사이다. 둘 중 하나가 끝난 후 다른 하나가 끝나기 전에 프로세스가 죽으면, 재시작했을 때 결과를 잃어버리거나 반복하게 된다. 둘 다를 하나의 데이터베이스 트랜잭션 안에서 수행하면 이 틈이 사라진다. 레코드가 저장되고 URL이 완료로 표시되거나, 둘 다 일어나지 않아서 그 URL이 단순히 다시 가져와지는 것이다.

4. 작업을 잠그지 말고 리스하라

워커는 제한된 시간 동안 URL을 가져간다(claim). 작업을 끝내면 URL은 완료로 표시된다. 충돌하면 리스가 만료되고, 다른 워커 또는 재시작된 워커가 그 URL을 다시 가져간다. 충돌을 누군가 알아챌 필요 없이 작업이 복구된다.

코드

아래 모듈은 SQLite와 requests로 네 가지를 모두 구현한다. SQLite는 예제를 자체적으로 완결되게 만들어 주며, 같은 설계는 PostgreSQL이나 트랜잭션과 업서트를 지원하는 어떤 데이터베이스에도 그대로 적용된다.

import hashlib
import json
import re
import sqlite3
import time

import requests

SCHEMA = """
CREATE TABLE IF NOT EXISTS frontier (
    url TEXT PRIMARY KEY,
    status TEXT NOT NULL DEFAULT 'pending',      -- pending, leased, done
    leased_until REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS records (
    record_id TEXT PRIMARY KEY,                  -- derived from the source and its natural key
    url TEXT NOT NULL,
    content_hash TEXT NOT NULL,
    data TEXT NOT NULL,
    first_seen REAL NOT NULL,
    last_changed REAL NOT NULL
);
"""


def open_db(path):
    db = sqlite3.connect(path, isolation_level=None)  # explicit transactions below
    db.execute("PRAGMA journal_mode=WAL")
    db.executescript(SCHEMA)
    return db


def record_id(source, natural_key):
    """The same item always gets the same ID, however many times it is scraped."""
    return hashlib.sha256(f"{source}:{natural_key}".encode()).hexdigest()[:16]


def enqueue(db, urls):
    """Adding a URL that is already known is a no-op, so re-discovering links is harmless."""
    db.executemany("INSERT OR IGNORE INTO frontier (url) VALUES (?)", [(u,) for u in urls])


def lease(db, lease_seconds=60):
    """Claim one URL. A lease left behind by a crashed worker expires and the URL is retried."""
    now = time.time()
    row = db.execute(
        "UPDATE frontier SET status = 'leased', leased_until = ? WHERE url = ("
        "  SELECT url FROM frontier WHERE status = 'pending' OR (status = 'leased' AND leased_until < ?) LIMIT 1"
        ") RETURNING url", (now + lease_seconds, now)).fetchone()
    return row[0] if row else None


def complete(db, url, new_urls=(), record=None):
    """Write the result and mark the URL done in one transaction: either both happen or neither does."""
    db.execute("BEGIN IMMEDIATE")
    try:
        enqueue(db, new_urls)
        if record:
            now = time.time()
            payload = json.dumps(record["data"], sort_keys=True)
            digest = hashlib.sha256(payload.encode()).hexdigest()
            db.execute(
                "INSERT INTO records (record_id, url, content_hash, data, first_seen, last_changed) VALUES (?, ?, ?, ?, ?, ?) "
                "ON CONFLICT(record_id) DO UPDATE SET data = excluded.data, content_hash = excluded.content_hash, "
                "last_changed = excluded.last_changed WHERE records.content_hash != excluded.content_hash",
                (record["id"], url, digest, payload, now, now))
        db.execute("UPDATE frontier SET status = 'done' WHERE url = ?", (url,))
        db.execute("COMMIT")
    except Exception:
        db.execute("ROLLBACK")
        raise


def crawl(db, base, session=None, lease_seconds=60):
    session = session or requests.Session()
    enqueue(db, [f"{base}/list/0"])
    while True:
        url = lease(db, lease_seconds)
        if url is None:
            if db.execute("SELECT 1 FROM frontier WHERE status != 'done' LIMIT 1").fetchone():
                time.sleep(1)  # a lease is still held, perhaps by a worker that crashed; wait for it to expire
                continue
            return
        html = session.get(url, timeout=30).text
        if "/list/" in url:
            links = [base + href for href in re.findall(r'href="(/(?:product|list)/\d+)"', html)]
            complete(db, url, new_urls=links)
        else:
            sku = re.search(r'class="sku">([^<]+)<', html).group(1)
            price = re.search(r'class="price">([^<]+)<', html).group(1)
            complete(db, url, record={"id": record_id("example-shop", sku), "data": {"sku": sku, "price": price}})

crawl 함수는 우리 테스트 사이트에 특화되어 있으며, 리스팅 페이지 25개가 상품 페이지 500개로 연결된다. 그 위의 나머지는 재사용 가능하다. 놓치기 쉬운 두 가지 세부사항이 있다.

  • 업서트는 콘텐츠가 변경되었을 때만 기록을 수행한다. 충돌 업데이트의 WHERE 절은 변경되지 않은 레코드는 그대로 두므로, last_changed가 그 의미 그대로를 유지하며 변경 감지를 이끌 수 있다.
  • 루프는 큐가 비어도 멈추지 않는다. 우리의 첫 버전은 어떤 URL도 가져갈 수 없을 때 종료되었는데, 이는 충돌로 인해 리스가 남아 있을 때마다 페이지가 미완성 상태로 남게 만들었다. 이제 루프는 모든 URL이 완료될 때까지 대기한다.

열 번 죽여보기

우리는 리스팅 페이지 25개와 상품 페이지 500개로 구성된 로컬 테스트 사이트를 운영했으며, 완전한 크롤링에는 정확히 525개의 요청이 필요하다. 각 실행은 스크레이퍼를 시작해서 0.2초에서 1초 사이의 무작위 시점에 SIGKILL로 죽였고, 이를 끝까지 완료하도록 두기 전에 열 번 반복했다. 우리는 위의 멱등적인 스크레이퍼와 앞서 설명한 나이브한 스크레이퍼를 같은 종료 타이밍으로 각각 다섯 번씩 실행했으며, 멱등적인 버전에는 3초짜리 리스를 사용했다.

실행나이브: 저장된 행 수나이브: 요청 수멱등적: 저장된 행 수멱등적: 요청 수
12,1652,283500525
21,8751,977500526
31,8621,967500526
41,4551,538500526
51,2861,361500528

두 버전 모두 결국 500개 상품을 모두 저장했는데, 각각 마지막 실행은 끝까지 완료되었기 때문이다. 차이는 그 외의 모든 것에서 드러난다. 나이브한 스크레이퍼는 상품당 2.6개에서 4.3개의 행을 저장했고, 필요한 것보다 2.6배에서 4.3배 많은 요청을 보냈다. 멱등적인 스크레이퍼는 상품당 정확히 한 개의 행을 저장했으며, 모든 값이 출처 페이지와 일치했고, 열 번의 충돌에 걸쳐 반복된 요청은 최대 세 번뿐이었다. 페이지가 진행 중일 때 죽인 타이밍마다 한 번씩이다. 또한 충돌한 워커의 리스가 만료되기를 기다려야 했던 경우도 있었음에도, 멱등적인 버전은 모든 실행에서 더 빨리 끝났다. 6.8초에서 8.3초가 걸린 반면 나이브한 버전은 9.2초에서 10.8초가 걸렸다.

데이터베이스를 넘어서

스크레이퍼가 두 번 이상 수행하는 것은 레코드만이 아니다. 같은 원칙이 모든 부수 효과에 적용된다.

  • 파일. 다운로드한 이미지와 문서의 이름을 콘텐츠의 해시나 레코드 ID의 해시로 지정하면, 반복된 다운로드가 복사본을 추가하는 대신 덮어쓰게 된다.
  • 메시지와 웹훅. 레코드와 그 버전에서 파생된 멱등성 키를 함께 보내면, 소비자가 이미 처리한 메시지를 무시할 수 있다.
  • 카운터와 집계값. 매 가져오기마다 증가시키는 대신 저장된 레코드로부터 다시 계산하라. 그렇지 않으면 재시작이 값을 부풀린다.
  • 원본 응답. 원본 응답을 아카이브한다면, 반복된 가져오기는 두 번째 캡처를 추가하는데, 추출된 레코드가 멱등적으로 유지되는 한 이는 무해할 뿐 아니라 유용하기까지 하다.

실무 규칙

  • 고유 키를 신중하게 선택하라. 출처에서 안정적이어야 한다. SKU나 리스팅 ID여야 하며, 페이지상의 위치나 추적 파라미터가 붙은 URL이어서는 안 된다.
  • 먼저 재시도를 안전하게 만들고, 그다음에 빈번하게 만들어라. 쓰기가 멱등적이 되고 나면, 재시도와 백오프를 데이터 오염 없이 관대하게 설정할 수 있다.
  • 리스 시간을 가장 느린 페이지에 맞춰 설정하라. 느린 가져오기보다 짧은 리스는 두 워커가 같은 URL을 처리하게 만들 수 있다. 여기서는 무해하지만 대역폭이 낭비된다.
  • 반복률을 지켜보라. 저장된 레코드당 요청 수는 저렴한 건강 지표다. 이 값이 상승하면 충돌이나 리스 문제를 의미하며, 스크레이핑 파이프라인 모니터링에서 다른 지표들과 함께 다뤄야 한다.

결론

안전하게 재시작할 수 없는 스크레이퍼는 결국 스스로의 데이터를 오염시키며, 그것도 조용히 그렇게 한다. 해결책은 더 신중한 운영이 아니라, 재시작을 지루하게 만드는 설계다. 데이터에서 파생된 ID, 영구적인 프런티어, 함께 커밋되는 결과와 진행 상황, 그리고 만료되는 리스가 그것이다.

우리 테스트에서는 이 네 가지 결정이 열 번의 충돌을 최대 4.3배의 행과 요청에서 정확히 올바른 500개의 레코드와 세 번의 반복 요청으로 바꾸어 놓았다.

출처 및 참고자료

  • SQLite 문서, UPSERT 및 RETURNING.
  • Shifter가 2026년 10월 2일에 위 코드를 사용하여 로컬 테스트 사이트를 대상으로 실행한 킬 테스트.

시작할 준비가 되셨나요?

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

시작하기