웹 스크래핑 API는 수집 과정에서 가장 어려운 부분, 즉 프록시, 렌더링, 재시도, 차단 문제를 제거해 준다. 제거되지 않는 것은 응답이 도착한 이후에 일어나는 모든 일이며, 실제로 대부분의 파이프라인이 실패하는 곳이 바로 여기다.
이 실패들은 조용하다. 아무도 중복 제거하지 않은 재시도로 생긴 중복 행. 대시보드에서 누군가 숫자로 변환한 "$16.99" 같은 문자열로 가득한 가격 열. 3주 전에 필드를 null로 바꿔버린 사이트 리디자인. 모든 것을 다시 가져오는 비용을 지불하지 않으면 고칠 수 없는 추출기 버그.
이 가이드는 적재(load) 측면, 즉 스크래핑 API 출력을 대량으로, 저장한 내용을 설명하거나 재현할 수 있는 능력을 잃지 않고 안정적으로 SQL 데이터베이스에 넣는 것에 관한 것이다. 예시는 PostgreSQL과 Shifter Web Scraping API를 사용하며, 이 패턴은 모든 SQL 데이터베이스에 그대로 적용된다.
실제로 받는 응답에서 시작하라
Shifter Web Scraping API에서는 두 가지 응답 형태 중 하나를 선택할 수 있다.
원본 HTML을 받아 직접 파싱하는 방식. 또는 CSS 선택자를 필드에 매핑하는 extract_rules를 전달해 구조화된 JSON을 받는 방식.
curl "https://scrape.shifter.io/v1?api_key=YOUR_API_KEY&url=https://shop.example.com/p/42&render_js=1&extract_rules=%7B%22title%22%3A%7B%22selector%22%3A%22h1%22%2C%22output%22%3A%22text%22%7D%2C%22price%22%3A%7B%22selector%22%3A%22.price%22%2C%22output%22%3A%22text%22%7D%7D"
# {"title": "Example Product", "price": "$19.99"}
이 출력 형태에는 스키마에 영향을 주는 두 가지 특성이 있다. 선택자가 아무것도 매칭하지 않는 필드는 요청 실패가 아니라 null로 돌아오기 때문에, 누락된 엘리먼트와 깨진 선택자가 응답상 동일하게 보인다. 그리고 텍스트 출력은 사람이 보기 위한 표시용 문자열이므로, 가격, 평점, 날짜는 데이터베이스용 타입이 아니라 사람이 읽을 형식으로 도착한다. 결과 페이지용 리스트 추출을 포함한 규칙 문법은 extraction rules docs에 있다.
하나의 테이블이 아니라 세 개의 계층
실제 운영 환경에서 살아남는 설계는 받은 것과 결론 내린 것을 분리한다.
| 계층 | 내용 | 존재 이유 |
|---|---|---|
| 원본 적재 | 받은 그대로의 모든 성공 응답과 fetch 메타데이터 | 다시 가져오지 않고 추출을 재현 |
| 타입화된 관측값 | 파싱, 타입 지정, 검증된 값과 파싱 상태 | 분석가와 애플리케이션이 조회하는 대상 |
| 현재 상태 | 관측값에서 파생된 엔티티별 최신 값 | 제품과 대시보드를 위한 빠른 읽기 |
원본 계층은 팀들이 건너뛰고 나중에 후회하는 부분이다. 크레딧은 성공한 요청에 소비되므로, 다시 가져와야만 고칠 수 있는 파싱 버그는 전체 크롤링 비용을 두 배로 만든다. 먼저 응답을 적재하고 그 다음에 파싱하면, 파서 수정은 재현 쿼리 하나로 끝난다.
랜딩 테이블
CREATE TABLE scrape_raw (
job_id text PRIMARY KEY,
source_url text NOT NULL,
market text NOT NULL,
fetched_at timestamptz NOT NULL,
http_status smallint NOT NULL,
body jsonb NOT NULL,
body_hash text NOT NULL,
extractor_version text NOT NULL
);
CREATE INDEX scrape_raw_url_time ON scrape_raw (source_url, fetched_at DESC);
여기에는 몇 가지 의도적인 선택이 있다.
job_id는 멱등성 키로, 요청 전에 URL과 스케줄링 윈도우로부터 계산되므로, 동일한 논리적 작업의 재시도는 두 번째 행을 만드는 대신 같은 키에 적재된다. extractor_version은 어떤 추출 규칙 집합이 본문을 만들었는지 기록하며, 이를 통해 나중에 사이트 변경과 규칙 변경을 구분할 수 있다. market은 관측이 이루어진 위치를 기록하는데, 같은 URL이 국가별로 다른 콘텐츠를 반환할 수 있기 때문이다. 그리고 저장하는 요청 메타데이터에는 API 키를 절대 저장하지 않는데, 데이터베이스에 있는 자격 증명은 모든 백업에도 존재하는 자격 증명이기 때문이다.
멱등적으로 적재하기
import hashlib
import json
import os
import psycopg
import requests
from psycopg.types.json import Jsonb
API = "https://scrape.shifter.io/v1"
RULES = {
"title": {"selector": "h1", "output": "text"},
"price": {"selector": ".price", "output": "text"},
}
EXTRACTOR_VERSION = "product-v3"
INSERT_RAW = """
INSERT INTO scrape_raw
(job_id, source_url, market, fetched_at, http_status, body, body_hash, extractor_version)
VALUES (%s, %s, %s, now(), %s, %s, %s, %s)
ON CONFLICT (job_id) DO NOTHING
"""
def job_id(url: str, market: str, window: str) -> str:
return hashlib.sha256(f"{url}|{market}|{window}".encode()).hexdigest()
def fetch(url: str, market: str) -> requests.Response:
params = {
"api_key": os.environ["SHIFTER_API_KEY"],
"url": url,
"render_js": 1,
"country": market,
"extract_rules": json.dumps(RULES),
}
return requests.get(API, params=params, timeout=90)
def land(conn: psycopg.Connection, url: str, market: str, window: str) -> None:
resp = fetch(url, market)
if resp.status_code != 200:
raise RuntimeError(f"{resp.status_code} for {url}")
body = resp.text
with conn.cursor() as cur:
cur.execute(
INSERT_RAW,
(
job_id(url, market, window),
url,
market,
resp.status_code,
Jsonb(json.loads(body)),
hashlib.sha256(body.encode()).hexdigest(),
EXTRACTOR_VERSION,
),
)
ON CONFLICT (job_id) DO NOTHING이 적재를 재시도해도 안전하게 만드는 부분이다. 실패 원인이 네트워크든, 워커든, 데이터베이스든 상관없이, 작업을 다시 실행해도 중복이 생길 수 없다. MySQL에서는 유니크 키와 INSERT IGNORE 또는 ON DUPLICATE KEY UPDATE가 이에 상응한다.
처리량: fetch와 load를 분리하기
fetch 쪽은 플랜의 동시성 한도로 정해진 확실한 한계가 있으며, 이를 초과하는 요청은 429를 반환한다. 데이터베이스도 자체 한계가 있는데, 스크래핑한 페이지마다 연결과 트랜잭션을 여는 팀들이 보통 이 한계에 먼저 부딫힌다.
그 사이에 큐를 두어라. 동시성 한도에 맞춰 크기를 정한 fetch 워커들이 응답을 큐에 쓴다. 소수의 로더 워커가 배치로 이를 소비한다. 안정적인 볼륨이라면 psycopg의 executemany를 몇백 행 단위 배치로 사용하면 충분하다. 대규모 백필의 경우에는 배치를 언로그드 스테이징 테이블에 COPY하고 한 번의 문장으로 병합한다.
INSERT INTO scrape_raw
SELECT * FROM scrape_raw_staging
ON CONFLICT (job_id) DO NOTHING;
이렇게 하면 수천 번의 왕복이 한 번으로 줄어들고, 멱등성 보장은 그대로 유지된다.
렌더링이 오래 걸리는 경우, API는 비동기로 전달할 수 있다. webhook=<URL>을 전달하면 준비되는 즉시 응답이 엔드포인트로 전송된다. 이 수신기도 job_id에 대해 멱등적으로 만들어야 하는데, HTTP 전달은 어느 쪽에서든 재시도될 수 있기 때문이다.
API 오류를 파이프라인 동작에 매핑하기
상태 코드가 모두 재시도 대상은 아니며, 이를 동일하게 취급하는 로더는 깨진 설정에 스팸을 보내거나 일시적인 실패를 그냥 포기하게 된다. 전체 표는 errors and limits에 있다.
| 상태 | 파이프라인 동작 |
|---|---|
408, 422, 500 | 지수 백오프로 재시도 |
429 | 백오프하고 워커 동시성을 줄임 |
400, 401, 403 | 설정 오류: 데드레터 큐로 보내고 알림, 절대 재시도하지 않음 |
509 | 크레딧 소진: fetch 단계를 멈추고 알림, 재시도해도 소용없음 |
실패한 요청과 대상의 4xx 또는 5xx 응답에는 요금이 부과되지 않으며, API는 반환하기 전에 일시적인 실패를 이미 최대 3회 재시도하므로, 직접 하는 재시도는 크레딧이 아니라 시간을 소비한다. 그럼에도 시간이 든다는 점 때문에 백오프가 중요하다.
관측값 타입화하기
여기가 표시용 문자열이 데이터가 되는 지점이며, 대부분의 조용한 오류가 발생하는 지점이다.
CREATE TABLE price_observation (
source_url text NOT NULL,
market text NOT NULL,
observed_at timestamptz NOT NULL,
price_amount numeric(12,2),
currency char(3),
raw_price text,
parse_status text NOT NULL,
job_id text NOT NULL REFERENCES scrape_raw (job_id),
PRIMARY KEY (source_url, market, observed_at)
);
세 가지 규칙이 이를 신뢰할 수 있게 유지한다.
원본 문자열을 파싱된 값 옆에 유지하라. raw_price는 다시 가져오지 않고 의심스러운 숫자를 감사할 수 있게 해준다.
전역이 아니라 시장별로 파싱하라. "1.299,00"과 "1,299.00"은 표기 관례가 다를 뿐 같은 가격이며, 통화 기호는 통화가 아니다. $는 스토어에 따라 미국, 캐나다, 오스트레일리아 달러를 나타낼 수 있다. ISO 코드는 기호와 시장을 함께 고려해서 결정하라.
값이 왜 null인지 기록하라. missing, unparseable, ok로 구성된 parse_status는 “페이지에 가격이 없었다”와 “우리 파서가 실패했다”를 구분해주며, API의 null만으로는 이를 알 수 없다.
현재 상태는 유지 관리하지 말고 파생시켜라. PostgreSQL에서는 다음과 같다.
CREATE VIEW price_current AS
SELECT DISTINCT ON (source_url, market) *
FROM price_observation
WHERE parse_status = 'ok'
ORDER BY source_url, market, observed_at DESC;
파생된 뷰는 그것이 요약하는 히스토리와 동기화에서 벗어날 수 없다.
사용자보다 먼저 스키마 변경을 감지하라
사이트는 마크업을 바꾸며, 변경된 선택자는 오류를 던지지 않는다. 대신 null을 반환하고, 요청은 성공하고, 크레딧이 소비되고, 그 행은 유효한 것처럼 적재된다.
방어책은 필드별, 소스별, extractor 버전별 null 비율 모니터다. 이동하는 윈도우에서 각 필드의 missing 파싱 상태 비율을 계산하고, 기준값에서 급격히 이동하면 알림을 보내라. 가격 필드가 하룻밤 사이에 결측률 2%에서 60%로 올라간다면 리디자인이 일어난 것이며, 같은 날 이를 잡는 것과 3주 동안 쓸 수 없는 히스토리를 쌓는 것 사이의 차이를 만든다.
수정할 때는 extractor_version을 올리고, 영향을 받은 원본 행들을 새 파서로 재현하라. 그것이 원본 응답을 적재해 둔 것에 대한 보상이다.
보존 및 파티셔닝
원본 랜딩 테이블은 가장 빨리 커지고 가장 적게 읽힌다. fetch 날짜별로 파티션을 나누고, 현실적인 재현 윈도우를 커버할 만큼 오래 보관하며, 행을 삭제하는 대신 오래된 파티션을 드롭하라. 관측값 테이블은 역사적 기록이며 보통 같은 방식으로 파티션을 나눈 채 더 긴 보존 기간을 갖는다.
무엇을 모니터링해야 하는가
| 지표 | 잡아내는 문제 |
|---|---|
| 성공한 fetch 대비 적재된 행 | API와 데이터베이스 사이에서 발생한 로더 손실 |
중복 job_id 충돌 | 재시도 폭주 또는 스케줄링 중복 |
| 필드 및 extractor 버전별 null 비율 | 마크업 변경과 깨진 선택자 |
| 큐 깊이와 로드 지연 | fetch 단계보다 뒤처지는 로더 |
소비된 크레딧 대비 ok로 파싱된 행 | 사용할 수 없는 응답에 지출한 비용 |
마지막 지표가 중요한 비용 관점이다. 요청당 크레딧이 아니라 사용 가능한 행당 크레딧이다. API 자체의 사용량과 오류율은 패널의 Web Scraping API 섹션에서 확인할 수 있다.
FAQ
원본 계층에는 HTML을 저장해야 할까, 추출된 JSON을 저장해야 할까?
추출된 JSON이 훨씬 작고 대체로 충분하다. HTML은 추출 로직을 자주 바꿀 것으로 예상되는 소스에만 저장하고, 그 테이블에는 짧은 보존 기간을 부여하라.
JSONB를 직접 쿼리해도 충분할까?
탐색 목적이라면 그렇다. 제품이나 대시보드가 의존하는 모든 것에는 필드를 타입화된 컬럼으로 승격시켜, 데이터베이스가 타입을 강제하고 일반적인 인덱스를 사용할 수 있게 하라.
변경되지 않은 페이지에 대한 비용 지불을 피하려면 어떻게 해야 할까?
성공한 요청마다 크레딧이 소비되므로, 절약은 덜 쓰는 데서가 아니라 덜 가져오는 데서 와야 한다. 목록 페이지나 사이트맵 페이지 같은 저비용 신호를 사용해 어떤 상세 페이지를 가져올 필요가 있는지 결정하라.
Amazon API에도 이 방식이 적용되는가?
그렇다. 그 응답은 이미 구조화된 JSON이므로 추출 규칙은 필요 없지만, 가격은 여전히 표시용 문자열로 도착하며, 랜딩, 타입화, 드리프트 패턴은 그대로 적용된다.
결론
스크래핑 API는 수집을 해결한다. 수집한 것이 신뢰할 수 있는 상태로 남을지는 데이터베이스 설계가 결정한다. 멱등성 키와 extractor 버전을 붙여 모든 성공 응답을 적재하고, 원본 문자열을 유지하면서 시장별로 값을 타입화하고, 값이 왜 null인지 기록하고, 현재 상태는 유지 관리하는 대신 파생시키고, 필드별 null 비율을 지켜봐서 마크업 변경이 같은 날 드러나게 하라.
Amazon에 특화된 내용은 the best web scraping APIs for Amazon monitoring에서 옵션들을 비교하며, 같은 파이프라인의 부동산 버전은 how real estate companies use web scraping APIs에 있다. 제품은 Web Scraping API 페이지에 있으며, 요금제는 pricing page에 있다.