지식

스크랩 데이터의 스키마 드리프트: 사용자보다 먼저 깨진 추출기 발견하기

사이트가 변경되어도 스크레이퍼는 좀처럼 중단되지 않습니다. 계속 실행되면서 미묘하게 잘못된 데이터를 반환합니다. 데이터 계약과 배치 프로파일링이 드리프트를 조기에 발견하는 방법입니다.

Matt Brown

Matt Brown

2026년 9월 29일 · 7 분 소요

가장 심각한 스크레이퍼 실패는 실패처럼 보이지 않는다. 작업은 실행되고, 요청은 성공하며, 데이터는 예정대로 웨어하우스에 도착한다. 그러다 재무팀의 누군가가 왜 평균 가격이 하룻밤 사이에 세 배가 되었는지, 왜 카탈로그의 절반이 갑자기 품절되었는지 묻고, 조사 결과 아무도 눈치채지 못한 3주 전 웹사이트 리디자인으로 거슬러 올라간다.

이것이 스키마 드리프트다. 데이터는 계속 흘러 들어오지만 그 형태나 의미가 조용히 변한 것이다. 이는 웹 데이터의 일반적인 실패 방식인데, 소스는 예고 없이 변경되지만 추출기는 계속 출력을 생성하기 때문이다. 이 가이드는 이를 포착하는 두 개의 계층을 다룬다. 잘못된 값을 거부하는 레코드 수준 계약, 그리고 데이터 전체가 더 이상 스스로와 같아 보이지 않을 때 이를 알아채는 배치 수준 프로파일이다. 또한 작은 테스트를 통해 왜 둘 다 필요한지 보여준다.

핵심 요약

  • 드리프트는 대체로 조용하다. 변경된 페이지는 추출기를 고장 내는 경우가 드물다. 대신 추출기가 그럴듯하지만 틀린 값을 반환하게 만든다.
  • 레코드 수준 계약은 각 레코드를 규칙에 따라 검사한다. 필수 필드, 타입, 범위, 형식이다. 명백한 파손은 이것으로 잡아낸다.
  • 배치 수준 프로파일은 각 실행 결과를 기준선과 비교한다. 채움률, 타입 구성, 중앙값이다. 계약이 볼 수 없는 파손을 이것으로 잡아낸다.
  • 실제와 유사한 세 가지 드리프트를 테스트한 결과, 계약은 그중 하나만 잡아냈다. 나머지 둘은 모든 레코드 수준 검사를 통과했고 배치 프로파일만이 이를 잡아냈다.
  • 데이터가 출고되기 전에 드리프트에 대해 알림을 보내고, 배치를 격리하고, 어떤 사이트와 추출기 버전이 이를 만들어냈는지 기록하라.

드리프트는 실제로 어떻게 일어나는가

원인데이터에 일어나는 일
리디자인이 마크업을 변경셀렉터가 다른 요소와 일치하거나, 아무것도 일치하지 않음
가격 형식 변경”9.99”가 “€9.99”, “9,99” 또는 소수점 없는 999로 바뀜
필드가 JavaScript 뒤로 이동정적 HTML에 더 이상 포함되지 않아 빈 값으로 돌아옴
현지화 또는 지역 변형일부 페이지에서 다른 통화, 언어 또는 단위가 나타남
A/B 테스트일부 페이지가 새 레이아웃을 사용해 일부 레코드가 깨짐
차단 또는 챌린지 페이지페이지는 로드되고 추출기는 실행되지만 아무것도 반환하지 않거나 쓰레기 값을 반환

공통점은 이 중 어느 것도 예외를 발생시키지 않는다는 것이다. 마지막 항목, 즉 성공적으로 로드되지만 원하던 페이지가 아닌 경우는 침묵의 실패율에서 다룬다. 나머지는 추출기가 밑에서 변경된 페이지를 충실히 처리하고 있는 경우다.

계층 1: 레코드 수준 계약

데이터 계약은 유효한 레코드가 어떤 모습인지 명시한다. 어떤 필드가 필수인지, 그 타입은 무엇인지, 허용되는 범위와 형식은 무엇인지다. 모든 레코드는 수락되기 전에 검사되며, 위반 사항은 조용히 저장되는 대신 집계되고 격리된다.

