PHP는 여전히 웹의 많은 부분을 차지하고 있다: 쇼핑몰, CMS, 내부 도구, 고객 포털 등이다. 이런 애플리케이션 중 하나가 경쟁사 가격, 제품 정보, 목록 같은 다른 사이트의 데이터가 필요할 때, 흔히 선택하는 방법은 file_get_contents로 페이지를 가져와 정규식으로 파싱하는 것이다. 이는 첫날에는 작동하지만, 사이트가 서버의 IP를 차단하거나 마크업을 변경하는 열흘째가 되면 깨진다.
web scraping API는 이런 취약함을 대부분 제거해 준다. 자체 프록시와 브라우저를 통해 페이지를 가져오고, 실패 시 재시도하며, 요청한 필드만 JSON으로 반환할 수 있다. 이 튜토리얼에서는 curl 확장 외에 의존성 없이 하나의 작은 PHP 클라이언트를 만들고, 이를 애플리케이션에서 사용하는 방법을 보여준다.
클라이언트가 해야 할 일
실제 사용의 대부분은 네 가지로 요약된다:
- API 키와 대상 URL을 담아 요청을 전송한다.
- 추출 규칙과 함께 구조화된 데이터를 요청하여, 애플리케이션이 HTML을 파싱할 필요가 없게 한다.
- 올바른 오류만 재시도하고, 절대 성공하지 않을 오류는 즉시 멈춘다.
- 플랜의 동시성 한도를 초과하지 않으면서 여러 페이지를 병렬로 가져온다.
스크래핑 API를 처음 사용해본다면, 초보자 가이드에서 요청 형식과 상태 코드를 더 자세히 다루고 있다.
클라이언트
<?php
final class ShifterScraper
{
private const RETRY = [408, 422, 429, 500];
public function __construct(
private string $apiKey,
private string $endpoint = 'https://scrape.shifter.io/v1',
private int $attempts = 3,
private float $backoffSeconds = 2.0,
) {}
/** Fetch one page; with $extractRules, returns the decoded JSON instead of HTML. */
public function fetch(string $url, array $extractRules = [], bool $renderJs = false): string|array
{
$params = ['api_key' => $this->apiKey, 'url' => $url];
if ($renderJs) {
$params['render_js'] = 1;
}
if ($extractRules) {
$params['extract_rules'] = json_encode($extractRules);
}
for ($attempt = 1; ; $attempt++) {
[$status, $body] = $this->get($this->endpoint . '?' . http_build_query($params));
if ($status === 200) {
return $extractRules ? json_decode($body, true, flags: JSON_THROW_ON_ERROR) : $body;
}
if (!in_array($status, self::RETRY, true) || $attempt >= $this->attempts) {
throw new RuntimeException("HTTP {$status} for {$url}: " . substr($body, 0, 200));
}
usleep((int) ($this->backoffSeconds * $attempt * 1_000_000));
}
}
/** Fetch many pages at once with curl_multi, at most $concurrency in flight. */
public function fetchMany(array $urls, array $extractRules = [], int $concurrency = 10): array
{
$results = [];
foreach (array_chunk($urls, $concurrency) as $batch) {
$multi = curl_multi_init();
$handles = [];
foreach ($batch as $url) {
$params = ['api_key' => $this->apiKey, 'url' => $url];
if ($extractRules) {
$params['extract_rules'] = json_encode($extractRules);
}
$handles[$url] = $this->handle($this->endpoint . '?' . http_build_query($params));
curl_multi_add_handle($multi, $handles[$url]);
}
do {
curl_multi_exec($multi, $running);
curl_multi_select($multi);
} while ($running > 0);
foreach ($handles as $url => $h) {
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
$body = curl_multi_getcontent($h);
$results[$url] = $status === 200
? ($extractRules ? json_decode($body, true) : $body)
: $this->fetchOrNull($url, $extractRules); // retry failures one by one
curl_multi_remove_handle($multi, $h);
}
curl_multi_close($multi);
}
return $results;
}
private function fetchOrNull(string $url, array $extractRules): string|array|null
{
try {
return $this->fetch($url, $extractRules);
} catch (RuntimeException) {
return null;
}
}
private function handle(string $requestUrl): CurlHandle
{
$h = curl_init($requestUrl);
curl_setopt_array($h, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120]);
return $h;
}
private function get(string $requestUrl): array
{
$h = $this->handle($requestUrl);
$body = curl_exec($h);
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
return [$status, $body === false ? curl_error($h) : $body];
}
}
몇 가지 설계상의 선택을 짚어볼 만하다:
- 도움이 되는 경우에만 재시도한다. 타임아웃(
408), 렌더링 실패(422), 속도 제한(429), 서버 오류(500)는 점점 길어지는 대기 시간과 함께 재시도한다. 유효하지 않은 키 같은 그 외의 경우는 즉시 예외를 던진다. - 추출 규칙은 배열로 전달한다. 규칙이 있으면
fetch()는 디코딩된 JSON을 반환하므로, 호출하는 코드는 마크업이 아니라$product['price']와 같은 형태로 작업할 수 있다. curl_multi를 이용한 병렬 요청.fetchMany()는 한 번에 한 배치의 요청을 보내고 모두 완료되기를 기다린다. 배치 크기는 플랜의 동시성 한도 이하로 맞춰진다. 배치 안에서 실패한 페이지는 재시도 로직을 가진fetch()를 통해 한 번 더 기회를 얻으며, 그래도 실패하면null로 반환된다.
사용 방법
<?php
require __DIR__ . '/ShifterScraper.php';
$scraper = new ShifterScraper(getenv('SHIFTER_SCRAPER_KEY'));
// One page, as structured data instead of HTML
$product = $scraper->fetch('https://shop.example.com/p/123', [
'name' => ['selector' => 'h1', 'output' => 'text'],
'price' => ['selector' => '.price', 'output' => 'text'],
]);
// A batch, ten requests in flight at a time; failed pages come back as null
$pages = $scraper->fetchMany($productUrls, ['name' => ['selector' => 'h1', 'output' => 'text']], 10);
API 키는 코드가 아니라 환경 변수나 프레임워크의 시크릿 설정에 보관한다.
테스트 방법
API를 대신하는 로컬 PHP 서버를 대상으로 클라이언트를 실행했다. 서버는 일반 페이지와, 성공하기 전에 429로 두 번 실패하는 페이지 하나, 항상 401을 반환하는 페이지 하나, 항상 408로 타임아웃되는 페이지 하나를 제공했다. 테스트는 다음을 확인했다:
- HTML과 추출된 JSON이 올바르게 돌아오는지;
- 불안정한 페이지가 세 번째 시도에서 성공하는지;
401은 한 번만 시도되고408은 실패하기 전까지 상한까지 재시도되는지;- 13개 페이지로 구성된 배치가 12개의 결과와 실패한 페이지에 대한
null하나를 반환하는지.
PHP 8.5에서 11개의 검사가 모두 통과했다.
애플리케이션에 적용하기
페이지 뷰 중에는 스크래핑하지 않는다. 사용자가 원격 사이트가 로드되기를 기다려서는 안 된다. 수집 작업은 큐 워커나 예약된 작업에서 실행하고, 저장된 결과를 서비스하라.
결과는 타임스탬프와 함께 저장한다. 각 레코드를 수집된 시간과 함께 저장하면, 애플리케이션이 데이터의 신선도를 보여주고 오래된 것만 새로고침할 수 있다.
적극적으로 캐시한다. 대부분의 데이터는 분 단위로 바뀌지 않는다. 대부분의 사이트에서 가격과 목록은 한 시간이나 하루에 한 번 새로고침해도 충분하며, 건너뛰는 요청마다 비용이 절감된다.
실패를 기록한다. 어떤 URL이 null로 돌아오는지, 그리고 왜 그런지 기록하라. 갑작스러운 증가는 보통 사이트가 레이아웃을 바꾸었고 추출 규칙을 업데이트해야 한다는 뜻이다.
플랜의 한도를 지킨다. 배치 크기를 플랜의 동시성 한도 이하로 설정한다. Shifter의 경우 플랜에 따라 한 번에 20개에서 500개의 요청 사이의 한도가 있다.
프록시를 직접 사용해야 할 때
대부분의 데이터 수집에는 스크래핑 API가 적합하다. 렌더링, 재시도, IP 선택을 대신 처리해 주기 때문이다. HTTP 클라이언트를 완전히 제어해야 하거나 하나의 IP로 긴 세션을 유지해야 하는 경우처럼, 일부 작업은 여전히 직접적인 프록시 연결이 더 적합하다. cURL과 Guzzle로 PHP에서 레지덴셜 프록시 사용하기 가이드에서 이 방법을 다루고 있다.
FAQ
PHP로 웹 스크래핑을 할 수 있나요?
가능하다. PHP의 curl 확장은 curl_multi를 이용한 병렬 요청을 포함해 HTTP를 잘 처리한다. 여기에 web scraping API를 결합하면 애플리케이션이 프록시, 브라우저, 차단을 직접 관리할 필요가 없다.
PHP에서 web scraping API를 호출하는 데 Guzzle이 필요한가요?
아니다. 이 가이드의 클라이언트는 내장된 curl 확장만 사용한다. 애플리케이션이 이미 Guzzle을 사용하고 있다면 그것도 사용할 수 있다.
PHP에서 JavaScript로 렌더링되는 페이지는 어떻게 스크래핑하나요?
render_js를 전달해 API가 페이지를 렌더링하도록 요청하면 된다. 이 클라이언트에서는 매개변수로 노출되어 있다. API가 브라우저를 실행하고 렌더링된 페이지를 반환하므로, PHP 쪽에서는 헤드리스 브라우저가 필요 없다.
한 번에 몇 개의 요청을 보낼 수 있나요?
scraping API 플랜이 허용하는 만큼 가능하다. fetchMany() 배치 크기를 그 한도 이하로 맞추면 된다.
결론
PHP 애플리케이션은 스크래핑 인프라를 직접 구축하지 않고도 웹 데이터를 활용할 수 있다. curl 기반의 작은 클라이언트, HTML 파싱 대신 추출 규칙, 성공 가능성이 있는 오류에 대해서만 수행하는 재시도, 그리고 플랜에 맞춘 크기의 병렬 배치만 있으면 된다. 이를 큐에서 실행하고 결과를 타임스탬프와 함께 저장하면, 애플리케이션은 사용자가 다른 누군가의 웹사이트를 기다리게 하지 않으면서도 최신 데이터를 얻을 수 있다.