스크래퍼가 빈 페이지를 반환하나요? JavaScript 때문입니다. 해결 방법을 알려드립니다.
스크래퍼의 body 태그가 비어 있지만 DevTools에서는 가득 차 있습니다. 이는 버그가 아니라 클라이언트 측 렌더링입니다. 이를 확인하는 방법, 데이터를 얻는 세 가지 방법, 그리고 불필요한 브라우저 실행을 피하는 방법을 알려드립니다.
페이지를 가져와 HTML을 출력했는데 원하는 부분이 누락되어 있습니다 — 빈 <body>, 빈 노드 목록, 또는 "JavaScript가 필요합니다"라는 스텁. 반면 브라우저의 검사기에는 데이터가 그대로 있습니다. 이는 스크래퍼의 버그가 아닙니다. 페이지가 JavaScript로 클라이언트 측에서 콘텐츠를 렌더링하고 있으며, HTTP 요청은 JavaScript가 실행되기 전의 빈 껍데기만 받습니다. 이를 확인하는 방법, 데이터를 얻는 세 가지 방법, 그리고 불필요한 헤드리스 브라우저 실행을 피하는 방법을 알려드립니다.
왜 body가 비어 있을까요
원시 HTTP 클라이언트 — Python requests, Node fetch, cURL — 는 서버가 보내는 HTML을 다운로드하고 멈춥니다. JavaScript를 실행하지 않습니다. React, Vue, Angular 또는 Next.js로 구축된 사이트는 거의 비어 있는 HTML 스켈레톤과 데이터를 가져와 브라우저에서 DOM을 구축하는 번들을 전송합니다. BeautifulSoup이나 Cheerio 같은 파서는 스켈레톤을 보게 되므로 DevTools에서 잘 작동하는 선택자가 코드에서는 빈 배열을 반환합니다. React 페이지에서 Cheerio가 []를 반환하는 것은 전형적인 증상입니다; JavaScript를 실행하지 않으며, 실행할 수도 없습니다.
import * as cheerio from "cheerio";
const html = await (await fetch("https://shop.example.com/products")).text();
const $ = cheerio.load(html);
console.log($("div.product").length);
// 0 -> the products are drawn by JavaScript, not in the HTML
10초 안에 확인하기
브라우저를 사용하기 전에 진단을 증명하세요. 두 가지 빠른 검사로 정확히 무엇을 다루고 있는지 알 수 있습니다: 원시 응답과 브라우저가 렌더링한 내용을 비교하고, 콘솔에서 요소를 세어보세요. 콘솔이 원시 HTML에 없는 노드를 찾으면 콘텐츠는 클라이언트 측입니다. 원시 HTML에 이미 있다면, 선택자나 헤더가 문제이지 렌더링이 아닙니다.
# does the raw response actually contain the data?
curl -s https://shop.example.com/products | grep -c 'class="product"'
# 0 -> not in the HTML | 24 -> it IS there, fix your selector
# then, in the browser DevTools console on the same page:
# document.querySelectorAll('div.product').length -> 24
또 다른 가능성을 배제하세요: 빈 body가 항상 JavaScript 때문은 아닙니다. 때때로 서버는 올바른 헤더나 쿠키를 보낼 때까지 콘텐츠를 보류합니다 — 세션 쿠키, 참조자, Accept-Language, 또는 "스크립트"라고 외치지 않는 User-Agent. cURL이 짧은 페이지를 반환하지만 브라우저(전체 헤더 세트를 보내는)가 전체 페이지를 반환한다면, 렌더링을 가정하기 전에 브라우저의 요청 헤더를 재생해보세요. 불필요한 브라우저 실행을 피할 수 있는 5분 테스트입니다.