import re
import statistics
from collections import Counter

CONTRACT = {
    "name":     {"type": str, "required": True},
    "price":    {"type": float, "required": True, "min": 0.01, "max": 100_000},
    "currency": {"type": str, "required": True, "pattern": r"^[A-Z]{3}$"},
    "in_stock": {"type": bool, "required": False},
}


def violations(record, contract=CONTRACT):
    """Record-level checks: presence, type, range, format."""
    problems = []
    for field, rule in contract.items():
        value = record.get(field)
        if value in (None, ""):
            if rule.get("required"):
                problems.append(f"{field}: missing")
            continue
        if rule["type"] is float and isinstance(value, int) and not isinstance(value, bool):
            value = float(value)
        if not isinstance(value, rule["type"]):
            problems.append(f"{field}: expected {rule['type'].__name__}, got {type(value).__name__}")
            continue
        if "min" in rule and value < rule["min"] or "max" in rule and value > rule["max"]:
            problems.append(f"{field}: {value} out of range")
        if "pattern" in rule and not re.match(rule["pattern"], value):
            problems.append(f"{field}: {value!r} bad format")
    return problems

{"name": "x", "price": "€9.99", "currency": "eur"}와 같은 레코드는 두 번 실패한다. 가격이 문자열이고, 통화가 세 글자 대문자 코드가 아니다. 계약은 저렴하고 명시적이며 이해하기 쉽다. 그 한계는 각 레코드가 개별적으로 판단된다는 점이며, 상당수의 드리프트는 개별적으로는 유효한 레코드를 만들어낸다.

계층 2: 배치 수준 프로파일

프로파일은 전체 배치를 요약한다. 각 필드에 대해 얼마나 자주 채워지는지, 어떤 타입이 나타나는지, 숫자의 경우 중앙값이 무엇인지다. 각 실행의 프로파일을 최근 정상 실행들로부터 얻은 기준선과 비교하면, 모든 레코드가 계약을 통과하더라도 데이터 전체의 형태가 변할 때 이를 알 수 있다.

def profile(records, fields=CONTRACT):
    """Batch-level shape: how often each field is filled, its types, and numeric medians."""
    n = max(1, len(records))
    out = {}
    for field in fields:
        values = [r.get(field) for r in records]
        present = [v for v in values if v not in (None, "")]
        numbers = [float(v) for v in present if isinstance(v, (int, float)) and not isinstance(v, bool)]
        out[field] = {
            "fill_rate": len(present) / n,
            "types": Counter(type(v).__name__ for v in present),
            "median": statistics.median(numbers) if numbers else None,
            "distinct": len(set(map(str, present))),
        }
    return out


def drift(baseline, current, fill_drop=0.1, median_ratio=3.0):
    """Compare two batch profiles and describe what changed shape."""
    alerts = []
    for field, base in baseline.items():
        cur = current[field]
        if base["fill_rate"] - cur["fill_rate"] > fill_drop:
            alerts.append(f"{field}: filled {base['fill_rate']:.0%} -> {cur['fill_rate']:.0%}")
        if set(cur["types"]) - set(base["types"]):
            alerts.append(f"{field}: new types {sorted(set(cur['types']) - set(base['types']))}")
        if base["median"] and cur["median"]:
            ratio = cur["median"] / base["median"]
            if ratio > median_ratio or ratio < 1 / median_ratio:
                alerts.append(f"{field}: median {base['median']:g} -> {cur['median']:g}")
    return alerts

이 임계값은 출발점일 뿐이다. 채움률이 10포인트 하락하거나 중앙값이 세 배 변하는 것은 평상시라고 보기 어렵다. 몇 주간의 이력이 쌓이면 필드별로 두 값을 조정하라.

왜 둘 다 필요한가: 작은 테스트

1,000개의 제품 레코드로 이루어진 정상 기준선을 생성한 다음, 실제와 유사한 세 가지 파손을 시뮬레이션하고 각각에 대해 두 계층을 실행했다.

