누구나 애그리게이터 스크래퍼부터 만듭니다. Cars.com, CarGurus, AutoTrader까지 3개 사이트에 파서 하나씩 붙이면, 주말쯤에는 중고차 시장처럼 보이는 데이터 피드가 완성됩니다.
그러다 누군가 나머지 데이터는 어디에 있는지 묻기 시작합니다.
과제
NADA의 2025년 중반 보고서에 따르면 미국 내 프랜차이즈 경량차량 딜러 수는 16,972개입니다. 누구도 일관되게 집계하지 않는 독립 매매상은 제외한 수치입니다. 동일한 NADA 수치를 인용한 2차 자료들도 15,720개에서 16,990개 사이로 제각각인데, 이는 이 시장의 데이터 집계가 얼마나 불완전한지 보여줍니다.
이들 매장은 저마다 자체 웹사이트를 운영합니다. 사이트에 등록된 매물은 해당 딜러의 보유 차량으로, 제3자 리스팅 사이트에 반영되기 며칠 전에 이미 가격이 책정되어 올라옵니다. 중고차 가격을 산정하거나 잔존 가치를 예측하고, 혹은 딜러 대상 경쟁력 분석 툴을 판매하려는 경우 매장 자체 사이트야말로 필요한 데이터 원천입니다. 애그리게이터 데이터는 지연되고 필터링된 사본에 불과합니다.
결국 팀들은 개별 매장 사이트로 눈을 돌리며 다음 세 가지 사실을 차례로 알게 됩니다.
첫째, 사이트 구조가 제각각이지 않습니다. 거의 모든 사이트가 Dealer.com, DealerOn, Dealer Inspire, CDK, Reynolds, Sincro, Lotlinx 등 소수의 딜러 웹사이트 플랫폼을 기반으로 동작합니다 (분류 기준에 따라 목록은 다를 수 있습니다). 이 데이터를 다루는 곳은 딜러 매장별이 아니라 플랫폼별로 파서를 구축합니다. Apify의 딜러 웹사이트 재고 스크래퍼 역시 명시하고 있듯이, 먼저 플랫폼을 감지한 뒤 데이터를 추출합니다. 몇 가지 템플릿만으로 수만 개의 매장 사이트를 처리할 수 있다는 점은 다행인 부분입니다.
둘째, 가져온 HTML에는 대개 가격 정보가 없습니다. 이들 플랫폼은 가격 영역을 클라이언트 사이드에서 렌더링하며, 예상 결제액과 프로모션 혜택은 추가 호출을 통해 나중에 불러오는 경우가 많습니다. 단순 HTTP 요청만으로는 연식, 제조사, 모델, 주행거리, VIN만 얻을 수 있고 가격 필드는 비어 있게 됩니다.
셋째, 데이터셋을 조용히 망가뜨리는 문제입니다. 딜러 사이트에서는 실패 결과가 정상 데이터와 완전히 동일하게 보입니다. 봇 감지 서비스는 의심스러운 요청에 대해 HTTP 200과 함께 인터스티셜 화면을 반환합니다. 렌더링되지 않은 가격 영역은 DOM에 "Call for Price"를 남기는데, 이는 실제로 딜러가 의도적으로 입력하는 문구이기도 합니다. 결국 두 경우 모두 데이터 웨어하우스에 똑같이 정상적인 레코드로 저장됩니다.
이러한 패턴은 차단 화면이 데이터처럼 보이는 문제를 다루었던 다른 분야에서도 살펴본 바 있습니다. 자동차 도메인은 "가격 미기재"가 명백한 이상 징후가 아니라 정상적인 비즈니스 상태 중 하나이기 때문에 문제가 훨씬 더 까다롭습니다.
비용 단위 분할이 가져오는 변화
안정적으로 유지되는 파이프라인은 사이트별로 구조화되지 않습니다. 요청당 비용을 기준으로 설계됩니다.
가장 비용이 큰 요청은 매장 사이트에 대한 첫 번째 요청입니다. 브라우저를 실행하고, 딜러 플랫폼의 방어 로직을 통과한 뒤 세션을 획득해야 하는 단계입니다. 그 이후의 모든 작업은 첫 요청에서 확보한 세션을 재사용하는 저비용 HTTP 호출로 처리됩니다.
import requests
FOURA = "https://api.foura.ai/api"
AUTH = {"Authorization": "Bearer pk_live_..."}
# First page of a rooftop: render it, clear the defense, keep the session.
first = requests.post(f"{FOURA}/browser", headers=AUTH, json={
"url": "https://example-motors.com/used-inventory/index.htm",
"unblocker": True,
"timeout_ms": 45000,
}).json()
listings = first["body"]
jar = "; ".join(f'{c["name"]}={c["value"]}' for c in first["cookies"])
agent = first["userAgent"]
exit_id = first["proxy"] # opaque proxy ID, send it back to stay on the same exit
그런 다음 브라우저 비용을 다시 들이지 않고 해당 딜러의 나머지 재고를 순회합니다.
page = requests.post(f"{FOURA}/single", headers=AUTH, json={
"method": "GET",
"url": "https://example-motors.com/used-inventory/index.htm?start=20",
"proxy": exit_id,
"headers": [["Cookie", jar], ["User-Agent", agent]],
"validate": {
"status": {"accept": [200]},
"data": {
"accept": ["vehicle-card"],
"fail": ["Just a moment", "Access Denied"]
}
}
}).json()
여기서 두 가지 세부 사항은 겉보기보다 훨씬 더 중요합니다.
세션은 하나의 단위로 이동합니다. clearance cookie는 이를 획득한 exit 및 획득 당시의 User-Agent에 바인딩됩니다. 다른 위치나 다른 User-Agent에서 이를 재전송하면 사이트는 challenge 단계부터 다시 시작하게 만듭니다. 이것이 jar, agent 문자열, proxy ID가 함께 이동해야 하는 이유입니다. 가장 자주 발생하는 실수가 바로 이것입니다. cookie는 유지하면서 exit을 버리고, 왜 저렴한 경로가 더 이상 저렴하지 않은지 의아해합니다.
validate 블록은 무음 실패(silent-failure) 문제를 해결합니다. 이는 실제 페이지가 어떤 형태여야 하는지 request 수준에서 명시하는 것입니다. 매물 그리드가 렌더링된 후에만 존재하는 마커를 수락하고, interstitial 문자열이 나타나면 실패로 처리합니다. 이러한 규칙을 충족하지 못한 response는 price가 null인 row가 아닙니다. 이는 실패로 분류되며 성공으로 집계되지 않습니다. 자동차 분야에서는 긍정 마커와 부정 목록을 모두 작성해야 합니다. "Call for Price"는 실제로 모호할 수 있지만, "차량 카드가 렌더링되지 않음"은 절대 모호하지 않기 때문입니다.
특정 플랫폼에 어떤 경로가 필요한지 아직 모르는 경우, Auto는 단 한 번의 호출로 이를 확인하고 성공한 세션을 반환합니다. 이를 프로덕션 경로가 아닌 정찰 단계로 활용하십시오. 한 플랫폼에는 브라우저가 필요하고 인접 플랫폼에는 필요하지 않다는 점을 파악했다면, 각각을 direct 엔진에 고정하고 매일 밤 오케스트레이터가 동일한 결과를 재탐색하느라 비용을 낭비하지 않도록 하십시오.
Results
중간 규모 작업(특정 고객이 아닌 업계 벤치마크 기반의 예시 시나리오)을 기준으로 계산해 보십시오. 4,000개 대리점, 각 대리점당 약 180대의 중고차, 매일 밤 갱신되는 조건입니다.
- 720,000페이지 대신 4,000페이지 렌더링. 대리점당 1회의 렌더링으로 세션을 열고, 나머지 716,000페이지는 동일한 세션의 저렴한 경로를 통해 처리됩니다. 매일 밤 수집 비용을 감당할 수 있는지는 파서가 아니라 바로 이 비율이 결정합니다.
- 두 가지 유형의 가격 누락을 서로 다른 테이블로 분리. validate 규칙을 적용하면 "딜러가 가격을 게시하지 않음"과 "페이지를 가져오지 못함"이 동일한 row 형태를 공유하지 않게 됩니다. 모델은 전자만 확인하게 됩니다.
- 대리점별이 아닌 플랫폼별 단일 파서 운영. response에서 플랫폼을 감지하고 해당 HTML을 전용 파서로 전달합니다. 이미 지원되는 플랫폼에 속한 새 대리점은 온보딩 비용이 들지 않습니다.
- 플랫폼을 변경한 딜러는 명확하게 실패를 발생시킵니다. 플랫폼 감지에 실패하면 row가 기록되지 않으며, 6주 동안 잘못된 가격 데이터가 조용히 쌓이는 대신 담당자에게 티켓이 발송됩니다.
더 까다로운 점도 있습니다. 대형 딜러 그룹은 플랫폼 외부에서 자체 구축 사이트를 운영하는 경우가 늘고 있으며, 이러한 사이트는 여전히 유지 관리가 필요한 수작업 파서가 필요합니다. 대리점 데이터의 최신성 역시 균일하지 않습니다. 일부 플랫폼은 재고 페이지를 강력하게 캐싱하므로, 아무리 자주 수집해도 "오늘의 가격"이 하루 전 데이터일 수 있습니다. 모델이 모든 대리점의 타임스탬프를 동일하게 실시간 데이터로 취급한다면, 어떤 수집 인프라로도 해결할 수 없는 오류가 발생합니다.
Key Takeaway
자동차 데이터 수집에서 까다로운 부분은 모두가 벤치마킹하는 3개 대형 애그리게이터가 아니었습니다. 개별로는 전용 스크래퍼를 구축할 가치가 없지만, 합쳐지면 막대한 가치를 지니는 17,000개의 소규모 사이트가 문제였습니다.
이러한 패턴은 자동차 분야 외에서도 광범위하게 나타납니다. 약국, 장비 딜러, 지역 식료품점, 프랜차이즈 등 모든 롱테일 영역은 각 대상을 고유한 사이트로 취급할 때만 비용이 커 보입니다. 하지만 실제로는 그렇지 않은 경우가 많습니다. 첫 번째 request를 올바르게 처리하고 session을 재사용 가능하게 만들면, 남은 작업은 데이터 수집 문제가 아닌 파싱 문제로 전환되며, 이는 전자보다 비용이 훨씬 적게 듭니다.