해결 방법 1: 숨겨진 JSON API 호출 (가장 빠름)
대부분의 사람들이 놓치는 점은 이렇습니다: 페이지가 JavaScript로 자체를 그리는 경우, 데이터는 어딘가에서 옵니다 — 보통 로드 후 프론트엔드가 호출하는 내부 JSON 엔드포인트입니다. Network 탭을 열고 Fetch/XHR로 필터링한 후, 데이터를 반환하는 요청을 찾으세요. 해당 엔드포인트를 직접 호출하는 것이 HTML을 파싱하는 것보다 빠르고 가볍고 훨씬 덜 취약합니다. 브라우저 전체를 건너뛰고 구조화된 JSON을 바로 얻기 때문입니다. 때때로 엔드포인트는 초기 HTML에 포함된 토큰이 필요합니다 — 페이지에서 가져온 후 API를 호출하세요.
// 1) the SPA loads data from its own endpoint after paint
const res = await fetch("https://shop.example.com/api/catalog?page=1", {
headers: { accept: "application/json", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" },
});
const { products } = await res.json();
console.log(products.length); // real data, no browser
// 2) if it needs a token, the initial HTML usually carries it
const page = await (await fetch("https://shop.example.com/")).text();
const token = page.match(/"s_token":\s*"([^"]+)"/)?.[1];
해결 방법 2: hydration JSON 추출
별도의 API 호출이 없더라도, 프레임워크는 초기 상태를 HTML에 직접 임베드하여 클라이언트가 왕복 없이 "수화"할 수 있도록 합니다. Next.js는 이를 <script id="__NEXT_DATA__"> 태그에 담아 전송합니다; 다른 스택은 window.__INITIAL_STATE__ 또는 유사한 blob을 사용합니다. 그 JSON은 이미 다운로드한 원시 응답에 있습니다 — 렌더링이 필요 없습니다. 스크립트 태그를 파싱하면 페이지가 표시할 구조화된 데이터를 종종 얻을 수 있으며, 렌더링된 DOM을 스크래핑하는 것보다 깔끔합니다.
import * as cheerio from "cheerio";
const html = await (await fetch(url)).text();
const $ = cheerio.load(html);
const blob = $("#__NEXT_DATA__").html(); // Next.js embeds page state here
if (blob) {
const data = JSON.parse(blob);
const products = data.props.pageProps.products;
console.log(products.length); // structured, from static HTML
}
숨겨진 API와 수화 추출 사이에서 대부분의 "빈 페이지" 문제는 브라우저를 실행하지 않고도 해결됩니다. JavaScript가 많은 사이트 스크래핑 가이드는 이러한 엔드포인트를 신뢰성 있게 찾는 방법을 더 깊이 다룹니다.
두 가지 모두에 대한 주의 사항: 숨겨진 엔드포인트와 상태 blob은 비공식적인 표면이므로 예고 없이 변경됩니다. 파싱을 체크에 래핑하여 모양이 바뀔 때 크게 실패하도록 하세요 — 누락된 키나 길이가 0인 배열은 조용히 아무것도 반환하지 말고 오류를 발생시켜야 합니다. 이렇게 하면 사이트가 API를 재구조화할 때 빈 데이터셋을 일주일 동안 알아차리지 못하는 대신 로그에 명확한 오류로 나타납니다. 작동했을 때 사용한 User-Agent와 헤더도 고정하세요; 일부 엔드포인트는 조용히 이에 의존합니다.
해결 방법 3: 렌더링 — 하지만 최후의 수단으로 취급하세요
데이터가 노출된 JSON 없이 DOM에서 실제로 빌드되는 경우 JavaScript를 실행해야 합니다. Playwright나 Puppeteer는 실제 브라우저를 구동하고, 콘텐츠를 기다린 후 완성된 HTML을 제공합니다. 어디서나 작동하지만, 이는 비용이 많이 드는 옵션입니다: 렌더링은 JSON 호출의 대역폭과 시간의 약 10-50배의 비용이 들며, 데이터센터 IP에서의 헤드리스 브라우저는 빠르게 차단됩니다. 주거용 프록시를 통해 라우팅하고 고정된 수면이 아닌 올바른 신호를 기다리세요.
import { chromium } from "playwright";
const browser = await chromium.launch();
const context = await browser.newContext({
proxy: { server: "http://gate.quantumproxies.io:8000", username: "USER", password: "PASS" },
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForSelector("div.product"); // wait for data, not a sleep
const html = await page.content();
await browser.close();
Scraper API로 한 번의 호출로 렌더링 및 회전

자체 렌더링이 가치가 없어질 때
자체 브라우저 플릿을 운영한다는 것은 헤드리스 감지, 프록시 회전, 메모리 누수 및 대기 로직을 모든 대상에 대해 관리해야 한다는 것을 의미합니다. 이를 유지 관리하고 있다면, JavaScript를 렌더링하고 주거용 IP를 회전하며 깨끗한 HTML, JSON 또는 markdown을 단일 요청으로 반환하는 Scraper API가 보통 더 적은 코드와 더 높은 성공률을 제공합니다 — 그리고 렌더링된 페이지에서 AI 추출을 실행할 수 있어 선택자를 완전히 건너뛸 수 있습니다. 렌더링 비용 대 일반 요청에 대한 우리의 분석은 언제 전환해야 하는지를 보여줍니다.
자주 묻는 질문
왜 내 웹 스크래퍼가 빈 body를 반환하나요?
페이지가 HTML이 로드된 후 JavaScript로 콘텐츠를 렌더링하기 때문이며, HTTP 클라이언트는 JavaScript를 실행하지 않습니다. 서버가 보내는 빈 스켈레톤을 받습니다. 원시 응답(cURL을 통해)과 DevTools가 보여주는 내용을 비교하여 확인하세요 — 브라우저에 원시 HTML에 없는 데이터가 있다면, 이는 클라이언트 측 렌더링입니다.
브라우저 없이 JavaScript 렌더링된 페이지를 어떻게 스크래핑하나요?
브라우저를 완전히 피하는 두 가지 방법이 있습니다. Network 탭에서 페이지가 호출하는 내부 JSON 엔드포인트를 찾아 직접 요청하거나, HTML에 임베드된 수화 상태(예: __NEXT_DATA__ 스크립트 태그)를 추출하세요. 둘 다 렌더링보다 더 빠르고 신뢰성 있게 구조화된 데이터를 제공합니다.
BeautifulSoup이나 Cheerio가 JavaScript를 실행하나요?
아니요. 둘 다 제공된 HTML 문자열을 파싱하며 JavaScript를 실행하지 않습니다. React, Vue 또는 Next.js 페이지에서는 사전 렌더링된 스켈레톤만 보게 되므로 선택자가 빈 배열을 반환합니다. 데이터를 얻으려면 렌더링된 DOM(Playwright/Puppeteer)이나 기본 JSON이 필요합니다.
스크래핑할 때 'JavaScript가 비활성화되었습니다'라는 메시지를 받는 이유는 무엇인가요?
사이트는 JavaScript를 실행하지 않는 클라이언트를 위한 대체 콘텐츠를 제공합니다, 그리고 스크래퍼가 그렇게 보입니다. 이는 종종 봇 감지와 함께 제공됩니다. 깨끗한 주거용 IP에서 헤드리스 브라우저로 페이지를 실행하면 해결됩니다; 반복되면, 대상이 요청을 지문으로 식별하고 있는 것입니다, 단순히 JavaScript를 확인하는 것이 아닙니다.
빈 페이지는 진단이지 막다른 길이 아닙니다. 클라이언트 측인지 확인하고, 숨겨진 API나 수화 JSON을 먼저 찾고, 데이터가 다른 곳에 살 곳이 없을 때만 렌더링하세요. 더 적은 코드를 작성하고, 대역폭의 일부만 사용하며, 차단될 가능성도 훨씬 적습니다. 이를 유지 관리하고 싶지 않다면, Scraper API가 렌더링과 회전을 처리해줍니다.