
무한 스크롤 페이지를 끝까지 긁기 위해 스크롤 반복문을 돌렸는데 전체 항목 수보다 적게 나온다면, 범인은 대부분 document.body.scrollHeight 비교 방식입니다. 서버 응답이 스크립트의 대기 시간보다 늦게 끝나면 스크롤 루프는 “더 이상 늘어날 콘텐츠가 없다”고 착각하고 일찍 종료해 버립니다. 예를 들어 항목이 1,200개인 목록 페이지에서 이 방식을 쓰면 700~800개 선에서 멈추는 현상이 흔하게 나타나는데, 원인은 네트워크가 아니라 수집 스크립트의 판단 로직 쪽에 있습니다.
이 글에서는 왜 이런 누락이 생기는지, 그리고 scrollHeight 비교 대신 어떤 방식으로 바꿔야 끝까지 긁기가 안정적으로 끝나는지를 실제 코드와 함께 정리합니다.
스크롤 자동화가 항목을 놓치는 진짜 원인
무한 스크롤은 사용자가 화면 하단에 닿으면 자바스크립트가 추가 데이터를 비동기로 요청하고, 응답이 오면 그 데이터를 DOM에 덧붙이는 구조입니다. 문제는 이 요청과 응답 사이에 시간차가 있다는 점입니다. Selenium이나 Playwright로 스크롤을 자동화할 때 흔히 time.sleep(1~2초) 같은 고정 대기를 넣는데, 서버 쪽 쿼리가 느려지거나 이미지 레이지로딩 때문에 레이아웃 재계산이 지연되면 이 고정 대기 시간 안에 새 콘텐츠가 다 로드되지 않습니다.
이때 스크립트는 scrollHeight 값이 이전과 같다고 판단해 “끝까지 긁기 완료”로 오인하고 루프를 빠져나갑니다. 실제로는 서버가 아직 다음 페이지 데이터를 내려주는 중인데 말이죠. 두 비동기 작업(스크롤 트리거와 응답 완료)의 순서가 보장되지 않는 전형적인 경쟁상태 문제로, 이 개념은 위키백과의 설명에서도 운영체제·네트워크 프로그래밍 전반에 나타나는 현상으로 다뤄집니다.
scrollHeight 비교, 왜 믿을 수 없을까요?
아래는 네이버 블로그 등에서도 자주 보이는 전형적인 Selenium 스크롤 수집 코드입니다.

last_height = driver.execute_script("return document.body.scrollHeight")
while True:
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
time.sleep(1.5)
new_height = driver.execute_script("return document.body.scrollHeight")
if new_height == last_height:
break
last_height = new_height
items = driver.find_elements(By.CSS_SELECTOR, ".list-item")
print(len(items))
이 코드는 1.5초 안에 새 항목의 DOM 삽입과 높이 재계산이 전부 끝난다는 가정을 깔고 있습니다. 하지만 목록 API 응답 시간이 요청량에 따라 들쭉날쭉하고, 썸네일 이미지가 느리게 로드되면 컨테이너 높이가 뒤늦게 바뀌는 경우도 있습니다. new_height == last_height 조건은 “아직 안 끝남”과 “진짜 끝”을 구분하지 못하므로, 끝까지 긁기는커녕 중간에 멈춘 걸 완료로 착각하게 됩니다. 대기 시간을 5초, 10초로 늘려도 근본 원인은 그대로이고 수집 속도만 느려질 뿐입니다.
네트워크 응답을 가로채면 누락이 줄어듭니다
높이 변화를 감시하는 대신, 목록 데이터를 담은 API 응답 자체를 가로채서 누적하는 방식이 더 안정적입니다. Playwright(Python, 1.4x 이상)는 page.on("response") 이벤트로 네트워크 응답을 직접 받을 수 있습니다.
async def collect_all(page, api_url_pattern):
collected = []
async def on_response(response):
if api_url_pattern in response.url and response.status == 200:
data = await response.json()
collected.extend(data.get("items", []))
page.on("response", on_response)
while True:
prev_count = len(collected)
await page.mouse.wheel(0, 3000)
await page.wait_for_timeout(800)
if len(collected) == prev_count:
await page.wait_for_timeout(1500) # 마지막 요청이 늦게 끝났을 가능성 재확인
if len(collected) == prev_count:
break
return collected
이 방식은 화면에 그려졌는지를 보는 게 아니라 “서버가 실제로 몇 개를 내려줬는지”를 직접 세기 때문에, 응답이 늦어져도 collected 리스트에 그대로 쌓입니다. 무한 스크롤 페이지를 끝까지 긁기 작업에서는 높이 대신 데이터 개수를 완료 기준으로 삼는 편이 훨씬 신뢰할 수 있습니다. 관련 네트워크 이벤트 API는 Playwright 공식 문서의 네트워크 섹션에 정리돼 있습니다.
| 방식 | 동작 원리 | 항목 누락 가능성 | 적합한 상황 |
|---|---|---|---|
| scrollHeight 폴링 | 스크롤 후 높이 변화를 비교 | 높음 (응답 지연 시 조기 종료) | 응답이 빠르고 구조가 단순한 목록 |
| 네트워크 응답 가로채기 | 목록 API의 JSON 응답을 직접 누적 | 낮음 | API 패턴이 명확한 SPA |
| 뷰포트 진입 이벤트 대기 | 관찰 대상 요소가 화면에 들어오는 순간을 기다림 | 중간 | 레이지로딩 이미지·카드형 레이아웃 |

