무한 스크롤 피드를 스크래핑하는 가장 좋은 방법은 대개 스크롤을 아예 하지 않는 것이다. 대부분의 피드 뒤에는 페이지네이션된 요청이 존재하며, 흔히 커서 기반이고, 그 요청을 직접 재현하는 것이 브라우저를 구동하는 것보다 더 빠르고 저렴하며 안정적이다. 이 접근법은 페이지네이션과 무한 스크롤을 안정적으로 스크래핑하는 방법에서 자세히 다룬다.
이 가이드는 그 방법이 통하지 않는 경우를 위한 것이다. 요청이 단기 토큰으로 서명되어 있는 경우. 응답이 페이지가 클라이언트 측에서 디코딩하는 불투명한 블록인 경우. 피드가 요소가 뷰포트에 들어올 때만 로드되는 경우. “다음 페이지”가 재구성할 수 없는 상태에 연결된 버튼인 경우. 이런 경우에는 실제 렌더링 내부에서 스크롤과 클릭을 처리해야 하며, 이때 잘못될 수 있는 몇 가지 지점이 있다.
예제에서는 Shifter Web Scraping API를 사용하는데, 이는 헤드리스 Chrome에서 페이지를 실행하고 캡처 전에 일련의 브라우저 액션을 받아들인다.
동적 페이지네이션의 네 가지 유형
무엇이든 작성하기 전에 자신이 어떤 유형을 다루고 있는지 파악해야 한다. 유형마다 필요한 기법이 다르기 때문이다.
| 패턴 | 다음 배치를 트리거하는 것 | 기법 |
|---|---|---|
| 스크롤 트리거 피드 | 하단 근처의 요소가 뷰포트에 진입 | 센티널까지 스크롤, 대기, 반복 |
| 더 보기 버튼 | 항목을 추가하는 버튼 클릭 | 클릭, 새 항목 대기, 반복 |
| URL 상태 페이지네이션 | 결과를 넘길 때 URL이 갱신됨 | 해당 URL을 직접 가져옴, 스크롤 불필요 |
| 가상화된 리스트 | 스크롤하지만 새 행이 나타날 때 기존 행이 DOM에서 제거됨 | 구간 단위로 캡처하거나 기저 요청을 사용 |
세 번째 유형은 처리 비용이 가장 저렴하므로 가장 먼저 확인할 가치가 있다. 일반 브라우저에서 피드를 스크롤하며 주소창을 지켜보라. page나 offset 파라미터가 변경된다면 사이트는 이미 페이지네이션된 URL을 제공하고 있는 것이며, 각 페이지는 평범한 요청이다.
네 번째 유형은 데이터를 조용히 누락시키는 경우이며, 아래에서 별도로 다룬다.
렌더링과 올바른 대기 처리
모든 동적 기법은 동일한 두 가지 제어로 시작한다. render_js=1은 헤드리스 Chrome에서 페이지를 실행하며, 정적 요청과 마찬가지로 성공한 요청당 1크레딧이 소요된다. wait_for_css는 셀렉터가 DOM에 존재할 때까지 캡처를 보류하므로, 아직 채워지지 않은 페이지에서 추출하는 일을 방지한다. 렌더링 제어는 자바스크립트 렌더링에 문서화되어 있다.
대기 셀렉터는 신중하게 선택해야 한다. 리스트 컨테이너를 기다리는 것만으로는 부족한데, 많은 프레임워크가 먼저 빈 컨테이너를 렌더링한 뒤 나중에 채우기 때문이다. 컨테이너 대신 리스트의 첫 번째 항목을 기다려라.
스크롤 트리거 피드: 센티널까지 스크롤
브라우저 액션은 js_instructions에 들어가며, 캡처 전에 순서대로 실행되는 JSON 배열이다. 문서화된 액션은 scrollTo, click, wait이다.
스크롤 트리거 피드의 패턴은 리스트 뒤에 위치한 요소까지 스크롤하고, 다음 배치가 로드되기를 기다린 후, 반복하는 것이다. 리스트가 커짐에 따라 그 요소는 매번 더 아래로 이동하므로, 그 요소로 다시 스크롤하면 다음 로드가 트리거된다.
import json
import os
import requests
API = "https://scrape.shifter.io/v1"
instructions = [
{"action": "click", "selector": "button.accept-cookies"},
{"action": "scrollTo", "selector": "footer", "timeout": 5000, "block": "start"},
{"action": "wait", "duration": 2000},
{"action": "scrollTo", "selector": "footer", "timeout": 5000, "block": "start"},
{"action": "wait", "duration": 2000},
{"action": "scrollTo", "selector": "footer", "timeout": 5000, "block": "start"},
{"action": "wait", "duration": 2000},
]
rules = {
"items": {
"selector": "article.card",
"type": "list",
"item": {
"link": {"selector": "a.card-link", "output": "@href"},
"name": {"selector": "h3", "output": "text"},
"price": {"selector": ".price", "output": "text"},
},
}
}
params = {
"api_key": os.environ["SHIFTER_API_KEY"],
"url": "https://shop.example.com/category/shoes",
"render_js": 1,
"wait_for_css": "article.card",
"js_instructions": json.dumps(instructions),
"extract_rules": json.dumps(rules),
}
resp = requests.get(API, params=params, timeout=120)
resp.raise_for_status()
items = resp.json()["items"]
여기에는 몇 가지 의도적인 세부 사항이 있다.
쿠키 배너는 먼저 닫는데, 오버레이가 스크롤이나 클릭을 가로챌 수 있기 때문이다. 스크롤 사이의 대기는 네트워크 요청이 완료되고 DOM이 갱신될 시간을 준다. 너무 짧으면 배치가 도착하기 전에 캡처하게 된다. list 타입의 extract_rules는 모든 카드를 JSON 객체로 반환하므로 별도의 파서가 필요 없으며, requests가 두 JSON 파라미터를 모두 URL 인코딩해준다. 추출 문법은 추출 규칙에 있다.
대규모로 실행하기 전에 샘플 페이지에서 체인을 테스트하고, 해당 사이트가 실제로 얼마나 빨리 로드되는지에 맞춰 스크롤 단계 수와 대기 시간을 조정하라.
더 보기 버튼: 클릭한 후 증가를 기다림
더 보기 버튼은 트리거만 다를 뿐 동일한 루프다.
[
{"action": "click", "selector": "button.load-more", "timeout": 3000},
{"action": "wait", "duration": 2000},
{"action": "click", "selector": "button.load-more", "timeout": 3000},
{"action": "wait", "duration": 2000}
]
두 가지가 사람들을 곤혹스럽게 한다. 버튼 셀렉터는 로딩 중에 상태가 바뀌는 경우가 많아, disabled 클래스가 붙거나 스피너가 나타나므로, 대기 시간이 충분하지 않으면 버튼이 다시 클릭 가능해지기 전에 두 번째 클릭이 발생할 수 있다. 그리고 더 이상 로드할 것이 없으면 버튼이 대체로 사라지는데, 이는 완료를 알리는 유용한 신호이지만 체인이 마지막 클릭들이 대상 없이 실행되는 상황을 견뎌내야 한다는 뜻이기도 하다. 이번에도 실제 사이트에서 테스트하라.
단일 렌더링의 시간 예산
위의 모든 작업은 시간 제한이 있는 하나의 브라우저 세션 안에서 일어난다. wait_for_css는 기본적으로 30초 후 타임아웃되며, timeout은 브라우저가 페이지에 소비할 수 있는 시간을 제한한다. 수천 개의 항목을 가진 피드는 스크롤 단계를 아무리 연결해도 하나의 렌더링 안에서 완전히 로드되지 않는다.
그러므로 체인을 길게 늘리기보다 문제를 분할하라.
결과 집합을 좁혀라. 필터, 정렬 순서, 카테고리 패싯은 대체로 더 작은 피드를 만들어낸다. 각각 완전히 로드되는 스무 개의 좁은 피드가, 결코 끝나지 않는 하나의 거대한 피드보다 더 신뢰할 수 있다.
필터가 적용된 URL을 페이지 단위로 순회하라. 많은 사이트가 필터를 URL 상태와 결합하는데, 이는 무한 스크롤을 유한한 개수의 평범한 요청으로 바꿔준다.
피드가 정말로 길어서 좁힐 수 없을 때는 기저 요청으로 대체하라. 렌더링 없이 가져오라. JSON을 반환하는 엔드포인트라면 auto_parser=1이 파싱된 본문을 반환한다.
가상화된 리스트: 조용한 데이터 손실
일부 피드, 특히 매우 긴 피드는 리스트 가상화를 사용한다. 뷰포트 근처의 행만 DOM에 존재한다. 아래로 스크롤하면 상단의 행이 제거된다.
가상화된 리스트를 맨 아래까지 스크롤하고 캡처하면, 스크롤해서 지나온 모든 항목이 아니라 마지막 화면의 항목만 얻게 된다. 아무 오류도 발생하지 않는다. 추출 결과는 깔끔하고 그럴듯해 보이지만 불완전한 리스트다.
어떤 출력이든 신뢰하기 전에 이를 탐지하라. 스크롤 체인을 실행한 후, 첫 화면의 항목들이 캡처된 결과에 여전히 남아 있는지 확인하라. 사라졌다면 해당 리스트는 가상화된 것이며, 스크롤 후 캡처하는 방식은 작동할 수 없다. 대신 URL 상태 페이지네이션이나 기저 요청을 사용하라.
요청 간 동적 페이지네이션
각 페이지가 세션에 저장된 커서와 같이 서버 측 상태에 의존하는 별도의 요청일 때는, session_id를 사용해 순회하는 동안 그 상태를 일정하게 유지하라. 세션은 요청 간에 쿠키, 브라우저 상태, 상위 IP를 유지하며, 10분간 유휴 상태가 지속되면 만료된다. 세션이 유지되는 동안에는 country를 동일하게 유지하라. 중간에 전환하면 로케일에 결부된 쿠키가 무효화될 수 있기 때문이다. 자세한 내용은 세션과 프록시에 있다.
params.update({
"session_id": "shoes-walk-07",
"country": "de",
})
순회를 계속 진행되게 유지하라. 페이지 사이에 코드가 느린 작업을 하는 동안 유휴 상태로 있는 세션은 만료되어 커서가 깨진다.
모든 것을 다 가져왔는지 확인하기
일찍 멈춘 스크래퍼는 완료된 스크래퍼와 정확히 똑같아 보인다. 모든 실행에 완전성 검사를 내장하라.
- 표시된 총계와 비교하라. 많은 피드가 결과 개수를 표시한다. 페이지가 1,284라고 말하는데 960개를 추출했다면, 일찍 멈춘 것이다.
- 안정적인 키로 중복을 제거하라. 위치가 아니라 항목의 링크나 ID 같은 것을 사용해야 한다. 스크롤 피드는 항목을 자주 다시 렌더링하며, 콘텐츠가 삽입되면서 위치가 바뀐다.
- 종료 표시를 확인하라. 사라진 더 보기 버튼이나 결과 종료 메시지는 긍정적인 확인 신호다. 마지막 단계 이후에도 이것이 없다면 체인이 너무 짧았다는 뜻이다.
- 시간에 따른 완전성을 추적하라. 어제 1,200개 항목을 산출했던 카테고리가 오늘 400개라면, 대체로 재고가 아니라 마크업이나 로딩 방식이 바뀐 것이다.
크레딧과 지연 시간: 사이트별 접근법 선택
| 접근법 | 크레딧 | 지연 시간 | 신뢰성 위험 |
|---|---|---|---|
| 기저 요청 재현 | 직접 전송 시 없음; API를 통하면 페이지당 1 | 낮음 | 서명되거나 불투명한 요청 |
| URL 상태 페이지네이션 | 페이지당 1 | 낮음에서 보통 | 페이지네이션된 URL 필요 |
| 하나의 렌더링 내 스크롤 또는 클릭 체인 | 전체 체인에 1 | 높음 | 시간 예산, 가상화 |
여러 배치를 스크롤하는 하나의 렌더링은 1크레딧이 드는 반면, 동일한 배치를 개별 요청으로 재현하면 요청마다 1크레딧이 든다. 그 대가는 지연 시간과 시간 예산이다. 긴 체인은 더 느리고 타임아웃될 가능성이 더 높다. 기저 요청이 비실용적인 적당한 규모의 피드에는 스크롤 체인을 사용하고, 그 밖의 경우에는 다른 두 방법을 사용하라.
FAQ
무한 스크롤에는 항상 자바스크립트를 렌더링해야 하는가?
아니다. 먼저 URL 상태 페이지네이션과 재현 가능한 기저 요청을 확인하라. 렌더링은 기본값이 아니라 대체 수단이다.
체인에 스크롤 단계는 몇 개가 필요한가?
시간 예산 안에서 원하는 배치를 로드하는 데 사이트가 필요로 하는 만큼이다. 샘플 페이지에서 측정하고, 예산이 허용하는 것보다 더 많은 단계가 필요하다면 피드를 좁혀라.
추출 결과가 스크롤해서 지나온 것보다 항목이 적게 나오는 이유는 무엇인가?
거의 항상 리스트 가상화 때문이며, 스크롤하면서 행이 DOM에서 빠져나간다. 첫 화면의 항목이 캡처 시점까지 살아남는지 확인하라.
스크롤 단계마다 크레딧이 드는가?
아니다. 전체 체인은 하나의 요청 안에서 실행되며, 성공한 요청 하나당 1크레딧이다.
결론
무한 스크롤은 앞에 브라우저가 붙은 커서 페이지네이션일 뿐이며, 커서에 직접 도달할 수 있다면 그렇게 해야 한다. 그럴 수 없을 때는 렌더링 내부에서 처리하라. 컨테이너가 아니라 첫 번째 항목을 기다리고, 센티널까지 스크롤하거나 더 보기를 클릭하되 단계 사이에 충분한 대기 시간을 두고, 피드를 좁혀 시간 예산을 존중하고, 캡처를 신뢰하기 전에 가상화 여부를 테스트하고, 사이트 자체의 총계와 대조하여 완전성을 검증하라.
렌더링된 API 요청이 애초에 적절한 도구인지 판단하는 방법은 자바스크립트가 많은 사이트에 웹 스크래핑 API가 필요한 경우를 참고하라. 제품 정보는 JS 렌더링을 지원하는 웹 스크래핑 API 페이지에 있으며, 요금제는 가격 페이지에 있다.