드리프트발생한 일레코드 수준 계약배치 프로파일
형식이 바뀐 가격리디자인으로 페이지의 30%에서 통화 기호가 가격 안에 들어감잡아냄: 300개 레코드 거부잡아냄: price에 새 타입 str
소수점 없는 단위가격이 63.05 대신 6305로 도착하기 시작함놓침: 모든 레코드 통과잡아냄: 중앙값이 63.05에서 6305로, 새 타입 int
깨진 재고 셀렉터페이지의 80%에서 in-stock 필드가 빈 값으로 돌아옴놓침: 해당 필드는 선택 사항잡아냄: 채움률이 100%에서 20%로

계약은 명백히 잘못된 형식의 값을 만들어낸 드리프트 하나만 잡아냈다. 나머지 둘은 개별적으로는 유효한 레코드를 만들어냈다. 범위 내의 양수와 비어 있는 선택적 필드였고, 배치 관점만이 무언가가 변했다는 것을 보여주었다. 소수점 없는 단위 사례는 가상이 아니다. 지난주 우리가 검토한 실제 제품 엔드포인트는 100.00달러짜리 신발에 대해 10000을 반환했으며, 이는 HTML 파싱을 멈춰라에서 설명한 내용이다.

드리프트가 발생했을 때 해야 할 일

  1. 배치를 격리하라. 누군가 살펴볼 때까지 드리프트 알림이 발생한 배치의 데이터를 출고하지 마라. 늦은 데이터가 잘못된 데이터보다 낫다.
  2. 범위를 정확히 파악하라. 알림을 사이트, 페이지 유형, 추출기 버전별로 세분화하라. 드리프트는 대개 한 가지 변경 이후 한 사이트에서 시작된다.
  3. 표본을 비교하라. 영향을 받은 레코드 몇 개를 그것이 나온 페이지와 나란히 살펴보라. 원인은 대개 몇 분 안에 명백해진다.
  4. 수정하고 백필하라. 추출기를 업데이트한 다음, 저장된 페이지가 있다면 영향을 받은 기간을 재추출하고, 없다면 다시 수집하라.
  5. 기준선을 의도적으로 업데이트하라. 사이트가 실제로 통화를 변경하는 것처럼 변경이 정당한 경우, 기준선을 의도적으로 재설정하라. 절대 자동으로 하지 마라.

애초에 드리프트 가능성을 낮추는 방법

일부 소스는 다른 소스보다 드리프트가 적다. 검색 엔진을 위해 내장된 구조화 데이터는 페이지 레이아웃보다 훨씬 덜 자주 변경되며, 이것이 이를 먼저 추출하는 것이 계약이 발동하는 빈도 자체를 줄이는 이유다. 페이지 자체를 감시하는 것도 도움이 된다. 대규모 변경 감지로 감지된 템플릿 변경은 추출이 곧 깨질 수 있다는 조기 경고이며, 한 사이트에서 계약 위반 비율이 상승하는 것은 타겟 상태 점수에 강력한 입력값이 된다. 작업당 한 시장에서 일관되게 수집하는 것 또한 지역 및 통화 변형으로 인한 드리프트를 통째로 제거한다.

결론

웹 데이터는 웹이 묻지 않고 변하기 때문에 드리프트한다. 위험은 추출기가 고장 나는 것이 아니라, 더 이상 원래 대상으로 삼았던 것이 아닌 페이지에서 계속 작동하며, 한 번에 한 레코드씩 보면 멀쩡해 보이는 데이터를 만들어낸다는 데 있다.

모든 레코드를 계약에 대해 검사하고, 모든 배치를 그 자체의 최근 이력에 대해 검사하라. 우리의 테스트에서 계약만으로는 세 가지 드리프트 중 하나만 잡아냈지만, 배치 프로파일은 세 가지 모두를 잡아냈다. 둘을 함께 사용하면 “대시보드가 3주 동안 이상해 보였다”를 문제가 시작된 바로 그날 아침의 알림으로 바꿀 수 있다.

출처 및 참고 자료

  • Shifter가 2026년 9월 29일에 위 코드로, 생성된 1,000개의 제품 레코드와 세 가지 시뮬레이션된 드리프트에 대해 실행한 테스트.

시작할 준비가 되셨나요?

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

시작하기