가상화 리스트가 무한 스크롤 수집을 더 어렵게 만드는 구조
일부 목록은 성능을 위해 화면 밖으로 벗어난 항목의 DOM 노드를 아예 지워버리는 가상화(virtualization) 기법을 씁니다. 이런 리스트는 스크롤이 내려가는 동안 보이는 영역의 요소만 DOM에 남기고, 지나간 항목은 언마운트합니다. 네트워크 응답을 가로채는 방식으로도 데이터 자체는 모을 수 있지만, find_elements로 DOM을 직접 긁는 방식이라면 이미 지나간 앞쪽 항목이 사라진 뒤라 누락이 생깁니다.
이런 리스트는 보통 관찰 대상 요소가 뷰포트에 들어오는 순간을 감지하는 방식으로 로딩을 트리거하는데, 이 동작을 표준으로 정의한 인터섹션옵저버 스펙을 보면 threshold, rootMargin 옵션에 따라 트리거 시점이 미묘하게 달라지는 걸 확인할 수 있습니다. 즉 DOM 캡처 시점을 아무리 늦게 잡아도 가상화 리스트에서는 “지금 보이는 것”과 “전체 데이터” 사이에 항상 간격이 생긴다는 전제를 깔고 설계해야 합니다.
이 방식이 통하지 않는 경우도 있습니다
네트워크 응답 가로채기가 만능은 아닙니다. GraphQL처럼 여러 화면의 데이터를 한 번에 묶어서 요청하는 API는 api_url_pattern 하나로 걸러내기 어렵고, 응답 본문 안에서 원하는 목록 필드를 다시 파싱해야 합니다. 또한 커서 기반 페이지네이션 토큰이 짧은 시간 안에 만료되는 사이트라면, 스크롤 속도를 늦춰 안정성을 높이려다가 오히려 토큰이 만료돼 다음 요청이 비어있는 응답으로 돌아오는 경우도 있습니다.
접근 빈도를 짧은 간격으로 반복하면 서버 쪽에서 요청 패턴을 비정상 트래픽으로 보고 응답을 지연시키거나 빈 목록을 내려줄 수도 있습니다. 이 경우 수집 속도를 일부러 늦추는 쪽이 scrollHeight 방식보다 오히려 더 느려 보일 수 있다는 점도 감안해야 합니다. 즉 “누락 없이 긁기”와 “빠르게 긁기”는 종종 상충하는 목표입니다.
다음 수집 스크립트는 이 순서로 점검해 보세요
가장 먼저 확인할 부분은 완료 판정 기준입니다. scrollHeight 비교를 쓰고 있다면 네트워크 응답 개수 비교로 바꾸는 것만으로도 누락 비율이 크게 줄어듭니다. 두 번째로, 목록이 가상화 리스트인지 개발자 도구의 Elements 패널에서 스크롤하며 직접 확인해 보세요. 스크롤을 내릴 때 위쪽 항목의 DOM 노드가 사라진다면 DOM 캡처 대신 응답 가로채기 쪽으로 설계를 바꿔야 합니다. 마지막으로, 완료 조건을 한 번이 아니라 “변화 없음을 두 번 연속 확인”하는 식으로 이중 체크해 두면, 응답이 살짝 늦게 끝난 경우에도 조기 종료를 막을 수 있습니다.
