페이지네이션은 스크래핑에서 사소한 부분처럼 보인다. page=2를 증가시키고, 다음 버튼이 없을 때까지 반복하면 끝이다. 하지만 이곳이야말로 스크래퍼가 조용히 데이터를 가장 자주 잃어버리는 곳이며, 그 손실은 티가 나지 않는다. 1페이지부터 8페이지까지는 수집했지만 사이트가 상한선을 두어 9페이지부터 40페이지까지는 놓쳤는데도 아무런 오류가 발생하지 않는다. 또는 목록이 그 사이에 바뀌어서 항목을 중복으로 세기도 한다. 또는 로딩이 잠시 멈췄을 뿐인데 이를 끝으로 착각해서 일찍 멈추기도 한다. 마지막 항목을 정확히 한 번만 수집하고, 그것을 수집했다는 사실을 아는 것, 이것이 전부다.
이 문제에는 두 가지 형태가 있다. 클래식 페이지네이션과 무한 스크롤이며, 이 둘은 하나의 핵심 질문을 공유한다. 어떻게 모든 것을 정확히 한 번씩 수집하고, 언제 실제로 끝난 것인지 알 수 있는가? 두 경우 모두에 대한 답을 아래에서 다룬다.
클래식 페이지네이션: 어떤 종류인지 파악하라
루프를 작성하기 전에 페이지네이션 방식을 먼저 파악해야 한다. 흔한 세 가지 방식 중 두 가지는 데이터를 손상시키는 함정을 가지고 있기 때문이다.
오프셋 또는 페이지 번호 페이지네이션(?page=5 또는 ?offset=100&limit=25)은 가장 흔하면서도 가장 위험하다. 두 가지 문제가 생긴다. 첫째는 드리프트다. 활성화된 사이트에서 흔히 그렇듯 요청 사이에 하부 목록이 바뀌면, 새 항목이 맨 위에 추가되면서 모든 것이 아래로 밀리고, 2페이지가 1페이지와 겹치면서 그 사이에서 항목이 빠지게 된다. 위치로 중복 제거를 하지 말고, 안정적인 고유 ID로 중복 제거를 해야 한다. 둘째는 딥 페이지네이션 상한선이다. 많은 사이트가 100페이지나 오프셋 10,000을 넘어서는 요청을 거부하기 때문에, 오프셋 방식으로는 끝부분에 도달할 수 없다. 이 벽에 부딪히면 페이지를 넘기는 방식으로는 끝까지 갈 수 없으며, 날짜 범위, 카테고리, 가격대 등 다른 방식으로 데이터를 분할한 다음 각 조각 안에서 페이지네이션을 해야 한다. 이때 어느 조각도 상한선을 넘지 않도록 해야 한다.
커서 또는 키셋 페이지네이션(?after=<token>)은 견고한 방식이다. 응답이 다음 배치를 위한 커서를 건네주면 이를 따라가고, 커서가 없으면 멈춘다. 커서는 이동하는 오프셋이 아니라 데이터 내의 안정적인 위치를 가리키기 때문에 드리프트에 영향을 받지 않는다. 사이트가 이를 제공한다면 항상 이 방식을 우선하고, 절대 커서를 직접 만들거나 추측하려 하지 말고, 불투명한 값으로 취급해서 주어진 것만 그대로 따라가야 한다.
cursor, seen = None, set()while True: resp = fetch(url, params={"after": cursor} if cursor else {}) for item in resp["items"]: if item["id"] not in seen: # dedup by stable id, never by position seen.add(item["id"]); yield item cursor = resp.get("next_cursor") if not cursor: # absent cursor is the real end signal break여기서 가장 유용한 방법은 렌더링된 HTML이 아니라 네트워크 계층을 살펴보는 것이다. 대상 사이트를 브라우저에서 열고 XHR/fetch 요청을 관찰해 보면, 페이지 뒤에 커서 페이지네이션을 사용하는 깔끔한 JSON 엔드포인트가 숨어 있는 경우를 매우 자주 발견하게 된다. 이 엔드포인트를 직접 호출하는 것이 페이지별로 HTML을 스크래핑하는 것보다 더 빠르고, 대역폭 부담이 적으며, 훨씬 더 안정적이다.
무한 스크롤: 대개 커서 페이지네이션을 위장한 것에 불과하다
무한 스크롤은 실제 브라우저를 거의 필요로 하지 않는다. 내부적으로 보면 스크롤은 그저 동일한 페이지네이션된 fetch를 트리거할 뿐이며, 네트워크 탭에서 이 하부 요청을 찾아낼 수 있다면, API를 다루듯 그 커서와 함께 직접 호출하고 브라우저를 완전히 건너뛸 수 있다. 이 방식은 대역폭과 시간 면에서 훨씬 저렴하다.
사이트가 정말로 렌더링을 요구하는 경우에는 Playwright나 Puppeteer로 구동하되, 누구나 빠지는 세 가지 함정에 유의해야 한다.
첫째, 무엇이 로드를 트리거하는지 파악하라. 스크롤 위치, 하단 근처의 IntersectionObserver 센티널, 또는 “더 보기” 버튼일 수 있다. 올바른 것을 트리거해야 한다.
둘째, 고정된 타이머가 아니라 새 배치가 실제로 도착할 때까지 기다려야 한다. sleep(2)는 경쟁 상태를 만든다. 콘텐츠가 이미 로드된 경우도 있고 아직 로드되지 않은 경우도 있으며, 느린 프록시 홉에서는 후자가 자주 발생한다. 항목 수가 늘어나거나 네트워크가 유휴 상태가 될 때까지 기다려야 한다.
prev = 0while True: page.mouse.wheel(0, 20000) # trigger the next batch page.wait_for_function( # wait for real arrival, not a timer "n => document.querySelectorAll('.item').length > n", arg=prev) items = page.query_selector_all('.item') if len(items) == prev: # no growth after a real wait break # ...but see termination below prev = len(items)셋째, 그리고 가장 조용히 데이터를 잘라먹는 것은 가상화된 목록이다. react-window 같은 라이브러리는 성능을 위해 화면 밖의 행을 DOM에서 제거한다. 따라서 맨 아래까지 스크롤한 다음에야 DOM을 스크래핑하면, 마지막으로 보이는 창만 얻고 나머지는 얻지 못한다. 스크롤하는 동안 항목이 나타날 때마다 추출해야지, 끝난 후 한 번에 추출해서는 안 된다.
언제 실제로 끝난 것인지 아는 법
종료 시점을 판단하는 것이 가장 어려운 부분이다. “로딩이 멈췄다”는 사실은 서로 완전히 다른 두 가지 원인에서 비롯될 수 있는데, 둘은 똑같이 보인다. 진짜 끝에 도달한 것이거나, 사이트가 시퀀스 중간에 속도 제한을 걸어서 더 이상 조용히 데이터를 제공하지 않게 된 것이다. 후자를 전자로 착각하는 것이 바로 끝부분이 빠진 데이터셋을 만들어내는 방식이다.
세 가지 방어책이 있다. 멈춘 로딩을 끝으로 단정하기 전에 몇 번 재시도해서, 느린 배치를 완료로 오인하지 않도록 하라. 사이트가 총 개수를 노출하는 경우 이를 대조하라. “1,240개 결과”라는 헤더는 일종의 체크섬이다. 900개만 수집했다면 완료된 것이 아니라 잘린 것이다. 그리고 요청이 몰린 직후 로딩이 멈추는 경우는 끝이 아니라 소프트 차단으로 의심하고, 부분적인 데이터를 그대로 받아들이는 대신 새로운 아이덴티티로 그 끝부분을 재시도하라. 이는 파이프라인 모니터링의 채움률 및 예상 개수 확인이 전체 실행에 걸쳐 잡아내려는 것과 동일한 조용한 실패 문제다.
중복 제거와 완전성, 항상
완전한 데이터셋과 그럴듯해 보이지만 불완전한 데이터셋 사이의 차이를 만드는 것은 두 가지 습관이다. 페이지 번호나 순서, 또는 행 전체의 콘텐츠 해시가 아니라 안정적인 고유 ID로 중복 제거를 하라. 순서는 바뀌고 행은 가볍게 수정되기 때문이다. 그리고 사이트가 총 개수를 제공하는 곳에서는 수집된 개수 대 예상 개수를 항상 추적해서, 잘림이 한참 뒤에야 알아채는 빈틈이 아니라 맞아떨어지지 않는 숫자로 드러나게 하라.
프록시가 들어맞는 지점
페이지네이션된 시퀀스는 대개 하나의 논리적 세션이며, 그렇게 보여야 한다. 스티키 세션을 사용해서 하나의 쿼리의 모든 페이지가 동일한 IP로 나가도록 하라. 시퀀스 중간에 로테이션하면 한 명의 방문자가 일관되게 페이지를 넘길 것으로 기대하는 안티봇 시스템을 자극할 수 있고, 개인화되거나 지역에 따라 달라지는 사이트에서는 페이지 간에 일관되지 않은 결과가 반환될 수도 있다. 각 쿼리에 자체 스티키 세션을 부여하고, 쿼리 간에만 로테이션하라.
딥 페이지네이션은 또한 짧은 시간 안에 단일 호스트로 많은 요청을 보낸다는 뜻이며, 이것이 바로 속도 제한과 차단을 당하는 지점이다. 시퀀스의 속도를 조절하고, 429에 대해 밀어붙이기보다는 물러서며, 처음부터 차단당하는 빈도를 줄이기 위해 깨끗한 풀에 의존하라. 시퀀스 중간의 차단이 끝처럼 위장하기 때문에, 더 나은 IP 평판은 여기서 두 가지 역할을 한다. 잘림 자체를 줄이는 동시에, 예상 개수 확인과 결합되면 실제로 발생한 잘림을 조용히 넘어가지 않고 눈에 띄게 만든다.
결론
페이지네이션은 사소한 부분이 아니라, 완전성이 결정되는 지점이다. 실제 메커니즘을 파악하고, 오프셋과 HTML 방식보다 커서 페이지네이션이나 하부 JSON 엔드포인트를 우선하라. 위치가 아니라 안정적인 ID로 중복 제거를 하라. 무한 스크롤에서는 가능하면 하부 fetch를 직접 호출하고, 렌더링이 꼭 필요할 때는 타이머가 아니라 실제 로딩을 기다리고, 가상화된 목록이 데이터를 삼키지 않도록 항목이 나타날 때마다 추출하라. 무엇보다도, 예상 개수 확인이나 재시도된 끝부분이 진짜 끝임을 증명하기 전까지는 “로딩이 멈췄다”는 상태를 의심하고, 각 페이지네이션 쿼리를 자체 스티키 세션에서 실행해서 시퀀스가 일관되게 유지되도록 하라.
이렇게 하면 전체 목록을 정확히 한 번씩 수집하고, 그것을 수집했다는 사실도 알 수 있다. 깨끗한 레지덴셜 게이트웨이로 시퀀스를 보내면 끝부분이 차단의 벽으로 변하지 않으며, GB당 가격을 이용하면 요청당 계량이 발목을 잡는 일 없이 딥 페이지네이션을 크롤링할 수 있다.